create-pmos 0.161.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.161.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@ada3150
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-12T07:44:14Z"}
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-12T07:44:14Z"}
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-12T07:44:14Z"}
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);
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  type: Guide
3
- title: "Sessions in console3: start, open, 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, 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: start, 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
 
@@ -46,6 +46,33 @@ The terminal of the session opens in a panel on the same page. You can type in i
46
46
 
47
47
  The **Open the session** link in the sessions dock opens the same panel.
48
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
+
49
76
  ## End a session
50
77
 
51
78
  1. Press the row to open it.
@@ -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.161.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.161.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.161.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",
@@ -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';
@@ -29,7 +29,6 @@ const shownOf = (s, ended) => (ended && ended.has(s.id) ? 'exited' : s.shown);
29
29
  const PI = { claude: 'term', cloud: 'cloud', codex: 'bolt', shell: 'term', local: 'term' }; // spark is reserved for the agent surfaces
30
30
  const PN = { claude: 'Claude', cloud: 'Claude cloud', codex: 'Codex', shell: 'Shell', local: 'Local' };
31
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).';
32
- 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).';
33
32
  const MAX_TREES = 10;
34
33
 
35
34
  const ago = (ms) => {
@@ -105,6 +104,15 @@ export function createDock(opts) {
105
104
  const r = await pmPost('/api/scratchpad', { session: s.id, text: pad.value });
106
105
  saveBtn.title = r && r.ok ? 'Saved' : 'Not saved: ' + ((r && r.error) || 'unreadable');
107
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(); } });
108
116
  detail.replaceChildren(...[
109
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'))),
110
118
  el('div', { class: 'dim mono', style: 'font-size:var(--fs-1)' }, facts),
@@ -113,8 +121,8 @@ export function createDock(opts) {
113
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'),
114
122
  term,
115
123
  el('div', { class: 'form' },
116
- el('input', { type: 'text', readonly: true, 'data-off': '', placeholder: s.shown === 'needs' ? 'Your answer' : 'Message', 'aria-label': 'Message', title: MORE, 'data-c3pg': 'dock-msg' }),
117
- 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')),
118
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'))),
119
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.'),
120
128
  el('button', { type: 'button', class: 'chip2 lk', 'data-c3pg': 'dock-end-yes', onclick: () => endSession(s) }, c3ic('stop', 'sm'), 'End'),
@@ -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
+ }
@@ -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}
@@ -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));
@@ -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);