Skip to content

Commit 940ba6a

Browse files
authored
Merge pull request #344 from TraderAlice/UI-issue
feat: Ask-Alice quick-chat launch (seeded TUI + daily chat workspace)
2 parents eb34c6d + 86680ef commit 940ba6a

22 files changed

Lines changed: 909 additions & 86 deletions

File tree

src/webui/routes/workspaces.ts

Lines changed: 249 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { listDir, PathTraversal, readWorkspaceFile } from '../../workspaces/file
2525
import { gitLog, gitStatus } from '../../workspaces/git-service.js';
2626
import { logger as launcherLogger } from '../../workspaces/logger.js';
2727
import type { SessionRecord } from '../../workspaces/session-registry.js';
28+
import type { WorkspaceMeta } from '../../workspaces/workspace-registry.js';
2829
import { HeadlessCapacityError, resumeFromRecord, type SessionFactoryContext, type WorkspaceService } from '../../workspaces/service.js';
2930
import type { WorkspaceAiCred } from '../../workspaces/cli-adapter.js';
3031
import { 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.
4041
const AGENT_SESSION_ID_RE = /^[A-Za-z0-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+
42101
export 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)

src/workspaces/adapters/claude.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,14 @@ export const claudeAdapter: CliAdapter = {
6363

6464
composeCommand(base: readonly string[], ctx: SpawnContext): readonly string[] {
6565
const cmd = [...base, '--settings', AUTOTRUST_SETTINGS];
66-
if (ctx.resume === undefined) return cmd;
66+
if (ctx.resume === undefined) {
67+
// Quick-chat seed: `claude [flags] -- <prompt>` opens the interactive TUI
68+
// and auto-submits the prompt. The `--` end-of-options terminator (same as
69+
// the headless path) keeps a prompt starting with `-`/`--` from being
70+
// mis-parsed as a flag (claude accepts `--` interactively; verified).
71+
if (ctx.initialPrompt) return [...cmd, '--', ctx.initialPrompt];
72+
return cmd;
73+
}
6774
if (ctx.resume === 'last') {
6875
throw new Error(
6976
'claude adapter: "last" resume not supported — use --resume <sessionId> or undefined (fresh)',

src/workspaces/adapters/codex.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,15 @@ export const codexAdapter: CliAdapter = {
7979
*/
8080
composeCommand(_base: readonly string[], ctx: SpawnContext): readonly string[] {
8181
const head = codexMcpHead(ctx);
82-
if (ctx.resume === undefined) return head;
82+
if (ctx.resume === undefined) {
83+
// Quick-chat seed: `codex [-c …] -- <prompt>` opens the interactive TUI on
84+
// that prompt ("Optional user prompt to start the session" per `codex
85+
// --help`). `--` terminates options so a `-`-leading prompt is safe (codex
86+
// accepts `--` at the top level; verified). Seeding only on fresh spawns —
87+
// codex's `resume <id>` subcommand has no positional-prompt slot.
88+
if (ctx.initialPrompt) return [...head, '--', ctx.initialPrompt];
89+
return head;
90+
}
8391
if (ctx.resume === 'last') return [...head, 'resume', '--last'];
8492
return [...head, 'resume', ctx.resume.sessionId];
8593
},

0 commit comments

Comments
 (0)