create-pmos 0.160.0 → 0.162.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.160.0",
3
+ "version": "0.162.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@dd003f0
1
+ kernel-source: pmos@4dfe5a0
2
2
  assembled: 2026-10-12
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-12T06:29:58Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T09:50:47Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-12T06:29:58Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T09:50:47Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-12T06:29:58Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T09:50:47Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -5,7 +5,8 @@
5
5
  * Starts a real server with its own state directory. Before it starts, a session record is written whose process does not exist and
6
6
  * whose file still says it runs: the server restores it as exited, and GET /api/runs must say it ended (it read "starting · 4 d" before).
7
7
  * Then one live shell is started, and the page checks: the live row reads the server's state, not "starting"; Open the session draws the
8
- * session's real terminal in a sheet on /c3.html and the address does not change; End asks first, Keep it keeps it, End ends it.
8
+ * session's real terminal in a sheet on /c3.html and the address does not change; the message box types into the session and presses Enter;
9
+ * End asks first, Keep it keeps it, End ends it.
9
10
  */
10
11
  const fs = require('fs');
11
12
  const path = require('path');
@@ -31,6 +32,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31
32
  async function until(fn, ms = 8000) { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await sleep(150); } return null; }
32
33
  async function waitFor(page, fn, arg, ms = 15000) { try { await page.waitForFunction(fn, arg, { timeout: ms }); return true; } catch { return false; } }
33
34
  const get = (p) => fetch(BASE + p).then((r) => r.json());
35
+ // the agent CLI's home for this run: the conversation test writes a session record and a transcript here, as claude would
36
+ const CLAUDE_HOME = path.join(T.STATE_DIR, 'claude-home');
34
37
 
35
38
  function startShell(name) {
36
39
  return new Promise((resolve) => {
@@ -58,7 +61,7 @@ function startShell(name) {
58
61
  fs.writeFileSync(path.join(sessDir, 'sess-900.json'), JSON.stringify({ id: 'sess-900', name: 'stale claude', agent: 'shell', state: 'working', created, tmux: 'pmos-gone-900', origin: 'manual', cwd: path.resolve(__dirname, '..'), root: path.resolve(__dirname, '..') }));
59
62
  fs.writeFileSync(path.join(sessDir, 'sess-900.scrollback'), '');
60
63
 
61
- srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
64
+ srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT), PMOS_CLAUDE_HOME: CLAUDE_HOME }) });
62
65
  await T.waitForServer(BASE);
63
66
 
64
67
  /* ── A: /api/runs believes the server, not a stale file ── */
@@ -102,6 +105,7 @@ function startShell(name) {
102
105
  await page.keyboard.press('Escape');
103
106
  await sleep(300);
104
107
  ok('C3 Esc in the terminal goes to the shell, not the sheet', await page.evaluate((i) => !!document.querySelector(`[data-c3-session-term="${i}"]`), id));
108
+ await page.keyboard.press('Control+u'); // the shell read that Esc as a Meta prefix: this key takes it, so the next line starts clean
105
109
  await page.click('[data-shell="sheet"] [aria-label="Close"]');
106
110
  ok('C4 the close button closes the terminal sheet', await waitFor(page, () => !document.querySelector('[data-c3-session-term]')));
107
111
  ok('C5 closing the sheet leaves the session running', await until(async () => ((await get('/api/sessions')).sessions || []).some((s) => s.id === id && s.state !== 'exited')));
@@ -114,6 +118,45 @@ function startShell(name) {
114
118
  ok('D1 End asks first', await waitFor(page, (i) => !!document.querySelector(`[data-end-confirm="${i}"]`), id));
115
119
  await page.click(`[data-end-confirm="${id}"] button:not([data-end-yes])`);
116
120
  ok('D2 Keep it keeps the session', await waitFor(page, (i) => !document.querySelector(`[data-end-confirm="${i}"]`), id) && ((await get('/api/sessions')).sessions || []).some((s) => s.id === id));
121
+
122
+ /* ── M: the message box types into the session and presses Enter ── */
123
+ const box = `[data-blank-panel="${id}"] [data-run-chat]`;
124
+ ok('M0 the message box is enabled', await page.isEnabled(box).catch(() => false));
125
+ const pidFile = path.join(T.STATE_DIR, 'c3-shell.pid');
126
+ await page.fill(box, 'echo $$ > ' + pidFile + '; echo c3-msg-$((6*7))');
127
+ await page.press(box, 'Enter');
128
+ ok('M2 the message shows in the panel as it is sent', await waitFor(page, (i) => /c3-msg/.test((document.querySelector(`[data-blank-panel="${i}"] [data-chat-feed] [data-chat="me"]`) || {}).textContent || ''), id, 5000));
129
+ const m1 = await until(async () => /c3-msg-42/.test((await get('/api/sessions/transcript?id=' + id + '&max=4000')).text || ''), 8000);
130
+ if (!m1) console.log(' transcript:', JSON.stringify(((await get('/api/sessions/transcript?id=' + id + '&max=4000')).text || '').slice(-300)));
131
+ ok('M1 the message reaches the session and Enter runs it', !!m1);
132
+ /* the conversation: a transcript for this shell, as claude writes one, shows in the panel as messages */
133
+ const shellPid = (() => { try { return fs.readFileSync(pidFile, 'utf8').trim(); } catch { return ''; } })();
134
+ const sessRow = ((await get('/api/sessions')).sessions || []).find((x) => x.id === id) || {};
135
+ const root = sessRow.root || sessRow.cwd || path.resolve(__dirname, '..');
136
+ fs.mkdirSync(path.join(CLAUDE_HOME, 'sessions'), { recursive: true });
137
+ fs.writeFileSync(path.join(CLAUDE_HOME, 'sessions', shellPid + '.json'), JSON.stringify({ pid: +shellPid, sessionId: 'conv-test-1', cwd: root, version: 'test' }));
138
+ const proj = path.join(CLAUDE_HOME, 'projects', root.replace(/\//g, '-'));
139
+ fs.mkdirSync(proj, { recursive: true });
140
+ const rec = (o) => JSON.stringify({ isSidechain: false, timestamp: new Date().toISOString(), ...o });
141
+ fs.writeFileSync(path.join(proj, 'conv-test-1.jsonl'), [
142
+ rec({ type: 'user', isMeta: true, message: { role: 'user', content: '<system-reminder>not shown</system-reminder>' } }),
143
+ rec({ type: 'user', message: { role: 'user', content: 'What changed today?' } }),
144
+ rec({ type: 'assistant', message: { id: 'msg_1', model: 'claude-test', role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'git log' } }] } }),
145
+ rec({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: 'ok' }] } }),
146
+ rec({ type: 'assistant', message: { id: 'msg_2', model: 'claude-test', role: 'assistant', content: [{ type: 'text', text: 'Two things changed:\n- the **panel** reads the transcript\n- `send` works' }] } }),
147
+ ].join('\n') + '\n');
148
+ const feedSel = `[data-blank-panel="${id}"] [data-chat-feed]`;
149
+ // the server re-resolves a session's transcript at most every 2 s
150
+ const conv = (await until(async () => { const c = await get('/api/sessions/conversation?id=' + id); return c.messages && c.messages.length ? c : null; }, 8000)) || {};
151
+ ok('M3 the server reads the conversation from the transcript', conv.ok && (conv.messages || []).map((m) => m.role).join(',') === 'user,tool,assistant', JSON.stringify(conv).slice(0, 300));
152
+ ok('M4 the panel shows the prompt, the tool line and the reply', await waitFor(page, (sel) => {
153
+ const f = document.querySelector(sel); if (!f) return false;
154
+ const me = [...f.querySelectorAll('[data-chat="me"]')].some((n) => n.textContent === 'What changed today?');
155
+ const tool = [...f.querySelectorAll('[data-chat="tool"]')].some((n) => /Used Bash/.test(n.textContent));
156
+ const bot = f.querySelector('[data-chat="bot"]');
157
+ return me && tool && !!bot && !!bot.querySelector('li b') && !!bot.querySelector('li code') && !/not shown/.test(f.textContent);
158
+ }, feedSel, 10000));
159
+
117
160
  await page.click(`[data-end-session="${id}"]`);
118
161
  await page.click(`[data-end-yes="${id}"]`);
119
162
  const gone = async () => !((await get('/api/sessions')).sessions || []).some((s) => s.id === id);
@@ -121,6 +164,30 @@ function startShell(name) {
121
164
  ok('D3 End ends the session', !!(await until(gone, 8000)));
122
165
  ok('D4 its row leaves Started by me', await waitFor(page, (s) => !document.querySelector(s), sel));
123
166
 
167
+ /* ── F: New session starts on console3 and opens its terminal here (c3-new-session; it went to the classic app before) ── */
168
+ const before = new Set(((await get('/api/sessions')).sessions || []).map((s) => s.id));
169
+ if (!(await page.isVisible('[data-c3t="new"]').catch(() => false))) await page.click('[data-c3pg="agent-button"]');
170
+ ok('F1 Started by me has a New button', await waitFor(page, () => !!document.querySelector('[data-c3t="new"]')));
171
+ await page.click('[data-c3t="new"]');
172
+ ok('F2 New opens the new-session sheet on this page', await waitFor(page, () => !!document.querySelector('[data-c3ns="form"]')) && page.url() === url0, page.url());
173
+ await page.fill('[data-c3ns="name"]', 'c3 new shell');
174
+ await page.selectOption('[data-c3ns="agent"]', 'shell');
175
+ await page.click('[data-c3ns="start"]');
176
+ const made = await until(async () => ((await get('/api/sessions')).sessions || []).find((s) => !before.has(s.id) && s.name === 'c3 new shell'), 10000);
177
+ ok('F3 Start starts the session on the server', !!made);
178
+ ok('F4 its terminal opens in a sheet on console3', !!made && await waitFor(page, (i) => !!document.querySelector(`[data-c3-session-term="${i}"] .xterm`), made.id) && page.url() === url0, page.url());
179
+ await page.click('[data-shell="sheet"] [aria-label="Close"]');
180
+ await waitFor(page, () => !document.querySelector('[data-c3-session-term]'));
181
+ await page.evaluate(() => window.dispatchEvent(new CustomEvent('pmos:open-session', { detail: {} })));
182
+ ok('F5 an open-session request with no session opens the new-session sheet, not the classic app', await waitFor(page, () => !!document.querySelector('[data-c3ns="form"]')) && page.url() === url0, page.url());
183
+ await page.click('[data-shell="sheet"] [aria-label="Close"]');
184
+ await waitFor(page, () => !document.querySelector('[data-c3ns="form"]'));
185
+ if (await page.isVisible('[data-c3pg="dock-new"]').catch(() => false)) {
186
+ await page.click('[data-c3pg="dock-new"]');
187
+ ok('F6 the dock\'s + opens the new-session sheet on this page', await waitFor(page, () => !!document.querySelector('[data-c3ns="form"]')) && page.url() === url0, page.url());
188
+ await page.click('[data-shell="sheet"] [aria-label="Close"]');
189
+ }
190
+
124
191
  ok('E1 the page threw no error', errors.length === 0, errors.join(' | '));
125
192
  return finish();
126
193
  })().catch((e) => { console.error(e); finish(1); });
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  type: Guide
3
- title: "Sessions in console3: open, read and end your sessions"
4
- description: "How the Sessions panel in console3 groups your sessions, how to open a session's terminal without leaving console3, and how to end a session."
3
+ title: "Sessions in console3: start, open, message, read and end your sessions"
4
+ description: "How the Sessions panel in console3 groups your sessions, how to start a session, how to open a session's terminal without leaving console3, how to send a message to a session, and how to end a session."
5
5
  timestamp: 2026-10-12
6
6
  ---
7
7
 
8
- # Sessions in console3: open, read and end your sessions
8
+ # Sessions in console3: start, open, message, read and end your sessions
9
9
 
10
10
  Press the **Sessions** button in the console3 header to open the panel. It is the bolt icon with no label. Point at it to see its name. When a session needs you, a count shows on it. It shows your sessions in four groups: **On a card**, **Started by me**, **Past (7 days)** and **Automatic**.
11
11
 
@@ -13,6 +13,24 @@ Press the **Sessions** button in the console3 header to open the panel. It is th
13
13
 
14
14
  The dock is open on the Briefing. On the other tabs it is folded away, and no rail of buttons is left at the right edge. To open the dock again, press Ctrl J (⌘J on a Mac), or use **Open the dock** in the **More** menu.
15
15
 
16
+ ## Start a session
17
+
18
+ You start a session in console3. You do not need the classic app.
19
+
20
+ 1. Do one of these steps:
21
+ - Press **+** in the header of the dock.
22
+ - Press **+ session** on a branch that has no session.
23
+ - Press **New** next to **Started by me** in the Sessions panel.
24
+ 2. The **New session** sheet opens. Type a name, or leave it empty to use `Shell` or `Claude`.
25
+ 3. Select the **Agent**: Shell or Claude. A Claude session uses the profile, the launch flags and the tier from Settings.
26
+ 4. Select where the session runs in **Runs on**:
27
+ - **Automatic**: the server selects the tree.
28
+ - A listed tree: the session runs in that working tree.
29
+ - **New branch**: type the branch name. The session runs in its own working copy of the new branch.
30
+ 5. Press **Start**. The session starts, and its terminal opens in a sheet on the same page.
31
+
32
+ If the server cannot start the session, the sheet shows the reason.
33
+
16
34
  ## Read a row
17
35
 
18
36
  Each row under **Started by me** shows the state of the session, how long ago it started, and its tree. The state comes from the workspace server, for example `working` or `quiet`.
@@ -28,6 +46,33 @@ The terminal of the session opens in a panel on the same page. You can type in i
28
46
 
29
47
  The **Open the session** link in the sessions dock opens the same panel.
30
48
 
49
+ ## Send a message to a session
50
+
51
+ 1. Press the row to open it.
52
+ 2. Type your message in the message box.
53
+ 3. Press Enter.
54
+
55
+ Your message is typed into the session, and Enter is pressed. The message shows in the panel at once.
56
+
57
+ ## Read the conversation
58
+
59
+ The panel shows the conversation of a Claude session:
60
+
61
+ - Your messages are on the right.
62
+ - The replies of Claude are on the left. Lists, bold text and code show as formatted text.
63
+ - A grey line, for example "Used Bash, Read ×2", shows the tools that Claude used. The input of a tool does not show.
64
+ - While Claude works, a grey line says so.
65
+
66
+ The panel reads the conversation from the transcript that Claude Code writes on this computer. Nothing is sent to a different place. The panel updates while it is open.
67
+
68
+ A session that is not a Claude session, or a shell where Claude has not started, has no conversation. The panel shows what you sent, and the output is in the terminal. To read it, press **Open the session**.
69
+
70
+ The **Message** box in the dock and the reply line below a card do the same.
71
+
72
+ If the connection to the workspace server is down, the message is not sent. The message stays in the box, and a notice tells you.
73
+
74
+ **Try again** on a session that has not started is not available. The session can wait for a question, for example whether to trust the folder. Open the session and read the question first.
75
+
31
76
  ## End a session
32
77
 
33
78
  1. Press the row to open it.
@@ -7,7 +7,7 @@ timestamp: 2026-10-12
7
7
 
8
8
  # Guide: layout
9
9
 
10
- This page describes the classic workspace, at `/classic.html`. The address `/` opens the console (see [the Briefing](briefing.md)). The console's More menu has **Open the classic app**, and a session or branch is started there.
10
+ This page describes the classic workspace, at `/classic.html`. The address `/` opens the console (see [the Briefing](briefing.md)). The console's More menu has **Open the classic app**. You can start a session in the console too (see [Sessions in console3](console3-sessions.md)).
11
11
 
12
12
  On a desktop screen the workspace is one frame. It has one top bar, the place on screen under it, the detail when you open one, and the agent dock at the right. There is no left sidebar.
13
13
 
@@ -315,5 +315,90 @@ function readHarness(file, clean = (x) => x) {
315
315
  return out;
316
316
  }
317
317
 
318
- module.exports = { findTranscript, readHarness, prettyTool, slugFor, resolvePid, descendants,
318
+ /* ── The conversation (console3's session panel) ──────────────────────────────────────────────
319
+ * The PM's prompts and the agent's words, read from the same transcript, so the panel can show the
320
+ * conversation without parsing terminal bytes. Only what a person would call the conversation:
321
+ * · a `user` record with string content (or text blocks) that is not meta, a sidechain or a compact
322
+ * summary, and is not the CLI's own command bookkeeping — a slash command shows as `/name`;
323
+ * · an `assistant` record's text blocks, from a real model turn (never `<synthetic>`);
324
+ * · tool calls fold into one activity line between them ("Used Bash, Read ×2"), never their input,
325
+ * which can hold anything.
326
+ * Every string goes through `clean` (server.js's scrub, D08). The newest `limit` items are returned. */
327
+ const CONV_LIMIT = 80;
328
+ const CONV_TEXT_MAX = 8000;
329
+ const CLI_NOISE = /^\s*<(local-command-stdout|local-command-stderr|command-message|command-args|system-reminder|bash-input|bash-stdout|bash-stderr)>|^\s*Caveat: The messages below/;
330
+
331
+ function userText(r) {
332
+ const m = r.message || {};
333
+ let t = '';
334
+ if (typeof m.content === 'string') t = m.content;
335
+ else if (Array.isArray(m.content)) t = m.content.filter((c) => c && c.type === 'text' && typeof c.text === 'string').map((c) => c.text).join('\n');
336
+ if (!t.trim()) return '';
337
+ const cmd = /<command-name>\s*\/?([^<\s]+)\s*<\/command-name>/.exec(t);
338
+ if (cmd) {
339
+ const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(t);
340
+ return '/' + cmd[1] + (args && args[1].trim() ? ' ' + args[1].trim() : '');
341
+ }
342
+ if (CLI_NOISE.test(t)) return '';
343
+ return t.trim();
344
+ }
345
+
346
+ function readConversation(file, clean = (x) => x, limit = CONV_LIMIT) {
347
+ let raw;
348
+ try {
349
+ const st = fs.statSync(file);
350
+ const fd = fs.openSync(file, 'r');
351
+ try {
352
+ const start = Math.max(0, st.size - MAX_BYTES);
353
+ const len = st.size - start;
354
+ const buf = Buffer.allocUnsafe(len);
355
+ fs.readSync(fd, buf, 0, len, start);
356
+ raw = buf.toString('utf8');
357
+ if (start > 0) raw = raw.slice(raw.indexOf('\n') + 1);
358
+ } finally { fs.closeSync(fd); }
359
+ } catch {
360
+ return { messages: [], missing: "can't read the conversation — the transcript could not be read" };
361
+ }
362
+ const out = [];
363
+ const cap = (t) => (t.length > CONV_TEXT_MAX ? t.slice(0, CONV_TEXT_MAX) + ' …' : t);
364
+ const push = (role, text, at) => { out.push({ role, text: clean(cap(text)), at: at || null }); };
365
+ const tool = (name, at) => {
366
+ const last = out[out.length - 1];
367
+ const n = clean(prettyTool(name));
368
+ if (last && last.role === 'tool') {
369
+ last.tools[n] = (last.tools[n] || 0) + 1;
370
+ last.at = at || last.at;
371
+ } else out.push({ role: 'tool', tools: { [n]: 1 }, at: at || null });
372
+ };
373
+ for (const line of raw.split('\n')) {
374
+ if (!line) continue;
375
+ let r;
376
+ try { r = JSON.parse(line); } catch { continue; }
377
+ if (r.isSidechain) continue;
378
+ if (r.type === 'user') {
379
+ if (r.isMeta || r.isCompactSummary || r.isVisibleInTranscriptOnly) continue;
380
+ const t = userText(r);
381
+ if (t) push('user', t, r.timestamp);
382
+ continue;
383
+ }
384
+ if (r.type !== 'assistant') continue;
385
+ const m = r.message || {};
386
+ if (!m.model || m.model === SYNTHETIC) continue;
387
+ for (const c of (Array.isArray(m.content) ? m.content : [])) {
388
+ if (!c) continue;
389
+ if (c.type === 'text' && typeof c.text === 'string' && c.text.trim()) {
390
+ const last = out[out.length - 1];
391
+ // one model turn can stream its text in several records: join them into one bubble
392
+ if (last && last.role === 'assistant' && last.msgId && last.msgId === m.id) last.text = clean(cap(last.text + '\n\n' + c.text.trim()));
393
+ else { push('assistant', c.text.trim(), r.timestamp); out[out.length - 1].msgId = m.id || null; }
394
+ } else if (c.type === 'tool_use' && c.name) tool(c.name, r.timestamp);
395
+ }
396
+ }
397
+ const messages = out.slice(-limit).map((x) => (x.role === 'tool'
398
+ ? { role: 'tool', tools: x.tools, at: x.at }
399
+ : { role: x.role, text: x.text, at: x.at }));
400
+ return { messages };
401
+ }
402
+
403
+ module.exports = { findTranscript, readHarness, readConversation, CONV_LIMIT, prettyTool, slugFor, resolvePid, descendants,
319
404
  MAX_BYTES, MAX_WALK_DEPTH, PS_SNAPSHOT_MS, defaultChildren };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.160.0",
3
+ "version": "0.162.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.160.0",
9
+ "version": "0.162.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.160.0",
3
+ "version": "0.162.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -1,5 +1,5 @@
1
1
  /* pm/c3-dock.js — the console3 dock on /c3.html: the mockup's repository, branch and session tree (console3.html renderDock, line 1957).
2
- * Header: the repository select, a start link and the fold button. Tree: one .br per working tree of the repository, its branch on
2
+ * Header: the repository select, the New session button (c3-new-session.js) and the fold button. Tree: one .br per working tree of the repository, its branch on
3
3
  * top and the sessions that run in that tree under it, each with a state glyph, a provider icon and a name. Below the tree: the
4
4
  * selected session's detail (what it is, where it runs, how long, the output tail, the Message box) and a scratchpad.
5
5
  * Real data only:
@@ -8,7 +8,7 @@
8
8
  * GET /api/sessions/transcript the output tail, already stripped of terminal codes
9
9
  * GET /api/runs the needs-you count only (a run that asks, holds a permission or needs the terminal); the cards are on the Terminal page
10
10
  * GET and POST /api/scratchpad the session's pad
11
- * The Message box, Send and End are DISABLED (drawn as the mockup's): no page press route sends a prompt to a session, and none is invented here.
11
+ * The Message box and Send type the message into the session and press Enter (c3-terminal-data.js send: the terminal's own socket). End asks first.
12
12
  * Icon language (c3-terminal-icons.js): the header's AGENT button (spark) opens the Terminal page, the full agent surfaces; the TERMINAL glyph (`>_`) is every "Open the session" link.
13
13
  * A folded dock leaves no rail (PM, 2026-10-12): Ctrl J and the More menu open it again. */
14
14
  import { el } from './dom.js';
@@ -17,6 +17,10 @@ import { c3ic } from './pm-work-view.js';
17
17
  import { ic, TIPS } from './c3-terminal-icons.js';
18
18
  import { source, needsYou } from './c3-terminal-data.js';
19
19
  import { toast } from './c3-toast.js';
20
+ import { button } from './primitives.js';
21
+
22
+ /* a primitives button (keyboard, role) carrying the dock's own look and children */
23
+ const c3btn = (attrs, onClick, ...kids) => { const b = button({ variant: 'ghost', attrs, onClick }); b.replaceChildren(...kids); return b; };
20
24
 
21
25
  /* the mockup's SM (line 1953): glyph and class by state. The server's `shown` words map onto them. */
22
26
  const SM = { working: ['●', 'w'], needs: ['◆', 'n'], quiet: ['◼', ''], blocked: ['◇', 'b'], exited: ['○', ''] };
@@ -25,7 +29,6 @@ const shownOf = (s, ended) => (ended && ended.has(s.id) ? 'exited' : s.shown);
25
29
  const PI = { claude: 'term', cloud: 'cloud', codex: 'bolt', shell: 'term', local: 'term' }; // spark is reserved for the agent surfaces
26
30
  const PN = { claude: 'Claude', cloud: 'Claude cloud', codex: 'Codex', shell: 'Shell', local: 'Local' };
27
31
  const NOEND = 'Ending a session needs the page route /api/session/remove, which did not answer. Use the terminal in the classic app (/classic.html).';
28
- const MORE = 'Sending a prompt is not available on this page: no safe route sends one. Use the session\'s terminal in the classic app (/classic.html).';
29
32
  const MAX_TREES = 10;
30
33
 
31
34
  const ago = (ms) => {
@@ -50,13 +53,15 @@ export function createDock(opts) {
50
53
  const side = el('aside', { class: 'c3dock dock', 'aria-label': 'Sessions', 'data-c3pg': 'dock' },
51
54
  el('div', { class: 'dk-h' }, c3ic('repo', 'sm'), sel,
52
55
  listBtn,
53
- el('a', { class: 'ib sm', href: '/classic.html', title: 'Start a branch and session in the classic app. A session starts over the terminal connection; the page has no route for it.', 'aria-label': 'Start a branch and session in the classic app' }, c3ic('plus', 'sm')),
56
+ c3btn({ class: 'ib sm', title: 'New session', 'aria-label': 'New session', 'data-c3pg': 'dock-new' }, () => newSession(), c3ic('plus', 'sm')),
54
57
  el('button', { type: 'button', class: 'ib sm', title: 'Fold the dock (Ctrl J)', 'aria-label': 'Fold the dock', onclick: () => o.setOpen && o.setOpen(false) }, c3ic('fold'))),
55
58
  body);
56
59
 
57
60
  const withEnd = (s) => (S.ended.has(s.id) ? { ...s, shown: 'exited', label: 'ended' } : s);
58
61
  const mine = () => S.sessions.filter((s) => s.ws === S.ws && !s.automatic).map(withEnd);
59
62
  const inTree = (dir) => mine().filter((s) => s.tree === dir).sort((a, b) => (a.shown === 'exited') - (b.shown === 'exited') || String(b.created).localeCompare(String(a.created)));
63
+ /* a new session: the sheet asks name, agent and where it runs, starts it over the terminal connection and opens its terminal here (c3-new-session) */
64
+ const newSession = (on) => import('./c3-new-session.js').then((m) => m.openNewSession({ ws: S.ws, trees: (S.trees || []).filter((t) => t.ws === S.ws || t.main), on, onStarted: (id) => { S.sel = id; refresh(); } }));
60
65
  const pick = (id) => { S.sel = id; S.selTree = null; S.confirm = null; drawTree(); drawDetail(); if (o.setOpen) o.setOpen(true); };
61
66
 
62
67
  function row(s) {
@@ -73,7 +78,7 @@ export function createDock(opts) {
73
78
  const ss = inTree(t.dir);
74
79
  const body = ss.length ? ss.slice(0, 4).map(row)
75
80
  : [t.main ? el('span', { class: 'dim mono', style: 'font-size:var(--fs-1);padding:2px 6px' }, 'no session')
76
- : el('a', { class: 'chip2 lk', href: '/classic.html', title: 'Start a session on this branch in the classic app' }, c3ic('plus', 'sm'), 'session')];
81
+ : c3btn({ class: 'chip2 lk', title: 'Start a session on this branch', 'data-c3pg': 'dock-new-here' }, () => newSession('ws:' + t.ws), c3ic('plus', 'sm'), 'session')];
77
82
  if (ss.length > 4) body.push(el('span', { class: 'dim mono', style: 'font-size:var(--fs-1);padding:2px 6px' }, '+' + (ss.length - 4) + ' more'));
78
83
  return el('div', { class: 'br', 'data-c3-tree': t.dir }, el('button', { type: 'button', class: 'bn', 'data-c3-branch': t.dir, 'aria-current': String(!!S.sel && ss.some((x) => x.id === S.sel)), title: ss.length ? 'Open the session on this branch' : 'This branch has no session', onclick: () => { if (ss[0]) pick((ss.find((x) => x.shown !== 'exited') || ss[0]).id); else { S.sel = null; S.selTree = t.dir; drawTree(); drawDetail(); } },
79
84
  style: 'border:0;background:none;text-align:left;width:100%;cursor:pointer' }, c3ic('branch', 'sm'), t.branch || '(detached)'), el('div', { class: 'ss' }, ...body));
@@ -88,7 +93,7 @@ export function createDock(opts) {
88
93
 
89
94
  async function drawDetail() {
90
95
  const s = mine().find((x) => x.id === S.sel);
91
- if (!s) { const bt = S.selTree && S.trees && S.trees.find((x) => x.dir === S.selTree); detail.replaceChildren(el('p', { class: 'dim mono dk-empty', 'data-c3pg': 'dock-empty' }, bt ? (bt.branch || 'This branch') + ' has no session. A session starts in the classic app.' : mine().length ? 'Pick a session above.' : 'No session runs in this repository.')); return; }
96
+ if (!s) { const bt = S.selTree && S.trees && S.trees.find((x) => x.dir === S.selTree); detail.replaceChildren(el('p', { class: 'dim mono dk-empty', 'data-c3pg': 'dock-empty' }, bt ? (bt.branch || 'This branch') + ' has no session. Press + session to start one.' : mine().length ? 'Pick a session above.' : 'No session runs in this repository.')); return; }
92
97
  const t = S.trees && S.trees.find((x) => x.dir === s.tree);
93
98
  const started = ago(Date.now() - Date.parse(s.created));
94
99
  const last = Number.isFinite(s.lastOutputAt) ? ago(Date.now() - s.lastOutputAt) : null;
@@ -99,6 +104,15 @@ export function createDock(opts) {
99
104
  const r = await pmPost('/api/scratchpad', { session: s.id, text: pad.value });
100
105
  saveBtn.title = r && r.ok ? 'Saved' : 'Not saved: ' + ((r && r.error) || 'unreadable');
101
106
  } }, c3ic('notes', 'sm'));
107
+ const ended = s.shown === 'exited';
108
+ const msg = el('input', { type: 'text', disabled: ended, placeholder: s.shown === 'needs' ? 'Your answer' : 'Message', 'aria-label': 'Message', title: 'Types this into the session and presses Enter', 'data-c3pg': 'dock-msg' });
109
+ async function sendMsg() {
110
+ const v = msg.value.trim(); if (!v) return;
111
+ msg.value = '';
112
+ const r = await source().send(s.id, { verb: 'free', text: v });
113
+ if (r && r.ok) toast({ text: 'Sent to ' + s.name + '.' }); else msg.value = v;
114
+ }
115
+ msg.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); sendMsg(); } });
102
116
  detail.replaceChildren(...[
103
117
  el('div', { class: 'chips2' }, el('b', { style: 'font-size:var(--fs-3)' }, s.name), el('span', { class: 'chips2 dim mono', style: 'font-size:var(--fs-1)' }, c3ic(PI[s.agent] || 'term', 'sm'), (PN[s.agent] || s.agent) + ' · ' + ((t && t.branch) || 'no branch'))),
104
118
  el('div', { class: 'dim mono', style: 'font-size:var(--fs-1)' }, facts),
@@ -107,8 +121,8 @@ export function createDock(opts) {
107
121
  onclick: (e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button > 0) return; e.preventDefault(); import('./c3-session-term.js').then((m) => m.openSessionTerm({ id: s.id, name: s.name })); } }, ic('term', 'sm'), 'Open the session'),
108
122
  term,
109
123
  el('div', { class: 'form' },
110
- el('input', { type: 'text', readonly: true, 'data-off': '', placeholder: s.shown === 'needs' ? 'Your answer' : 'Message', 'aria-label': 'Message', title: MORE, 'data-c3pg': 'dock-msg' }),
111
- el('button', { type: 'button', class: 'ib pri sm', disabled: true, title: MORE, 'aria-label': 'Send (not available here)', 'data-c3pg': 'dock-send' }, c3ic('out', 'sm')),
124
+ msg,
125
+ c3btn({ class: 'ib pri sm', disabled: ended, title: 'Send: types this into the session and presses Enter', 'aria-label': 'Send', 'data-c3pg': 'dock-send' }, sendMsg, c3ic('out', 'sm')),
112
126
  s.shown === 'exited' ? null : el('button', { type: 'button', class: 'ib sm', title: 'End the session', 'aria-label': 'End the session', 'aria-expanded': String(S.confirm === s.id), 'data-c3pg': 'dock-end', onclick: () => { S.confirm = S.confirm === s.id ? null : s.id; drawDetail(); } }, c3ic('stop', 'sm'))),
113
127
  S.confirm === s.id ? el('div', { class: 'dk-confirm', role: 'group', 'aria-label': 'End this session?', 'data-c3pg': 'dock-confirm' }, el('span', { class: 'dim mono' }, 'End it? Any pending change stays a proposal.'),
114
128
  el('button', { type: 'button', class: 'chip2 lk', 'data-c3pg': 'dock-end-yes', onclick: () => endSession(s) }, c3ic('stop', 'sm'), 'End'),
@@ -169,6 +183,7 @@ export function createDock(opts) {
169
183
  start() { document.addEventListener('keydown', onKey); drawTree(); refresh(); timer = setInterval(refresh, 15000); if (source().subscribe) S.unsub = source().subscribe(refresh); },
170
184
  stop() { if (timer) clearInterval(timer); if (S.unsub) S.unsub(); document.removeEventListener('keydown', onKey); },
171
185
  refresh,
186
+ newSession,
172
187
  };
173
188
  return api;
174
189
  }
@@ -0,0 +1,94 @@
1
+ /* pm/c3-new-session.js — start a session from console3, in a sheet, and open its terminal in the console (c3-new-session).
2
+ * openNewSession({ ws, trees, initiative, on, onStarted }) on: 'auto' (default), 'ws:<id>' (that tree) or 'new'
3
+ * Before this, every "start a session" press on console3 went to the classic app (/classic.html), because the page had no way
4
+ * to start one. A session starts over the terminal connection, so this uses the same request as the classic dock's new-session
5
+ * sheet (tab-term.js requestSpawn) and then opens the terminal with c3-session-term.js. Nothing opens unless the server says the
6
+ * session started; a refusal stays in the sheet as the server's sentence.
7
+ * Fields: name (empty: Shell or Claude, numbered when taken), agent (the Settings default first), and where it runs:
8
+ * Automatic the server's start policy picks the tree (the initiative's own working copy, or the main tree)
9
+ * a listed tree that working tree as it is
10
+ * New branch a new branch in its own working copy
11
+ * A Claude session takes the server's default profile and the Settings launch flags and tier, as the classic dock does. */
12
+ import { el } from './dom.js';
13
+ import { openSheet } from './c3-sheet.js';
14
+ import { button } from './primitives.js';
15
+ import { toast } from './c3-toast.js';
16
+ import { pmGet } from './http.js';
17
+ import { sessionDefaults, adoptProfileDefault, CLIS } from './dock.js';
18
+
19
+ const AGENT = { shell: 'Shell', claude: 'Claude' };
20
+
21
+ /** A free name: `base`, else `base 2`, `base 3`, … among the sessions that have not ended. Pure, for Node. */
22
+ export function freeName(base, sessions) {
23
+ const taken = new Set((sessions || []).filter((s) => s && s.state !== 'exited' && s.shown !== 'exited').map((s) => s.name));
24
+ if (!taken.has(base)) return base;
25
+ for (let i = 2; ; i += 1) if (!taken.has(base + ' ' + i)) return base + ' ' + i;
26
+ }
27
+
28
+ /** What requestSpawn is sent, from the form. Pure, for Node. Returns { error } when the form is not complete. */
29
+ export function spawnOpts(f, d) {
30
+ const claude = f.agent === 'claude';
31
+ const common = {
32
+ name: f.name, agent: f.agent, initiative: f.initiative || null,
33
+ profile: claude ? (f.profile === undefined ? null : f.profile) : null,
34
+ flags: claude && d && Array.isArray(d.flags) && d.flags.length ? d.flags.map((x) => ({ flag: x.flag, ...(x.value ? { value: x.value } : {}) })) : undefined,
35
+ tier: claude && d && d.tier && d.tier !== 'auto' ? d.tier : null,
36
+ workType: 'general',
37
+ };
38
+ if (f.on === 'new') {
39
+ const b = String(f.branch || '').trim();
40
+ if (!b) return { error: 'Type a name for the new branch.' };
41
+ return { ...common, ws: f.ws, branch: b, createBranch: true, isolate: true };
42
+ }
43
+ if (f.on && f.on.startsWith('ws:')) return { ...common, ws: f.on.slice(3), branch: null, createBranch: false, isolate: null };
44
+ return { ...common, ws: f.ws, start: true };
45
+ }
46
+
47
+ export function openNewSession(opts) {
48
+ const o = opts || {};
49
+ const d = sessionDefaults();
50
+ const trees = (o.trees || []).filter((t) => t && !t.missing && t.ws);
51
+ const F = { agent: CLIS.includes(d.cli) ? d.cli : 'shell', on: o.on && (o.on === 'new' || trees.some((t) => 'ws:' + t.ws === o.on)) ? o.on : 'auto', busy: false };
52
+
53
+ const name = el('input', { type: 'text', 'aria-label': 'Name', placeholder: 'Empty: Shell or Claude', 'data-c3ns': 'name' });
54
+ const agent = el('select', { 'aria-label': 'Agent', 'data-c3ns': 'agent', onchange: (e) => { F.agent = e.target.value; } },
55
+ ...CLIS.map((c) => el('option', { value: c, selected: c === F.agent }, AGENT[c] || c)));
56
+ const branch = el('input', { type: 'text', 'aria-label': 'New branch', placeholder: 'feature/…', 'data-c3ns': 'branch' });
57
+ const branchRow = el('label', { class: 'c3ns-f', hidden: F.on !== 'new' }, el('span', { class: 'dim mono' }, 'New branch'), branch);
58
+ const where = el('select', { 'aria-label': 'Runs on', 'data-c3ns': 'on', onchange: (e) => { F.on = e.target.value; branchRow.hidden = F.on !== 'new'; } },
59
+ el('option', { value: 'auto', selected: F.on === 'auto' }, o.initiative ? 'Automatic: the working copy of initiative/' + o.initiative : 'Automatic: the main working tree'),
60
+ ...trees.map((t) => el('option', { value: 'ws:' + t.ws, selected: F.on === 'ws:' + t.ws }, (t.main ? 'Main tree' : '⑂') + ' ' + (t.branch || 'detached'))),
61
+ el('option', { value: 'new', selected: F.on === 'new' }, 'New branch'));
62
+ const err = el('p', { class: 'c3ns-err', role: 'alert', 'data-c3ns': 'error' });
63
+ const go = button({ label: 'Start', variant: 'primary', size: 'sm', onClick: () => start(), attrs: { 'data-c3ns': 'start' } });
64
+ const body = el('div', { class: 'c3ns', 'data-c3ns': 'form' },
65
+ el('label', { class: 'c3ns-f' }, el('span', { class: 'dim mono' }, 'Name'), name),
66
+ el('label', { class: 'c3ns-f' }, el('span', { class: 'dim mono' }, 'Agent'), agent),
67
+ el('label', { class: 'c3ns-f' }, el('span', { class: 'dim mono' }, 'Runs on'), where),
68
+ branchRow, err, el('div', { class: 'c3ns-go' }, go));
69
+ const sheet = openSheet({ title: o.initiative ? 'New session for ' + o.initiative : 'New session', icon: 'term', body });
70
+ requestAnimationFrame(() => name.focus());
71
+
72
+ async function start() {
73
+ if (F.busy) return;
74
+ if (!o.ws) { err.textContent = 'Pick a repository first.'; return; }
75
+ F.busy = true; go.disabled = true; err.textContent = '';
76
+ const sessions = await pmGet('/api/sessions').then((r) => (r && Array.isArray(r.sessions) ? r.sessions : []), () => []);
77
+ const base = (AGENT[F.agent] || 'Shell') + (o.initiative ? ' · ' + o.initiative : '');
78
+ // the default profile is the server's; this browser's copy can be old, so a Claude start reads it first
79
+ const profile = F.agent === 'claude' ? adoptProfileDefault(await pmGet('/api/profiles').catch(() => null)) : null;
80
+ const req = spawnOpts({ name: name.value.trim() || freeName(base, sessions), agent: F.agent, initiative: o.initiative, profile, on: F.on, branch: branch.value, ws: o.ws }, d);
81
+ if (req.error) { err.textContent = req.error; F.busy = false; go.disabled = false; return; }
82
+ const term = await import('./tab-term.js').catch(() => null);
83
+ const res = term ? await term.requestSpawn(req) : { ok: false, error: 'The terminal connection could not be loaded on this page.' };
84
+ F.busy = false; go.disabled = false;
85
+ if (!res || !res.ok) { err.textContent = (res && res.error) || 'The session was not started.'; return; }
86
+ sheet.close();
87
+ if (o.onStarted) o.onStarted(res.id);
88
+ const m = await import('./c3-session-term.js');
89
+ m.openSessionTerm({ id: res.id, name: req.name });
90
+ // a session that shares its working tree with a running one is told so: their edits land in the same files
91
+ if (res.notice && /already running/.test(res.notice)) toast({ text: res.notice, timeout: 12000 });
92
+ }
93
+ return sheet;
94
+ }
@@ -0,0 +1,103 @@
1
+ /* pm/c3-session-chat.js — a session's conversation in console3's session panel: what the PM typed and what the agent answered.
2
+ * chatFeed(id) -> { node, sending(text) -> { done(ok) } } one feed per session, kept across the panel's redraws
3
+ * renderMd(text) -> Node the agent's markdown as nodes (fences, headings, lists, **bold**, `code`); no innerHTML
4
+ * The words come from GET /api/sessions/conversation (the agent CLI's own transcript, read by the server; nothing is parsed from terminal
5
+ * bytes). The feed polls while it is on the page and stops when the panel is gone. A message the PM just sent shows at once as
6
+ * "sending"; it is replaced by the transcript's copy when the agent CLI records it. Tool calls show as one quiet line ("Used Bash, Read ×2"),
7
+ * never their input. */
8
+ import { el } from './dom.js';
9
+ import { pmGet } from './http.js';
10
+
11
+ const POLL_MS = 1500;
12
+ const feeds = new Map(); // session id -> feed
13
+
14
+ const bold = (s) => String(s).split(/(\*\*[^*\n]+\*\*|`[^`\n]+`)/).map((p) => (/^\*\*[^*]+\*\*$/.test(p) ? el('b', null, p.slice(2, -2))
15
+ : /^`[^`]+`$/.test(p) ? el('code', null, p.slice(1, -1)) : p));
16
+
17
+ export function renderMd(text) {
18
+ const out = []; let list = null; let fence = null; let para = [];
19
+ const flush = () => { if (para.length) { out.push(el('p', null, ...bold(para.join(' ')))); para = []; } };
20
+ for (const l of String(text || '').replace(/\r\n?/g, '\n').split('\n')) {
21
+ if (fence) {
22
+ if (/^\s*```/.test(l)) { out.push(el('pre', null, el('code', null, fence.join('\n')))); fence = null; } else fence.push(l);
23
+ continue;
24
+ }
25
+ if (/^\s*```/.test(l)) { flush(); list = null; fence = []; continue; }
26
+ const li = /^\s*(?:[-*]|\d+[.)])\s+(.*)$/.exec(l);
27
+ if (li) {
28
+ flush();
29
+ const ordered = /^\s*\d/.test(l);
30
+ if (!list || list.ordered !== ordered) { list = { ordered, node: el(ordered ? 'ol' : 'ul') }; out.push(list.node); }
31
+ list.node.append(el('li', null, ...bold(li[1])));
32
+ continue;
33
+ }
34
+ list = null;
35
+ const h = /^#{1,6}\s+(.*)$/.exec(l);
36
+ if (h) { flush(); out.push(el('h4', null, ...bold(h[1]))); continue; }
37
+ if (!l.trim()) { flush(); continue; }
38
+ para.push(l.trim());
39
+ }
40
+ if (fence) out.push(el('pre', null, el('code', null, fence.join('\n'))));
41
+ flush();
42
+ return el('div', { class: 'c3t-md' }, ...out);
43
+ }
44
+
45
+ const toolLine = (tools) => 'Used ' + Object.entries(tools || {}).map(([n, k]) => (k > 1 ? n + ' ×' + k : n)).join(', ');
46
+ const WORKING = { agent: 'Claude is working…', tool: 'Claude is running a tool, or waits for your allow in the terminal…' };
47
+
48
+ function paint(f) {
49
+ const atEnd = f.node.scrollHeight - f.node.scrollTop - f.node.clientHeight < 40;
50
+ const d = f.data;
51
+ const msgs = d && Array.isArray(d.messages) ? d.messages : [];
52
+ const kids = msgs.map((m) => (m.role === 'user' ? el('div', { class: 'm me', 'data-chat': 'me' }, m.text)
53
+ : m.role === 'assistant' ? el('div', { class: 'm bot', 'data-chat': 'bot' }, renderMd(m.text))
54
+ : el('div', { class: 'm st', 'data-chat': 'tool' }, toolLine(m.tools))));
55
+ for (const p of f.pending) kids.push(el('div', { class: 'm me', 'data-chat': 'me', 'data-pending': '' }, p.text), p.sent ? null : el('div', { class: 'm st', 'data-chat': 'st' }, 'Sending…'));
56
+ // no transcript (a plain shell, another agent): what was sent stays here, and the output is in the terminal
57
+ if (d && d.missing && f.pending.some((p) => p.sent)) kids.push(el('div', { class: 'm st', 'data-chat': 'st' }, 'Sent. ' + d.missing + ' The output is in its terminal: press Open the session.'));
58
+ const working = d && WORKING[d.turn];
59
+ if (working && !f.pending.length) kids.push(el('div', { class: 'm st', 'data-chat': 'working', role: 'status' }, working));
60
+ if (!kids.filter(Boolean).length) kids.push(el('div', { class: 'm st', 'data-chat': 'empty' }, !d ? 'Reading the conversation…'
61
+ : d.missing ? d.missing : 'No messages yet. Type below to talk to the session.'));
62
+ f.node.replaceChildren(...kids.filter(Boolean));
63
+ if (atEnd || f.stick) { f.node.scrollTop = f.node.scrollHeight; f.stick = false; }
64
+ }
65
+
66
+ async function poll(f) {
67
+ if (f.busy) return;
68
+ f.busy = true;
69
+ try {
70
+ const r = await pmGet('/api/sessions/conversation?id=' + encodeURIComponent(f.id)).catch(() => null);
71
+ if (!r || !r.ok) { if (!f.data) { f.data = { messages: [], missing: (r && r.error) || 'The conversation could not be read.' }; paint(f); } return; }
72
+ const msgs = r.messages || [];
73
+ // a pending message is done when the transcript holds a user message with its text, newer than the send
74
+ const before = f.pending.length;
75
+ // (a session with no transcript keeps them: they are the only record here of what was sent)
76
+ f.pending = f.pending.filter((p) => !msgs.some((m) => m.role === 'user' && m.text.trim() === p.text.trim() && (!m.at || Date.parse(m.at) >= p.at - 5000))
77
+ && (r.missing || !p.sent || Date.now() - p.at < 60000));
78
+ if (r.stamp && f.data && f.data.stamp === r.stamp && f.data.turn === r.turn && before === f.pending.length) return;
79
+ f.data = r;
80
+ paint(f);
81
+ } finally { f.busy = false; }
82
+ }
83
+
84
+ export function chatFeed(id) {
85
+ let f = feeds.get(id);
86
+ if (f) return f;
87
+ f = { id, data: null, pending: [], busy: false, gone: 0, stick: true, node: el('div', { class: 'feed conv', 'data-chat-feed': id, 'aria-live': 'polite' }) };
88
+ // sending(text) shows the message at once and returns its handle: done(true) when it reached the session, done(false) takes it back
89
+ f.sending = (text) => {
90
+ const p = { text: String(text), at: Date.now(), sent: false };
91
+ f.pending.push(p); f.stick = true; paint(f);
92
+ return { done: (ok) => { if (ok) p.sent = true; else f.pending = f.pending.filter((x) => x !== p); paint(f); if (ok) setTimeout(() => poll(f), 400); } };
93
+ };
94
+ f.timer = setInterval(() => {
95
+ // the panel redraws by rebuilding its tree and appending this same node again; only a node that stays off the page ends the feed
96
+ if (!f.node.isConnected) { if (++f.gone > 2) { clearInterval(f.timer); feeds.delete(id); } return; }
97
+ f.gone = 0; poll(f);
98
+ }, POLL_MS);
99
+ feeds.set(id, f);
100
+ paint(f);
101
+ poll(f);
102
+ return f;
103
+ }
@@ -425,7 +425,9 @@ function dock() {
425
425
  });
426
426
  S.dockEl = S.dock.side;
427
427
  // a session opens in its own terminal sheet on this page (c3-session-term.js), not in the main app
428
- window.addEventListener('pmos:open-session', (e) => { const id = e.detail && e.detail.id; if (!id) { location.assign('/classic.html'); return; } import('./c3-session-term.js').then((m) => m.openSessionTerm({ id, name: e.detail.name })); });
428
+ // no id means "start one": the new-session sheet opens on this page (c3-new-session.js), not the classic app
429
+ window.addEventListener('pmos:open-session', (e) => { const id = e.detail && e.detail.id; if (!id) { S.dock.newSession(); return; } import('./c3-session-term.js').then((m) => m.openSessionTerm({ id, name: e.detail.name })); });
430
+ window.addEventListener('pmos:new-session', (e) => { e.preventDefault(); S.dock.newSession(e.detail && e.detail.on); });
429
431
  return [S.dock.side];
430
432
  }
431
433
  /* The dock is open on the Briefing and folded away on the other tabs. A folded dock leaves no rail (PM, 2026-10-12): the header's Sessions button, Ctrl J and the More menu reach it. */
@@ -1,19 +1,41 @@
1
1
  /* pm/c3-terminal-data.js — where the agent surfaces read and press, in one place.
2
2
  * source() -> { demo, getRuns(), answer(body), correct(body), canPrompt, why, ... }
3
- * REAL: GET /api/runs; POST /api/runs/answer and /api/runs/correction (a page press; the server refuses any other caller). Nothing here sends
4
- * a prompt to a session: no page route does. `canPrompt` is false and `why` says so; every control that would send one is drawn disabled with it.
3
+ * REAL: GET /api/runs; POST /api/runs/answer and /api/runs/correction (a page press; the server refuses any other caller). send(id, m) types
4
+ * the message into the session and presses Enter, on the page's terminal socket (tab-term.js typeIntoSession: the frame the terminal's own keys
5
+ * use, under the page's launch nonce). The reply is in the session's terminal, which Open the session shows.
5
6
  * DEMO: the fixtures of c3-demo-term.js, in the real response shapes, answers from memory and advances its own state; nothing is sent.
6
7
  *
7
8
  * needsYou(runs) is the one count for the needs-you badge: a run that asks, holds a permission, needs the terminal or did not start. */
8
9
  import { pmGet, pmPost } from './http.js';
10
+ import { toast } from './c3-toast.js';
9
11
 
10
- export const WHY_PROMPT = 'Sending a prompt to a session is not available on this page: no safe route sends one. To type to the session, press Open the session.';
12
+ export const SENT = 'Sent to the session. The reply shows in the session panel and in its terminal.';
13
+ const NOT_SENT = 'Not sent: the connection to the workspace server is down. Your message is still here.';
14
+
15
+ /* what each verb types into the session */
16
+ function textOf(m) {
17
+ if (!m) return '';
18
+ if (m.verb === 'free') return String(m.text || '');
19
+ if (m.verb === 'suggest') return String(m.prompt || m.label || '');
20
+ if (m.verb === 'revise') return 'Revise it: ' + String(m.arg || '') + '.';
21
+ if (m.verb === 'retry') return 'Please try that again.';
22
+ return '';
23
+ }
24
+ async function sendToSession(id, m) {
25
+ const text = textOf(m).trim();
26
+ if (!id || !text) return { ok: false, error: 'nothing to send' };
27
+ const term = await import('./tab-term.js').catch(() => null);
28
+ const sent = !!(term && typeof term.typeIntoSession === 'function' && await term.typeIntoSession(id, text));
29
+ if (!sent) toast({ text: NOT_SENT, tone: 'error' });
30
+ return sent ? { ok: true, say: { text: SENT } } : { ok: false, error: NOT_SENT };
31
+ }
11
32
 
12
33
  const real = {
13
- demo: false, canPrompt: false, why: WHY_PROMPT,
34
+ demo: false, canPrompt: true, why: '',
14
35
  getRuns: () => pmGet('/api/runs'),
15
36
  answer: (b) => pmPost('/api/runs/answer', b),
16
37
  correct: (b) => pmPost('/api/runs/correction', b),
38
+ send: sendToSession,
17
39
  };
18
40
  /* demo: c3-demo.js registers the fixtures' factory (window.__c3termfx_make) by importing c3-demo-term.js; the page is in demo when installDemo() ran (window.__c3demo). */
19
41
  export const isDemo = () => typeof window !== 'undefined' && !!window.__c3demo && typeof window.__c3termfx_make === 'function';
@@ -1664,6 +1664,14 @@
1664
1664
  .c3 .c3t-panel .m{padding:6px 12px;border-radius:12px;max-width:88%;overflow-wrap:anywhere}
1665
1665
  .c3 .c3t-panel .m.me{align-self:flex-end;background:var(--acc);color:var(--on-accent)}.c3 .c3t-panel .m.bot{background:var(--muted)}
1666
1666
  .c3 .c3t-panel .m.st{color:var(--muted-foreground);font:var(--fs-2) var(--mono);padding:0}
1667
+ /* a real session's conversation (c3-session-chat.js): taller than the demo feed, the agent's markdown inside its bubble */
1668
+ .c3 .c3t-panel .feed.conv{max-height:min(460px,55vh);padding-right:4px}
1669
+ .c3 .c3t-panel .m.me{white-space:pre-wrap}
1670
+ .c3 .c3t-panel .m.bot .c3t-md{display:grid;gap:6px}
1671
+ .c3 .c3t-panel .m.bot .c3t-md>*{margin:0}
1672
+ .c3 .c3t-panel .m.bot ul,.c3 .c3t-panel .m.bot ol{padding-left:18px}
1673
+ .c3 .c3t-panel .m.bot code{font:var(--fs-2) var(--mono)}
1674
+ .c3 .c3t-panel .m.bot pre{margin:0;padding:8px 10px;border-radius:8px;background:var(--background);border:1px solid var(--border);overflow:auto;max-height:240px}
1667
1675
  .c3 .c3t-tl2 .tlr{display:flex;gap:var(--sp-2);align-items:baseline;padding:6px 0;border-bottom:1px solid var(--border)}
1668
1676
  .c3 .c3t-tl2 .tlr .lab{width:78px}
1669
1677
  .c3 .c3t-edge{display:grid;grid-template-columns:minmax(0,260px) minmax(0,1fr);gap:var(--sp-4);align-items:start}
@@ -2218,3 +2226,11 @@
2218
2226
  /* the dock's note under the branch list and its empty detail (c3-dock.js) */
2219
2227
  .c3dock .dk-more { font-size: var(--fs-1); padding: 4px; }
2220
2228
  .c3dock .dk-empty { font-size: var(--fs-2); }
2229
+
2230
+ /* the new-session sheet (c3-new-session.js) */
2231
+ .c3ns { display: flex; flex-direction: column; gap: var(--sp-3); }
2232
+ .c3ns-f { display: flex; flex-direction: column; gap: var(--sp-1); }
2233
+ .c3ns-f input, .c3ns-f select { width: 100%; }
2234
+ .c3ns-err { color: var(--destructive); font-size: var(--fs-2); margin: 0; }
2235
+ .c3ns-err:empty { display: none; }
2236
+ .c3ns-go { display: flex; justify-content: flex-end; }
@@ -8,8 +8,8 @@
8
8
  * Gestures (c3-swipe.js, card-state.swipeAction): swipe right = the card's primary move (Accept, on a finished result), swipe left = the reply tray
9
9
  * (the agent's suggested chips and a free line). A swipe sends nothing outward. Keys: ArrowRight, ArrowLeft on the focused card.
10
10
  * Presses: Accept is POST /api/runs/answer {behavior:'accept'}; Revise and Reject are POST /api/runs/correction (the correction signal); a permission
11
- * is POST /api/runs/answer {allow|deny}; the agent's question is {answer, message: an offered option}. Every one is the PM's press. Anything that
12
- * would send a prompt to the session (a suggestion, a revision, the free line, the mic) is drawn disabled with the reason: ctx.src.canPrompt.
11
+ * is POST /api/runs/answer {allow|deny}; the agent's question is {answer, message: an offered option}. Every one is the PM's press. A suggestion,
12
+ * a revision, the free line and Retry are typed into the session (ctx.src.send); a source that cannot send says why (ctx.src.canPrompt, ctx.src.why).
13
13
  * Per-run UI state (the open tray, the Revise chips, the flipped face) lives in `UI`, outside the render, so a poll never closes it. */
14
14
  import { el } from './dom.js';
15
15
  import { button } from './primitives.js';
@@ -89,7 +89,8 @@ function failBox(ctx, card, ui) {
89
89
  if (card.state === 'degraded' && card.fallbackText) nodes.push(el('div', { class: 'draft' }, card.fallbackText));
90
90
  if (ui.raw && (card.problems || []).length) nodes.push(el('div', { class: 'raw' }, (card.problems || []).map((p) => el('div', null, '> ' + p))));
91
91
  nodes.push(el('div', { class: 'c3t-tray' },
92
- (retry || asAgain) ? el('button', { type: 'button', class: 'pill rec', 'data-retry': '', disabled: !can, title: can ? '' : ctx.src.why, onclick: async () => { const r = await ctx.src.send(ctx.id, { verb: 'retry' }); if (r && r.ok) ctx.refresh(); } }, asAgain ? 'Ask again in the format' : card.state === 'not_started' ? 'Try again' : 'Retry') : null,
92
+ (retry || asAgain) ? button({ label: asAgain ? 'Ask again in the format' : card.state === 'not_started' ? 'Try again' : 'Retry', disabled: !can || card.state === 'not_started', onClick: async () => { const r = await ctx.src.send(ctx.id, { verb: 'retry' }); if (r && r.ok) ctx.refresh(); },
93
+ attrs: { class: 'pill rec', 'data-retry': '', title: !can ? ctx.src.why : card.state === 'not_started' ? NOSTART : 'Types "Please try that again." into the session' } }) : null,
93
94
  openBtn(ctx),
94
95
  (card.problems || []).length ? el('button', { type: 'button', class: 'pill', 'aria-pressed': String(!!ui.raw), onclick: () => { ui.raw = !ui.raw; ctx.redraw(); } }, ui.raw ? 'Hide' : 'What it said') : null,
95
96
  el('button', { type: 'button', class: 'ib', 'aria-label': 'Dismiss', title: 'Dismiss this notice', onclick: () => { ui.hidden = card.state + ':' + (code || ''); ctx.redraw(); } }, ic('x', 'sm'))));
@@ -100,6 +101,8 @@ function pausedBox(ctx, card) {
100
101
  return el('div', { class: 'ask', 'data-run-paused': '' }, el('b', { class: 'chips2' }, ic('run', 'sm'), 'Paused: usage limit'), dim(at ? 'Resumes by itself at about ' + at + '. Everything done so far is kept.' : 'The run is paused, not failed. Everything done so far is kept.'),
101
102
  el('div', { class: 'prog', 'aria-hidden': 'true' }, el('i', { class: 'd' }), el('i', { class: 'd' }), el('i'), el('i')), el('div', { class: 'c3t-tray' }, openBtn(ctx)));
102
103
  }
104
+ // a session that has not started may be at the CLI's trust-the-folder question, where a typed Enter would answer it
105
+ const NOSTART = 'Open the session to see what it waits for: typing into it now could answer a question you have not read.';
103
106
  export function openBtn(ctx) {
104
107
  const b = button({ label: 'Open the session', onClick: () => ctx.openSession && ctx.openSession(ctx.id), attrs: { class: 'pill c3t-open', 'data-open-session': '', title: 'Open the session: its real terminal, on this page' } });
105
108
  b.prepend(ic('term', 'sm'));
@@ -15,6 +15,7 @@ import { attachExpand, makeXp } from './c3-expand.js';
15
15
  import { toast } from './c3-toast.js';
16
16
  import { pmPost } from './http.js';
17
17
  import { button } from './primitives.js';
18
+ import { chatFeed } from './c3-session-chat.js';
18
19
 
19
20
  const live = new WeakMap();
20
21
  const DOT = { working: 'w', queued: 'w', asking: 'n', permission: 'n', attention: 'n', not_started: 'n', failed: 'b', quiet: '', paused: 'n', done: '' };
@@ -61,15 +62,31 @@ function row(st, s, card, parts) {
61
62
  const dim = (t) => el('span', { class: 'dim mono', style: 'font-size:var(--fs-2)' }, t);
62
63
 
63
64
  function blankPanel(st, s, card) {
64
- const ui = uiFor(s.id); const can = st.src.canPrompt;
65
- const chat = ui.chat || (ui.chat = [['bot', 'What would you like to look at?']]);
65
+ const ui = uiFor(s.id); const can = st.src.canPrompt; const demo = !!st.src.demo;
66
+ // the demo answers in the panel from its fixtures; a real session's conversation is read from the agent CLI's transcript (c3-session-chat.js)
67
+ const chat = ui.chat || (ui.chat = demo ? [['bot', 'What would you like to look at?']] : []);
68
+ const feed = !demo && can ? chatFeed(s.id) : null;
66
69
  const redraw = () => { st.redraw(); };
67
- const say = async (v) => { chat.push(['me', v], ['st', 'Reading planning/ …']); redraw(); const r = await st.src.send(s.id, { verb: 'free', text: v }); chat.pop(); chat.push(['bot', (r && r.say && r.say.text) || 'Done.']); redraw(); };
68
- const input = el('input', { type: 'text', 'aria-label': 'Message to the agent', placeholder: can ? 'Message the agent' : 'Message (not available here)', readonly: !can, disabled: !can, title: can ? '' : st.src.why, 'data-run-chat': '' });
69
- input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && input.value.trim()) say(input.value.trim()); });
70
+ const say = async (v) => {
71
+ if (feed) {
72
+ const h = feed.sending(v);
73
+ const r = await st.src.send(s.id, { verb: 'free', text: v });
74
+ h.done(!!(r && r.ok));
75
+ if (!(r && r.ok)) { ui.draft = v; redraw(); }
76
+ return;
77
+ }
78
+ chat.push(['me', v], ['st', 'Reading planning/ …']); redraw();
79
+ const r = await st.src.send(s.id, { verb: 'free', text: v });
80
+ chat.pop();
81
+ if (r && r.ok) chat.push(['bot', (r.say && r.say.text) || 'Done.']); else { chat.pop(); ui.draft = v; }
82
+ redraw();
83
+ };
84
+ const input = el('input', { type: 'text', 'aria-label': 'Message to the agent', placeholder: can ? 'Message the agent' : 'Message (not available here)', readonly: !can, disabled: !can, title: can ? 'Types this into the session and presses Enter' : st.src.why, 'data-run-chat': '' });
85
+ if (ui.draft) { input.value = ui.draft; ui.draft = ''; }
86
+ input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.isComposing && input.value.trim()) { const v = input.value.trim(); input.value = ''; say(v); } });
70
87
  return el('div', { class: 'c3t-panel', 'data-blank-panel': s.id },
71
- can ? el('div', { class: 'feed' }, chat.map((m) => el('div', { class: 'm ' + m[0] }, m[1]))) : null,
72
- can ? el('div', { class: 'c3t-tray' }, ["Review this week's slips", 'Draft the monthly report'].map((t, i) => el('button', { type: 'button', class: 'pill' + (i ? '' : ' rec'), onclick: () => say(t) }, t))) : null,
88
+ feed ? feed.node : can && chat.length ? el('div', { class: 'feed' }, chat.map((m) => el('div', { class: 'm ' + m[0] }, m[1]))) : null,
89
+ can && demo ? el('div', { class: 'c3t-tray' }, ["Review this week's slips", 'Draft the monthly report'].map((t, i) => pill(t, null, () => say(t), { class: i ? '' : 'rec' }))) : null,
73
90
  el('div', { class: 'c3t-tray' }, input, openBtn({ id: s.id, openSession: st.openSession }), endBtn(st, s)),
74
91
  st.confirm === s.id ? endConfirm(st, s) : null,
75
92
  can ? null : dim(st.src.why));
@@ -130,10 +147,15 @@ function draw(host, st) {
130
147
  : none('None running. A result appears on its card.')));
131
148
  /* Started by me */
132
149
  const mine = g.startedByMe || [];
133
- const add = el('a', { class: 'chip2 lk c3t-new', href: '/classic.html', title: 'Start a session in the classic app. A session starts over the terminal connection; this page has no route for it.' }, ic('plus', 'sm'), 'New');
150
+ // console3 answers pmos:new-session with its new-session sheet; a page that does not (the classic app) opens its own dock sheet
151
+ const add = button({ variant: 'ghost', attrs: { class: 'chip2 lk c3t-new', 'data-c3t': 'new', title: 'Start a new session' }, onClick: () => {
152
+ const ev = new CustomEvent('pmos:new-session', { cancelable: true, detail: {} });
153
+ if (window.dispatchEvent(ev)) Promise.all([import('./dock-sessions.js'), import('./board-read.js')]).then(([ds, br]) => ds.openNewSessionSheet(br.currentProduct(), { initiative: null })).catch(() => {});
154
+ } });
155
+ add.replaceChildren(ic('plus', 'sm'), 'New');
134
156
  root.append(section('term', 'Started by me', mine.length, add, mine.length
135
157
  ? el('ul', { class: 'list', 'data-c3t': 'mine' }, mine.map((s) => { const c = cards[s.id]; return row(st, s, c, { sub: liveWord(s, c) + ' · ' + ago(s.startedAt) + ' · main tree', body: [plain(s, c) ? null : cardHost(st, s, c || { state: 'working', title: 'Working', nextMoves: [], claims: [], steps: [] }), blankPanel(st, s, c)].filter(Boolean) }); }))
136
- : none('None. Start one in the classic app (/classic.html).')));
158
+ : none('None. Press New to start one.')));
137
159
  /* Past */
138
160
  const past = g.past || [];
139
161
  root.append(section('clock', 'Past (7 days)', past.length, null, past.length
@@ -1081,6 +1081,23 @@ export function sendToTerm(tab, data) {
1081
1081
  return send({ t: 'input', id: rec.sessionId, data });
1082
1082
  }
1083
1083
 
1084
+ /**
1085
+ * Type a message into a running session, by id, and press Enter — for console3, which shows a session without a pane.
1086
+ *
1087
+ * The same socket and `input` frame the terminal's own keys use (the socket carries this page's launch nonce), so it is
1088
+ * exactly as safe as typing into the terminal. The Enter goes in its own frame a moment later: an agent's screen reads
1089
+ * text and Enter that arrive together as a paste, and a pasted Enter is a new line, not a send. Resolves false when the
1090
+ * socket did not open, so a caller can say "not sent".
1091
+ */
1092
+ export async function typeIntoSession(id, text) {
1093
+ if (FIXTURE || !id || !text) return false;
1094
+ connect();
1095
+ for (let i = 0; i < 50 && !socketOpen(); i++) await new Promise((r) => setTimeout(r, 100));
1096
+ if (!send({ t: 'input', id, data: text })) return false;
1097
+ await new Promise((r) => setTimeout(r, 150));
1098
+ return send({ t: 'input', id, data: '\r' });
1099
+ }
1100
+
1084
1101
  /**
1085
1102
  * End a running session, by id — the `+ session` chooser's one destructive action.
1086
1103
  *
@@ -2755,6 +2755,33 @@ function agentPid(s) {
2755
2755
  // the CLI never PRINTS its model, effort, token usage or skill set to the terminal, so parsing
2756
2756
  // scrollback for them returned four empty rows. Scrollback still owns the one thing it is
2757
2757
  // actually the source of — the agent's last words. Re-read on transcript mtime/size change.
2758
+ // Which transcript is this session's (shared by the harness drill-in and console3's conversation). Re-resolved at most every ~2s.
2759
+ function txFound(s, now = Date.now()) {
2760
+ if (!s.txFound || (now - (s.txFoundAt || 0)) > 2000) {
2761
+ s.txFound = transcript.findTranscript(agentPid(s), s.root);
2762
+ s.txFoundAt = now;
2763
+ }
2764
+ return s.txFound;
2765
+ }
2766
+ // console3's session conversation: the PM's prompts and the agent's words from the transcript (transcript-read.readConversation).
2767
+ // Re-parsed only when the file moves; `stamp` lets the page skip a redraw when nothing changed. Read-only and local.
2768
+ function sessionConversation(s) {
2769
+ if (s.agent !== 'claude' && s.agent !== 'shell') return { messages: [], missing: 'Only Claude sessions show a conversation here.' };
2770
+ const found = txFound(s);
2771
+ if (found.missing) {
2772
+ if (s.agent === 'shell' && /wrote no session record/.test(found.missing)) return { messages: [], missing: 'No agent is running in this shell yet.' };
2773
+ return { messages: [], missing: found.missing };
2774
+ }
2775
+ let stamp = null;
2776
+ try { const st = fs.statSync(found.file); stamp = `${st.size}:${st.mtimeMs}`; } catch {}
2777
+ if (s.conv && s.convStamp === stamp) return s.conv;
2778
+ const c = transcript.readConversation(found.file, scrub);
2779
+ const h = transcriptHarness(s);
2780
+ c.turn = h && h.turn ? h.turn : null;
2781
+ c.stamp = stamp;
2782
+ s.conv = c; s.convStamp = stamp;
2783
+ return c;
2784
+ }
2758
2785
  function transcriptHarness(s) {
2759
2786
  if (s.state === 'exited') return { missing: { all: 'the session has exited' } };
2760
2787
  // A `shell` session is included: the PM launches `claude` INSIDE the default shell pane, so the
@@ -2767,11 +2794,7 @@ function transcriptHarness(s) {
2767
2794
  // spawn a ps per session per push. Re-resolving every ~2s also picks up a claude the PM restarts
2768
2795
  // inside the same shell (a new child pid) without a stale cache pinning the old transcript.
2769
2796
  const now = Date.now();
2770
- if (!s.txFound || (now - (s.txFoundAt || 0)) > 2000) {
2771
- s.txFound = transcript.findTranscript(agentPid(s), s.root);
2772
- s.txFoundAt = now;
2773
- }
2774
- const found = s.txFound;
2797
+ const found = txFound(s, now);
2775
2798
  if (found.missing) {
2776
2799
  // R4 — VERIFIED ABSENT IS NOT A FAILED SEARCH. A `shell` pane is walked for a descendant claude
2777
2800
  // because the PM often starts one there; when none is running, findTranscript's answer ("the CLI
@@ -6404,6 +6427,14 @@ async function handleRequest(req, res) {
6404
6427
  }
6405
6428
  // The tail of one session's terminal text, escape codes stripped, so a CLI can turn "what I just did" into a skill.
6406
6429
  // Read-only, local, and capped; it names the session it read or says there is none.
6430
+ // console3's session panel: the conversation (prompts and replies) of one session, from the agent CLI's own transcript.
6431
+ if (p === '/api/sessions/conversation' && req.method === 'GET') {
6432
+ const sess = sessions.get(String(url.searchParams.get('id') || ''));
6433
+ if (!sess) return json(res, { ok: false, error: 'no such session' }, 404);
6434
+ if (sess.state === 'exited') return json(res, { ok: true, id: sess.id, messages: [], missing: 'The session has ended.', turn: null, stamp: null });
6435
+ const c = sessionConversation(sess);
6436
+ return json(res, { ok: true, id: sess.id, messages: c.messages, missing: c.missing || null, turn: c.turn || null, stamp: c.stamp || null });
6437
+ }
6407
6438
  if (p === '/api/sessions/transcript' && req.method === 'GET') {
6408
6439
  const sess = sessions.get(String(url.searchParams.get('id') || ''));
6409
6440
  if (!sess) return json(res, { ok: false, error: 'no such session' }, 404);