@@ -25,6 +25,7 @@ import { listDir, PathTraversal, readWorkspaceFile } from '../../workspaces/file
2525import { gitLog , gitStatus } from '../../workspaces/git-service.js' ;
2626import { logger as launcherLogger } from '../../workspaces/logger.js' ;
2727import type { SessionRecord } from '../../workspaces/session-registry.js' ;
28+ import type { WorkspaceMeta } from '../../workspaces/workspace-registry.js' ;
2829import { HeadlessCapacityError , resumeFromRecord , type SessionFactoryContext , type WorkspaceService } from '../../workspaces/service.js' ;
2930import type { WorkspaceAiCred } from '../../workspaces/cli-adapter.js' ;
3031import { addCredential , readCredentials , credentialWires , credentialWireShapeEnum , type Credential } from '../../core/config.js' ;
@@ -39,9 +40,203 @@ const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]
3940// adapter's own resume flag.
4041const AGENT_SESSION_ID_RE = / ^ [ A - Z a - z 0 - 9 _ . - ] { 8 , 128 } $ / ;
4142
43+ /** Upper bound on a quick-chat seed prompt — matches the headless-dispatch cap. */
44+ const MAX_SEED_PROMPT = 16000 ;
45+
46+ /** The template quick-chat reuses-or-creates its workspace from. */
47+ const QUICK_CHAT_TEMPLATE = 'chat' ;
48+
49+ const MONTH_ABBR = [ 'jan' , 'feb' , 'mar' , 'apr' , 'may' , 'jun' , 'jul' , 'aug' , 'sep' , 'oct' , 'nov' , 'dec' ] as const ;
50+
51+ /**
52+ * Tag for TODAY's chat workspace — `chat-<mon><day>` (e.g. `chat-jun15`).
53+ * Quick-chat is one-workspace-per-DAY: today's conversations are sessions inside
54+ * today's workspace. The format mirrors the frontend's `defaultTagFor`
55+ * (`<template>-<month><day>`, en-US short month lowercased) so a quick-chat-
56+ * created daily workspace is byte-identical to one created from the form on the
57+ * same day — the two converge on the same workspace instead of duplicating.
58+ */
59+ function todayChatTag ( ) : string {
60+ const now = new Date ( ) ;
61+ return `${ QUICK_CHAT_TEMPLATE } -${ MONTH_ABBR [ now . getMonth ( ) ] } ${ now . getDate ( ) } ` ;
62+ }
63+
64+ /**
65+ * Validate an optional quick-chat seed prompt (the first message a fresh
66+ * interactive TUI opens already working on). Returns the trimmed prompt, `null`
67+ * when absent/blank (→ a normal unseeded fresh spawn), or a `{error}` to surface
68+ * as a 400. Mirrors the headless-dispatch validation so the interactive-seed and
69+ * one-shot paths agree on shape + cap.
70+ */
71+ function parseSeedPrompt (
72+ raw : unknown ,
73+ ) : { prompt : string } | { error : string ; message : string } | null {
74+ if ( raw === undefined || raw === null ) return null ;
75+ if ( typeof raw !== 'string' ) {
76+ return { error : 'bad_request' , message : 'initialPrompt must be a string' } ;
77+ }
78+ const trimmed = raw . trim ( ) ;
79+ if ( trimmed . length === 0 ) return null ;
80+ if ( trimmed . length > MAX_SEED_PROMPT ) {
81+ return { error : 'prompt_too_long' , message : `max ${ MAX_SEED_PROMPT } chars` } ;
82+ }
83+ return { prompt : trimmed } ;
84+ }
85+
86+ /** The 201 body both `/:id/sessions/spawn` and `/quick-chat` return. */
87+ interface SpawnedSessionBody {
88+ readonly sessionId : string ;
89+ readonly wsId : string ;
90+ readonly name : string ;
91+ readonly pid : number ;
92+ readonly agent : string ;
93+ readonly agentSessionId : string | null ;
94+ readonly startedAt : number ;
95+ }
96+
97+ type SpawnSessionResult =
98+ | { readonly ok : true ; readonly session : SpawnedSessionBody }
99+ | { readonly ok : false ; readonly status : number ; readonly body : { error : string ; message ?: string } } ;
100+
42101export function createWorkspaceRoutes ( svc : WorkspaceService ) : Hono {
43102 const app = new Hono ( ) ;
44103
104+ /**
105+ * Spawn one interactive PTY session in an existing workspace — the shared
106+ * core of `POST /:id/sessions/spawn` and `POST /quick-chat` (so the two never
107+ * drift on bootstrap / record-creation / pool-spawn). Resolves the adapter,
108+ * runs its bootstrap, pre-allocates the SessionRecord, and hands the
109+ * SessionFactoryContext (incl. the optional fresh-spawn `initialPrompt`) to
110+ * the pool. Returns the SpawnedSession body or an HTTP-mappable error.
111+ */
112+ async function spawnInteractiveSession (
113+ meta : WorkspaceMeta ,
114+ opts : {
115+ readonly agentId ?: string ;
116+ readonly resume ?: SessionFactoryContext [ 'resume' ] ;
117+ readonly initialPrompt ?: string ;
118+ } ,
119+ ) : Promise < SpawnSessionResult > {
120+ const id = meta . id ;
121+ const { agentId, resume, initialPrompt } = opts ;
122+ if ( agentId && ! svc . adapters . get ( agentId ) ) {
123+ return { ok : false , status : 400 , body : { error : 'unknown_agent' , message : `no adapter: ${ agentId } ` } } ;
124+ }
125+ const adapter = svc . resolveAdapter ( meta , agentId ) ;
126+ try {
127+ if ( adapter . bootstrap ) {
128+ await adapter . bootstrap ( { wsId : id , cwd : meta . dir , launcherRepoRoot : svc . config . launcherRepoRoot } ) ;
129+ }
130+ } catch ( err ) {
131+ launcherLogger . error ( 'adapter.bootstrap_failed' , { id, agent : adapter . id , err } ) ;
132+ return { ok : false , status : 500 , body : { error : 'bootstrap_failed' , message : ( err as Error ) . message } } ;
133+ }
134+ await svc . sessionRegistry . ensureLoaded ( id ) ;
135+ const prefix = adapter . namePrefix ?? adapter . id [ 0 ] ?? 's' ;
136+ const recordId = randomUUID ( ) ;
137+ const recordName = svc . sessionRegistry . nextName ( id , adapter . id , prefix ) ;
138+ const nowIso = new Date ( ) . toISOString ( ) ;
139+ const record : SessionRecord = {
140+ id : recordId ,
141+ wsId : id ,
142+ agent : adapter . id ,
143+ name : recordName ,
144+ createdAt : nowIso ,
145+ lastActiveAt : nowIso ,
146+ state : 'running' ,
147+ } ;
148+ try {
149+ await svc . sessionRegistry . create ( record ) ;
150+ } catch ( err ) {
151+ launcherLogger . error ( 'session_registry.create_failed' , { id, recordId, err } ) ;
152+ return { ok : false , status : 500 , body : { error : 'registry_failed' , message : ( err as Error ) . message } } ;
153+ }
154+ try {
155+ const ctx : SessionFactoryContext = {
156+ ...( resume !== undefined ? { resume } : { } ) ,
157+ ...( agentId !== undefined ? { agentId } : { } ) ,
158+ ...( initialPrompt !== undefined ? { initialPrompt } : { } ) ,
159+ recordId,
160+ recordName,
161+ } ;
162+ const session = svc . pool . spawn ( id , ctx ) ;
163+ launcherLogger . info ( 'workspace.session_spawned' , {
164+ id,
165+ sessionId : session . recordId ,
166+ name : session . name ,
167+ pid : session . pid ,
168+ agent : adapter . id ,
169+ resume : resume === undefined ? null : resume === 'last' ? 'last' : resume . sessionId ,
170+ seeded : resume === undefined && ! ! initialPrompt ,
171+ } ) ;
172+ return {
173+ ok : true ,
174+ session : {
175+ sessionId : session . recordId ,
176+ wsId : session . wsId ,
177+ name : session . name ,
178+ pid : session . pid ,
179+ agent : adapter . id ,
180+ agentSessionId : session . agentSessionId ,
181+ startedAt : session . startedAt ,
182+ } ,
183+ } ;
184+ } catch ( err ) {
185+ await svc . sessionRegistry . remove ( id , recordId ) . catch ( ( ) => undefined ) ;
186+ launcherLogger . error ( 'workspace.session_spawn_failed' , { id, err } ) ;
187+ return { ok : false , status : 500 , body : { error : 'spawn_failed' , message : ( err as Error ) . message } } ;
188+ }
189+ }
190+
191+ // TODAY's chat workspace, by its daily tag (`chat-jun15`). A workspace someone
192+ // happened to tag `chat-jun15` with a non-chat template doesn't count — the
193+ // daily bucket is a chat-template workspace.
194+ const findTodaysChat = ( ) : WorkspaceMeta | undefined => {
195+ const tag = todayChatTag ( ) ;
196+ return svc . registry . list ( ) . find ( ( w ) => w . template === QUICK_CHAT_TEMPLATE && w . tag === tag ) ;
197+ } ;
198+
199+ // Serializes quick-chat's find-or-create so two concurrent FIRST-OF-DAY
200+ // launches don't both bootstrap today's workspace — the loser's `registry.add`
201+ // would throw on the duplicate tag and leak an orphaned bootstrap dir.
202+ // In-process chain (quick-chat is low-frequency, single-process); the `.catch`
203+ // keeps a failed run from poisoning the gate forever.
204+ let chatWsGate : Promise < unknown > = Promise . resolve ( ) ;
205+
206+ const findOrCreateChatWorkspace = async ( ) : Promise <
207+ { ok : true ; meta : WorkspaceMeta } | { ok : false ; status : number ; body : { error : string ; message ?: string } }
208+ > => {
209+ const existing = findTodaysChat ( ) ;
210+ if ( existing ) return { ok : true , meta : existing } ;
211+ let created : Awaited < ReturnType < typeof svc . creator . create > > ;
212+ try {
213+ created = await svc . creator . create ( todayChatTag ( ) , QUICK_CHAT_TEMPLATE ) ;
214+ } catch ( err ) {
215+ // e.g. a concurrent create committed today's tag first. Re-find — the
216+ // winner's workspace now exists.
217+ const after = findTodaysChat ( ) ;
218+ if ( after ) return { ok : true , meta : after } ;
219+ launcherLogger . error ( 'quick_chat.create_threw' , { err } ) ;
220+ return { ok : false , status : 500 , body : { error : 'create_failed' , message : ( err as Error ) . message } } ;
221+ }
222+ if ( ! created . ok ) {
223+ // tag_in_use means today's workspace was created concurrently — re-find it.
224+ if ( created . code === 'tag_in_use' ) {
225+ const after = findTodaysChat ( ) ;
226+ if ( after ) return { ok : true , meta : after } ;
227+ }
228+ const status =
229+ created . code === 'tag_in_use' ? 409
230+ : created . code === 'unknown_template' ? 400
231+ : created . code === 'invalid_tag' ? 400
232+ : created . code === 'unknown_agent' ? 400
233+ : 500 ;
234+ launcherLogger . error ( 'quick_chat.create_failed' , { code : created . code , message : created . message } ) ;
235+ return { ok : false , status, body : { error : created . code , message : created . message } } ;
236+ }
237+ return { ok : true , meta : created . workspace } ;
238+ } ;
239+
45240 // ── templates / agents ───────────────────────────────────────────────────
46241
47242 app . get ( '/templates' , ( c ) => {
@@ -264,6 +459,7 @@ export function createWorkspaceRoutes(svc: WorkspaceService): Hono {
264459
265460 let resume : SessionFactoryContext [ 'resume' ] ;
266461 let agentId : string | undefined ;
462+ let initialPrompt : string | undefined ;
267463 try {
268464 const body = await safeJson ( c ) ;
269465 const fields = body && typeof body === 'object' ? ( body as Record < string , unknown > ) : { } ;
@@ -272,75 +468,65 @@ export function createWorkspaceRoutes(svc: WorkspaceService): Hono {
272468 else if ( typeof raw === 'string' && AGENT_SESSION_ID_RE . test ( raw ) ) resume = { sessionId : raw } ;
273469 const rawAgent = fields [ 'agent' ] ;
274470 if ( typeof rawAgent === 'string' && rawAgent . length > 0 ) agentId = rawAgent ;
471+ // Quick-chat seed (fresh-only): a first message the TUI opens already
472+ // working on. Ignored when resuming — seeding + resume is ambiguous on
473+ // codex's `resume <id>` / pi's `--session-id`.
474+ const seed = parseSeedPrompt ( fields [ 'initialPrompt' ] ) ;
475+ if ( seed && 'error' in seed ) return c . json ( seed , 400 ) ;
476+ if ( seed && resume === undefined ) initialPrompt = seed . prompt ;
275477 } catch ( err ) {
276478 return c . json ( { error : 'bad_request' , message : ( err as Error ) . message } , 400 ) ;
277479 }
278- if ( agentId && ! svc . adapters . get ( agentId ) ) {
279- return c . json ( { error : 'unknown_agent' , message : `no adapter: ${ agentId } ` } , 400 ) ;
280- }
281- const adapter = svc . resolveAdapter ( meta , agentId ) ;
282- try {
283- if ( adapter . bootstrap ) {
284- await adapter . bootstrap ( {
285- wsId : id ,
286- cwd : meta . dir ,
287- launcherRepoRoot : svc . config . launcherRepoRoot ,
288- } ) ;
289- }
290- } catch ( err ) {
291- launcherLogger . error ( 'adapter.bootstrap_failed' , { id, agent : adapter . id , err } ) ;
292- return c . json ( { error : 'bootstrap_failed' , message : ( err as Error ) . message } , 500 ) ;
293- }
294- await svc . sessionRegistry . ensureLoaded ( id ) ;
295- const prefix = adapter . namePrefix ?? adapter . id [ 0 ] ?? 's' ;
296- const recordId = randomUUID ( ) ;
297- const recordName = svc . sessionRegistry . nextName ( id , adapter . id , prefix ) ;
298- const nowIso = new Date ( ) . toISOString ( ) ;
299- const record : SessionRecord = {
300- id : recordId ,
301- wsId : id ,
302- agent : adapter . id ,
303- name : recordName ,
304- createdAt : nowIso ,
305- lastActiveAt : nowIso ,
306- state : 'running' ,
307- } ;
308- try {
309- await svc . sessionRegistry . create ( record ) ;
310- } catch ( err ) {
311- launcherLogger . error ( 'session_registry.create_failed' , { id, recordId, err } ) ;
312- return c . json ( { error : 'registry_failed' , message : ( err as Error ) . message } , 500 ) ;
313- }
480+ const result = await spawnInteractiveSession ( meta , {
481+ ...( agentId !== undefined ? { agentId } : { } ) ,
482+ ...( resume !== undefined ? { resume } : { } ) ,
483+ ...( initialPrompt !== undefined ? { initialPrompt } : { } ) ,
484+ } ) ;
485+ if ( ! result . ok ) return c . json ( result . body , result . status as 400 | 500 ) ;
486+ return c . json ( result . session , 201 ) ;
487+ } ) ;
488+
489+ // Quick-chat launch — the "type a message → you're in" front door, decoupled
490+ // from the multi-step create-workspace UI. Enters TODAY's chat workspace
491+ // (creating it on the day's first use), then spawns a fresh interactive session
492+ // seeded with the user's first message. One POST returns both the workspace
493+ // and the live session, so the client can drop the user straight into the TUI.
494+ // Body: { prompt: string; agent?: string }
495+ app . post ( '/quick-chat' , async ( c ) => {
496+ let prompt : string ;
497+ let agentId : string | undefined ;
314498 try {
315- const ctx : SessionFactoryContext = {
316- ...( resume !== undefined ? { resume } : { } ) ,
317- ...( agentId !== undefined ? { agentId } : { } ) ,
318- recordId,
319- recordName,
320- } ;
321- const session = svc . pool . spawn ( id , ctx ) ;
322- launcherLogger . info ( 'workspace.session_spawned' , {
323- id,
324- sessionId : session . recordId ,
325- name : session . name ,
326- pid : session . pid ,
327- agent : adapter . id ,
328- resume : resume === undefined ? null : resume === 'last' ? 'last' : resume . sessionId ,
329- } ) ;
330- return c . json ( {
331- sessionId : session . recordId ,
332- wsId : session . wsId ,
333- name : session . name ,
334- pid : session . pid ,
335- agent : adapter . id ,
336- agentSessionId : session . agentSessionId ,
337- startedAt : session . startedAt ,
338- } , 201 ) ;
499+ const body = await safeJson ( c ) ;
500+ const fields = body && typeof body === 'object' ? ( body as Record < string , unknown > ) : { } ;
501+ const seed = parseSeedPrompt ( fields [ 'prompt' ] ) ;
502+ if ( seed === null ) return c . json ( { error : 'prompt_required' } , 400 ) ;
503+ if ( 'error' in seed ) return c . json ( seed , 400 ) ;
504+ prompt = seed . prompt ;
505+ const rawAgent = fields [ 'agent' ] ;
506+ if ( typeof rawAgent === 'string' && rawAgent . length > 0 ) agentId = rawAgent ;
339507 } catch ( err ) {
340- await svc . sessionRegistry . remove ( id , recordId ) . catch ( ( ) => undefined ) ;
341- launcherLogger . error ( 'workspace.session_spawn_failed' , { id, err } ) ;
342- return c . json ( { error : 'spawn_failed' , message : ( err as Error ) . message } , 500 ) ;
508+ return c . json ( { error : 'bad_request' , message : ( err as Error ) . message } , 400 ) ;
343509 }
510+
511+ // One chat workspace per DAY: enter today's if it exists, else create it.
512+ // Each send is a new SESSION inside today's workspace (conversations =
513+ // sessions, resumable from the chat sidebar) — closer to a traditional
514+ // chatbot while staying aligned with the Workspace/Session model. Create is
515+ // heavy (bash + git + skill injection) but happens at most once per day. The
516+ // find-or-create runs through `chatWsGate` so concurrent first-of-day
517+ // launches don't double-bootstrap.
518+ const run = chatWsGate . catch ( ( ) => undefined ) . then ( ( ) => findOrCreateChatWorkspace ( ) ) ;
519+ chatWsGate = run ;
520+ const target = await run ;
521+ if ( ! target . ok ) return c . json ( target . body , target . status as 400 | 409 | 500 ) ;
522+ const meta = target . meta ;
523+
524+ const spawn = await spawnInteractiveSession ( meta , {
525+ ...( agentId !== undefined ? { agentId } : { } ) ,
526+ initialPrompt : prompt ,
527+ } ) ;
528+ if ( ! spawn . ok ) return c . json ( spawn . body , spawn . status as 400 | 500 ) ;
529+ return c . json ( { workspace : await svc . publicMeta ( meta ) , session : spawn . session } , 201 ) ;
344530 } ) ;
345531
346532 // pause / stop (alias)
0 commit comments