create-pmos 0.161.0 → 0.163.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 +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-sessions-panel-test.js +94 -2
- package/workspace/guide/console3-sessions.md +42 -3
- package/workspace/lib/transcript-read.js +86 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-dock.js +12 -4
- package/workspace/public/pm/c3-session-chat.js +103 -0
- package/workspace/public/pm/c3-session-term.js +69 -3
- package/workspace/public/pm/c3-sheet.js +5 -4
- package/workspace/public/pm/c3-terminal-data.js +26 -4
- package/workspace/public/pm/console3.css +24 -0
- package/workspace/public/pm/run-card.js +6 -3
- package/workspace/public/pm/sessions-panel.js +24 -7
- package/workspace/public/pm/tab-term.js +17 -0
- package/workspace/server.js +36 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.163.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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-12T10:13:23Z"}
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-12T10:13:23Z"}
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-12T10:13:23Z"}
|
|
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;
|
|
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,10 +105,60 @@ 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')));
|
|
108
112
|
|
|
113
|
+
/* ── G: the scratchpad and the size control on the terminal sheet (c3-term-pad-size) ── */
|
|
114
|
+
await page.evaluate((i) => window.dispatchEvent(new CustomEvent('pmos:open-session', { detail: { id: i } })), id);
|
|
115
|
+
const g0 = await waitFor(page, (i) => !!document.querySelector(`[data-c3-session-term="${i}"] .xterm`), id);
|
|
116
|
+
ok('G0 the terminal sheet opens for the pad checks', g0 || !!(await page.$('[data-c3-size-seg]')));
|
|
117
|
+
const sizes = await page.$$eval('[data-c3-size-seg] [data-c3-size]', (b) => b.map((x) => x.dataset.c3Size));
|
|
118
|
+
ok('G1 the size control has 4 options', sizes.join() === 'normal,half,two-thirds,whole', sizes.join());
|
|
119
|
+
const vw = await page.evaluate(() => window.innerWidth);
|
|
120
|
+
const wid = () => page.evaluate(() => document.querySelector('[data-shell="sheet"] .sheet').getBoundingClientRect().width);
|
|
121
|
+
ok('G2 Normal is the 600px sheet', Math.abs((await wid()) - 600) < 2, String(await wid()));
|
|
122
|
+
await page.click('[data-c3-size="half"]');
|
|
123
|
+
await sleep(200);
|
|
124
|
+
ok('G3 Half makes the sheet half of the window', Math.abs((await wid()) - vw / 2) < 3, (await wid()) + ' of ' + vw);
|
|
125
|
+
ok('G3b Half is shown as pressed', await page.evaluate(() => document.querySelector('[data-c3-size="half"]').getAttribute('aria-pressed') === 'true'));
|
|
126
|
+
await page.click('[data-c3-size="whole"]');
|
|
127
|
+
await sleep(200);
|
|
128
|
+
ok('G3c Whole fills the window minus the margins', Math.abs((await wid()) - (vw - 24)) < 3, String(await wid()));
|
|
129
|
+
await page.click('[data-c3-size="half"]');
|
|
130
|
+
await page.click('[data-shell="sheet"] [aria-label="Close"]');
|
|
131
|
+
await waitFor(page, () => !document.querySelector('[data-c3-session-term]'));
|
|
132
|
+
await page.evaluate((i) => window.dispatchEvent(new CustomEvent('pmos:open-session', { detail: { id: i } })), id);
|
|
133
|
+
await waitFor(page, (i) => !!document.querySelector(`[data-c3-session-term="${i}"] .xterm`), id);
|
|
134
|
+
await sleep(200);
|
|
135
|
+
ok('G4 the size choice is kept after closing and opening again', Math.abs((await wid()) - vw / 2) < 3, String(await wid()));
|
|
136
|
+
ok('G4b it is kept in localStorage', await page.evaluate(() => localStorage.getItem('pmos.workspace.c3.term-size') === 'half'));
|
|
137
|
+
const termH = await page.evaluate(() => document.querySelector('[data-c3-session-term]').getBoundingClientRect().height);
|
|
138
|
+
ok('G5 the terminal has room above the scratchpad', termH > 120 && await page.evaluate(() => { const t = document.querySelector('[data-c3-session-term]').getBoundingClientRect(); const p = document.querySelector('[data-c3-pad="box"]').getBoundingClientRect(); return t.bottom <= p.top + 1; }));
|
|
139
|
+
await page.fill('[data-c3-pad="text"]', 'echo from-the-pad-7');
|
|
140
|
+
ok('G6 typing in the pad saves it for the session', !!(await until(async () => (await get('/api/scratchpad?session=' + id)).text === 'echo from-the-pad-7', 4000)));
|
|
141
|
+
await page.click(`[data-c3-session-term="${id}"] .xterm`);
|
|
142
|
+
await page.keyboard.press('Backspace'); // the Esc in C3 left the shell's readline waiting for a meta key: this key answers it
|
|
143
|
+
await page.fill('[data-c3-pad="text"]', 'echo from-the-pad-7');
|
|
144
|
+
await page.click('[data-c3-pad="send"]');
|
|
145
|
+
await sleep(800);
|
|
146
|
+
const noEnter = !/^from-the-pad-7/m.test((await get('/api/sessions/transcript?id=' + id + '&max=4000')).text || '');
|
|
147
|
+
await page.click(`[data-c3-session-term="${id}"] .xterm`);
|
|
148
|
+
await page.keyboard.press('Enter');
|
|
149
|
+
ok('G7 Send to terminal types the text into the session, without pressing Enter', noEnter && !!(await until(async () => /^from-the-pad-7/m.test((await get('/api/sessions/transcript?id=' + id + '&max=4000')).text || ''), 8000)));
|
|
150
|
+
await page.fill('[data-c3-pad="text"]', 'flushed-on-close');
|
|
151
|
+
await page.click('[data-shell="sheet"] [aria-label="Close"]');
|
|
152
|
+
ok('G8 closing the sheet saves a pad edit at once', !!(await until(async () => (await get('/api/scratchpad?session=' + id)).text === 'flushed-on-close', 2000)));
|
|
153
|
+
await page.evaluate((i) => window.dispatchEvent(new CustomEvent('pmos:open-session', { detail: { id: i } })), id);
|
|
154
|
+
await waitFor(page, () => !!document.querySelector('[data-c3-pad="text"]'));
|
|
155
|
+
ok('G9 the pad loads its text when the terminal opens again', await waitFor(page, () => document.querySelector('[data-c3-pad="text"]').value === 'flushed-on-close'));
|
|
156
|
+
await page.click('[data-c3-pad="toggle"]');
|
|
157
|
+
ok('G10 the toggle hides the pad and the terminal gets the room', await page.evaluate(() => document.querySelector('[data-c3-pad="text"]').hidden === true && localStorage.getItem('pmos.workspace.c3.term-pad') === 'closed'));
|
|
158
|
+
await page.click('[data-c3-pad="toggle"]');
|
|
159
|
+
await page.click('[data-shell="sheet"] [aria-label="Close"]');
|
|
160
|
+
await waitFor(page, () => !document.querySelector('[data-c3-session-term]'));
|
|
161
|
+
|
|
109
162
|
/* ── D: End asks first, then ends ── */
|
|
110
163
|
await page.click('[data-c3pg="agent-button"]');
|
|
111
164
|
ok('D0 the panel opens again', await waitFor(page, (s) => !!document.querySelector(s), sel));
|
|
@@ -114,6 +167,45 @@ function startShell(name) {
|
|
|
114
167
|
ok('D1 End asks first', await waitFor(page, (i) => !!document.querySelector(`[data-end-confirm="${i}"]`), id));
|
|
115
168
|
await page.click(`[data-end-confirm="${id}"] button:not([data-end-yes])`);
|
|
116
169
|
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));
|
|
170
|
+
|
|
171
|
+
/* ── M: the message box types into the session and presses Enter ── */
|
|
172
|
+
const box = `[data-blank-panel="${id}"] [data-run-chat]`;
|
|
173
|
+
ok('M0 the message box is enabled', await page.isEnabled(box).catch(() => false));
|
|
174
|
+
const pidFile = path.join(T.STATE_DIR, 'c3-shell.pid');
|
|
175
|
+
await page.fill(box, 'echo $$ > ' + pidFile + '; echo c3-msg-$((6*7))');
|
|
176
|
+
await page.press(box, 'Enter');
|
|
177
|
+
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));
|
|
178
|
+
const m1 = await until(async () => /c3-msg-42/.test((await get('/api/sessions/transcript?id=' + id + '&max=4000')).text || ''), 8000);
|
|
179
|
+
if (!m1) console.log(' transcript:', JSON.stringify(((await get('/api/sessions/transcript?id=' + id + '&max=4000')).text || '').slice(-300)));
|
|
180
|
+
ok('M1 the message reaches the session and Enter runs it', !!m1);
|
|
181
|
+
/* the conversation: a transcript for this shell, as claude writes one, shows in the panel as messages */
|
|
182
|
+
const shellPid = (() => { try { return fs.readFileSync(pidFile, 'utf8').trim(); } catch { return ''; } })();
|
|
183
|
+
const sessRow = ((await get('/api/sessions')).sessions || []).find((x) => x.id === id) || {};
|
|
184
|
+
const root = sessRow.root || sessRow.cwd || path.resolve(__dirname, '..');
|
|
185
|
+
fs.mkdirSync(path.join(CLAUDE_HOME, 'sessions'), { recursive: true });
|
|
186
|
+
fs.writeFileSync(path.join(CLAUDE_HOME, 'sessions', shellPid + '.json'), JSON.stringify({ pid: +shellPid, sessionId: 'conv-test-1', cwd: root, version: 'test' }));
|
|
187
|
+
const proj = path.join(CLAUDE_HOME, 'projects', root.replace(/\//g, '-'));
|
|
188
|
+
fs.mkdirSync(proj, { recursive: true });
|
|
189
|
+
const rec = (o) => JSON.stringify({ isSidechain: false, timestamp: new Date().toISOString(), ...o });
|
|
190
|
+
fs.writeFileSync(path.join(proj, 'conv-test-1.jsonl'), [
|
|
191
|
+
rec({ type: 'user', isMeta: true, message: { role: 'user', content: '<system-reminder>not shown</system-reminder>' } }),
|
|
192
|
+
rec({ type: 'user', message: { role: 'user', content: 'What changed today?' } }),
|
|
193
|
+
rec({ type: 'assistant', message: { id: 'msg_1', model: 'claude-test', role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'git log' } }] } }),
|
|
194
|
+
rec({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: 'ok' }] } }),
|
|
195
|
+
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' }] } }),
|
|
196
|
+
].join('\n') + '\n');
|
|
197
|
+
const feedSel = `[data-blank-panel="${id}"] [data-chat-feed]`;
|
|
198
|
+
// the server re-resolves a session's transcript at most every 2 s
|
|
199
|
+
const conv = (await until(async () => { const c = await get('/api/sessions/conversation?id=' + id); return c.messages && c.messages.length ? c : null; }, 8000)) || {};
|
|
200
|
+
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));
|
|
201
|
+
ok('M4 the panel shows the prompt, the tool line and the reply', await waitFor(page, (sel) => {
|
|
202
|
+
const f = document.querySelector(sel); if (!f) return false;
|
|
203
|
+
const me = [...f.querySelectorAll('[data-chat="me"]')].some((n) => n.textContent === 'What changed today?');
|
|
204
|
+
const tool = [...f.querySelectorAll('[data-chat="tool"]')].some((n) => /Used Bash/.test(n.textContent));
|
|
205
|
+
const bot = f.querySelector('[data-chat="bot"]');
|
|
206
|
+
return me && tool && !!bot && !!bot.querySelector('li b') && !!bot.querySelector('li code') && !/not shown/.test(f.textContent);
|
|
207
|
+
}, feedSel, 10000));
|
|
208
|
+
|
|
117
209
|
await page.click(`[data-end-session="${id}"]`);
|
|
118
210
|
await page.click(`[data-end-yes="${id}"]`);
|
|
119
211
|
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
|
|
|
@@ -44,8 +44,47 @@ If the server finds that the process of a session stopped (for example, after a
|
|
|
44
44
|
|
|
45
45
|
The terminal of the session opens in a panel on the same page. You can type in it. When the terminal has focus, Esc and Tab go to the terminal. To close the panel, press its close button. The session continues to run.
|
|
46
46
|
|
|
47
|
+
### The scratchpad
|
|
48
|
+
|
|
49
|
+
Below the terminal is a scratchpad. It keeps text for this session only. The text is saved on this machine as you type, and it is there when you open the terminal again.
|
|
50
|
+
|
|
51
|
+
- To send the text to the session, press **Send to terminal**. The text is typed into the terminal. Enter is not pressed, so you can read it and press Enter yourself.
|
|
52
|
+
- To hide the pad, press **Scratchpad** above it. The text is kept. The terminal gets the room back. The page remembers if the pad was hidden.
|
|
53
|
+
- Esc in the pad closes the panel. Esc in the terminal goes to the terminal.
|
|
54
|
+
|
|
55
|
+
### The size of the terminal
|
|
56
|
+
|
|
57
|
+
Four buttons in the header of the panel set its width: **Normal**, **Half**, **2/3** and **Whole**. Half is half of the window. 2/3 is two thirds of the window. Whole is the full window. The terminal fits the new size. The page remembers your choice for the next terminal you open. On a phone the panel is already full width, so the buttons are not shown.
|
|
58
|
+
|
|
47
59
|
The **Open the session** link in the sessions dock opens the same panel.
|
|
48
60
|
|
|
61
|
+
## Send a message to a session
|
|
62
|
+
|
|
63
|
+
1. Press the row to open it.
|
|
64
|
+
2. Type your message in the message box.
|
|
65
|
+
3. Press Enter.
|
|
66
|
+
|
|
67
|
+
Your message is typed into the session, and Enter is pressed. The message shows in the panel at once.
|
|
68
|
+
|
|
69
|
+
## Read the conversation
|
|
70
|
+
|
|
71
|
+
The panel shows the conversation of a Claude session:
|
|
72
|
+
|
|
73
|
+
- Your messages are on the right.
|
|
74
|
+
- The replies of Claude are on the left. Lists, bold text and code show as formatted text.
|
|
75
|
+
- A grey line, for example "Used Bash, Read ×2", shows the tools that Claude used. The input of a tool does not show.
|
|
76
|
+
- While Claude works, a grey line says so.
|
|
77
|
+
|
|
78
|
+
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.
|
|
79
|
+
|
|
80
|
+
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**.
|
|
81
|
+
|
|
82
|
+
The **Message** box in the dock and the reply line below a card do the same.
|
|
83
|
+
|
|
84
|
+
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.
|
|
85
|
+
|
|
86
|
+
**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.
|
|
87
|
+
|
|
49
88
|
## End a session
|
|
50
89
|
|
|
51
90
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.163.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.163.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
117
|
-
|
|
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
|
+
}
|
|
@@ -6,7 +6,21 @@
|
|
|
6
6
|
import { el } from './dom.js';
|
|
7
7
|
import { openSheet } from './c3-sheet.js';
|
|
8
8
|
import { toast } from './c3-toast.js';
|
|
9
|
-
import { pmGet } from './http.js';
|
|
9
|
+
import { pmGet, pmPost } from './http.js';
|
|
10
|
+
import { button } from './primitives.js';
|
|
11
|
+
import { c3ic } from './pm-work-view.js';
|
|
12
|
+
|
|
13
|
+
/* a primitives button (keyboard, role) carrying the console3 look and children (the pattern c3-dock.js uses) */
|
|
14
|
+
const c3btn = (attrs, onClick, ...kids) => { const b = button({ variant: 'ghost', attrs, onClick }); b.replaceChildren(...kids); return b; };
|
|
15
|
+
const store = {
|
|
16
|
+
get(k) { try { return localStorage.getItem(k); } catch { return null; } },
|
|
17
|
+
set(k, v) { try { localStorage.setItem(k, v); } catch { /* no storage */ } },
|
|
18
|
+
};
|
|
19
|
+
export const SIZE_KEY = 'pmos.workspace.c3.term-size';
|
|
20
|
+
export const PAD_KEY = 'pmos.workspace.c3.term-pad';
|
|
21
|
+
/* the sheet widths: Normal is the 600px wide sheet; the others are classes in console3.css (desktop only) */
|
|
22
|
+
export const SIZES = [['normal', 'Normal', 'Normal'], ['half', 'Half', 'Half of the window'], ['two-thirds', '2/3', 'Two-thirds of the window'], ['whole', 'Whole', 'Whole window']];
|
|
23
|
+
export const sizeOf = (v) => (SIZES.some((x) => x[0] === v) ? v : 'normal');
|
|
10
24
|
|
|
11
25
|
const VENDOR = ['/vendor/xterm.js', '/vendor/addon-fit.js', '/vendor/addon-web-links.js'];
|
|
12
26
|
let vendor = null;
|
|
@@ -29,6 +43,43 @@ function loadVendor() {
|
|
|
29
43
|
return vendor;
|
|
30
44
|
}
|
|
31
45
|
|
|
46
|
+
/* the scratchpad under the terminal: the classic app's per-session pad (panel-tabs.js), kept through /api/scratchpad */
|
|
47
|
+
function makePad(id, term, tab) {
|
|
48
|
+
const url = '/api/scratchpad?session=' + encodeURIComponent(id);
|
|
49
|
+
let typed = false; let timer = null; let pending = null; let closed = false;
|
|
50
|
+
const flush = () => {
|
|
51
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
52
|
+
if (pending === null) return;
|
|
53
|
+
const text = pending; pending = null;
|
|
54
|
+
pmPost('/api/scratchpad', { session: id, text }).catch(() => { /* the text stays in the box */ });
|
|
55
|
+
};
|
|
56
|
+
const ta = el('textarea', { class: 'c3t-pad-ta', rows: '4', placeholder: 'Scratchpad. One pad for each session. Saved on this machine.', 'aria-label': 'Scratchpad', 'data-c3-pad': 'text',
|
|
57
|
+
oninput: () => { typed = true; pending = ta.value; if (timer) clearTimeout(timer); timer = setTimeout(flush, 500); sync(); } });
|
|
58
|
+
const send = c3btn({ class: 'pill', 'data-c3-pad': 'send', title: 'Type the pad into this session. Enter is not pressed.' }, () => {
|
|
59
|
+
if (!ta.value.trim()) return;
|
|
60
|
+
if (!term.sendToTerm(tab, ta.value)) { toast({ text: 'The text was not sent. The terminal is not connected.', tone: 'error' }); return; }
|
|
61
|
+
if (typeof term.focusTerm === 'function') term.focusTerm(tab);
|
|
62
|
+
}, c3ic('out', 'sm'), 'Send to terminal');
|
|
63
|
+
const open0 = store.get(PAD_KEY) !== 'closed';
|
|
64
|
+
const box = el('div', { class: 'c3t-pad', 'data-c3-pad': 'box' });
|
|
65
|
+
const toggle = c3btn({ class: 'chip2 lk', 'data-c3-pad': 'toggle' }, () => { const on = box.dataset.open !== 'true'; store.set(PAD_KEY, on ? 'open' : 'closed'); set(on); },
|
|
66
|
+
c3ic('edit', 'sm'), 'Scratchpad');
|
|
67
|
+
function set(on) {
|
|
68
|
+
box.dataset.open = String(on);
|
|
69
|
+
toggle.setAttribute('aria-expanded', String(on));
|
|
70
|
+
toggle.title = on ? 'Hide the scratchpad. The text is kept.' : 'Show the scratchpad';
|
|
71
|
+
ta.hidden = !on;
|
|
72
|
+
requestAnimationFrame(() => term.refitVisible());
|
|
73
|
+
}
|
|
74
|
+
function sync() { send.disabled = !ta.value.trim(); }
|
|
75
|
+
box.append(el('div', { class: 'c3t-pad-h' }, toggle, send), ta);
|
|
76
|
+
set(open0); sync();
|
|
77
|
+
pmGet(url).then((r) => { if (!typed && r && typeof r.text === 'string') { ta.value = r.text; sync(); } }).catch(() => { /* an empty pad */ });
|
|
78
|
+
const onHide = () => flush();
|
|
79
|
+
window.addEventListener('pagehide', onHide);
|
|
80
|
+
return { box, ta, dispose() { if (closed) return; closed = true; window.removeEventListener('pagehide', onHide); flush(); } };
|
|
81
|
+
}
|
|
82
|
+
|
|
32
83
|
export async function openSessionTerm({ id, name } = {}) {
|
|
33
84
|
if (!id) return null;
|
|
34
85
|
if (!name) { const r = await pmGet('/api/sessions').catch(() => null); const x = r && Array.isArray(r.sessions) ? r.sessions.find((y) => y.id === id) : null; name = x ? x.name : id; }
|
|
@@ -36,8 +87,23 @@ export async function openSessionTerm({ id, name } = {}) {
|
|
|
36
87
|
if (!ok || !term) { toast({ text: 'The terminal could not be loaded on this page.', tone: 'error' }); return null; }
|
|
37
88
|
const tab = { id: 'c3-session:' + id, kind: 'term', sessionId: id, spawn: false, sess: id };
|
|
38
89
|
const host = el('div', { class: 'c3t-term', 'data-c3-session-term': id });
|
|
39
|
-
|
|
40
|
-
const
|
|
90
|
+
const pad = makePad(id, term, tab);
|
|
91
|
+
const wrap = el('div', { class: 'c3t-tw' }, host, pad.box);
|
|
92
|
+
// the size control: Normal is the 600px sheet, the others are the classic dock's presets. Hidden on a phone, where the sheet is full width.
|
|
93
|
+
let size = sizeOf(store.get(SIZE_KEY));
|
|
94
|
+
const sizeBtns = SIZES.map(([k, label, title]) => c3btn({ 'aria-pressed': String(k === size), 'data-c3-size': k, title, 'aria-label': title }, () => setSize(k), label));
|
|
95
|
+
const seg = el('div', { class: 'seg c3t-size', role: 'group', 'aria-label': 'Terminal size', 'data-c3-size-seg': '' }, ...sizeBtns);
|
|
96
|
+
const cls = (k) => 'tsz-' + k;
|
|
97
|
+
// Esc and Tab belong to the shell while the terminal has focus (Esc interrupts an agent); the close button and a press outside still close.
|
|
98
|
+
// The scratchpad is outside the terminal host, so its keys are the page's own.
|
|
99
|
+
const sheet = openSheet({ title: name || id, icon: 'term', wide: true, body: wrap, actions: seg, cls: cls(size),
|
|
100
|
+
onClose: () => { pad.dispose(); term.disposeTerm(tab); }, passKeys: (e) => host.contains(e.target) });
|
|
101
|
+
function setSize(k) {
|
|
102
|
+
sheet.sheet.classList.remove(cls(size)); size = sizeOf(k); sheet.sheet.classList.add(cls(size));
|
|
103
|
+
store.set(SIZE_KEY, size);
|
|
104
|
+
sizeBtns.forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.c3Size === size)));
|
|
105
|
+
requestAnimationFrame(() => { term.refitVisible(); requestAnimationFrame(() => term.refitVisible()); });
|
|
106
|
+
}
|
|
41
107
|
term.mountTerm(host, tab);
|
|
42
108
|
requestAnimationFrame(() => { term.refitVisible(); term.focusTerm(tab); });
|
|
43
109
|
return sheet;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/* pm/c3-sheet.js — the console3 sheet, as a primitive (console3.html: bsHtml line 2502, the .sheet rules 143 to 145, 180, 496 to 502,
|
|
2
2
|
* the Esc handler 2536 and the phone drag 2538 to 2540).
|
|
3
3
|
*
|
|
4
|
-
* openSheet({ title, icon, body, wide, dw, scrim, onClose, passKeys }) -> { close, root, sheet, body } passKeys(e): true leaves that key to the page
|
|
4
|
+
* openSheet({ title, icon, body, wide, dw, scrim, onClose, passKeys, actions, cls }) -> { close, root, sheet, body } passKeys(e): true leaves that key to the page
|
|
5
|
+
* actions: a Node or an array of Nodes placed in the header before the close button; cls: extra class names on the sheet
|
|
5
6
|
* closeSheet() closes the open sheet, if any
|
|
6
7
|
*
|
|
7
8
|
* On a desktop window the sheet is the right-hand panel (340px; `wide` 600px; `dw` the document sheet, up to 780px) over a
|
|
@@ -50,7 +51,7 @@ export function closeSheet() {
|
|
|
50
51
|
if (typeof c.onClose === 'function') { try { c.onClose(); } catch { /* the caller reports its own errors */ } }
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
export function openSheet({ title = '', icon = 'doc', body, wide = false, dw = false, scrim = true, onClose, passKeys } = {}) {
|
|
54
|
+
export function openSheet({ title = '', icon = 'doc', body, wide = false, dw = false, scrim = true, onClose, passKeys, actions, cls = '' } = {}) {
|
|
54
55
|
closeSheet();
|
|
55
56
|
ensureC3Css();
|
|
56
57
|
const opener = document.activeElement && document.activeElement !== document.body ? document.activeElement : null;
|
|
@@ -60,8 +61,8 @@ export function openSheet({ title = '', icon = 'doc', body, wide = false, dw = f
|
|
|
60
61
|
const sbb = el('div', { class: 'brief sbb' }, ...bodyNodes(body));
|
|
61
62
|
const closeBtn = el('button', { type: 'button', class: 'ib sm', 'aria-label': 'Close', title: 'Close (Esc)', onclick: close }, c3ic('x', 'sm'));
|
|
62
63
|
const grab = el('div', { class: 'grab' });
|
|
63
|
-
const head = el('div', { class: 'sh2' }, el('span', { class: 'chips2' }, c3ic(icon), el('b', null, title)), el('span', { class: 'chips2' }, closeBtn));
|
|
64
|
-
const sheet = el('div', { class: 'sheet bs' + (wide ? ' wide' : '') + (dw ? ' dw' : ''), role: 'dialog', 'aria-modal': String(!!scrim), 'aria-label': title, tabindex: '-1' },
|
|
64
|
+
const head = el('div', { class: 'sh2' }, el('span', { class: 'chips2' }, c3ic(icon), el('b', null, title)), el('span', { class: 'chips2' }, ...bodyNodes(actions), closeBtn));
|
|
65
|
+
const sheet = el('div', { class: 'sheet bs' + (wide ? ' wide' : '') + (dw ? ' dw' : '') + (cls ? ' ' + cls : ''), role: 'dialog', 'aria-modal': String(!!scrim), 'aria-label': title, tabindex: '-1' },
|
|
65
66
|
grab, head, el('div', { class: 'sb' }, sbb));
|
|
66
67
|
/* the mockup's person and playbooks sheets have no scrim (they are panels beside the page, closed by their own button or Esc): `scrim: false` */
|
|
67
68
|
root.append(...(scrim ? [el('div', { class: 'scrim', onclick: close })] : []), sheet);
|
|
@@ -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).
|
|
4
|
-
*
|
|
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
|
|
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:
|
|
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';
|
|
@@ -1660,10 +1660,34 @@
|
|
|
1660
1660
|
.c3 .c3t-panel{display:grid;gap:var(--sp-2);border:1px solid var(--border);border-radius:10px;padding:var(--sp-3)}
|
|
1661
1661
|
/* a session's terminal in a sheet (c3-session-term.js) */
|
|
1662
1662
|
.c3 .c3t-term{height:calc(100vh - 150px);min-height:240px;display:flex;flex-direction:column}
|
|
1663
|
+
/* the terminal plus its scratchpad (c3-session-term.js): the terminal shrinks to make room for the pad */
|
|
1664
|
+
.c3 .c3t-tw{height:calc(100vh - 150px);min-height:240px;display:flex;flex-direction:column;gap:var(--sp-2);min-width:0}
|
|
1665
|
+
.c3 .c3t-tw .c3t-term{height:auto;flex:1 1 0;min-height:120px}
|
|
1666
|
+
.c3 .c3t-pad{display:grid;gap:var(--sp-2);flex:none;border-top:1px solid var(--border);padding-top:var(--sp-2)}
|
|
1667
|
+
.c3 .c3t-pad-h{display:flex;gap:var(--sp-2);align-items:center;justify-content:space-between}
|
|
1668
|
+
.c3 .c3t-pad-h .pill:disabled{opacity:.35;cursor:not-allowed}
|
|
1669
|
+
.c3 .c3t-pad-ta{width:100%;min-height:72px;max-height:40vh;resize:vertical;font:var(--fs-2) var(--mono);box-sizing:border-box}
|
|
1670
|
+
/* terminal sheet sizes, as the classic dock has them; the phone sheet is already full width */
|
|
1671
|
+
.c3 .c3t-size{flex:none}
|
|
1672
|
+
.c3 .c3t-size button{padding:4px 8px}
|
|
1673
|
+
.c3.ph .c3t-size{display:none}
|
|
1674
|
+
@media (min-width:761px){
|
|
1675
|
+
.c3 .sheet.wide.tsz-half{width:50vw}
|
|
1676
|
+
.c3 .sheet.wide.tsz-two-thirds{width:66.667vw}
|
|
1677
|
+
.c3 .sheet.wide.tsz-whole{width:calc(100vw - 24px)}
|
|
1678
|
+
}
|
|
1663
1679
|
.c3 .c3t-panel .feed{display:flex;flex-direction:column;gap:var(--sp-2);max-height:220px;overflow:auto}
|
|
1664
1680
|
.c3 .c3t-panel .m{padding:6px 12px;border-radius:12px;max-width:88%;overflow-wrap:anywhere}
|
|
1665
1681
|
.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
1682
|
.c3 .c3t-panel .m.st{color:var(--muted-foreground);font:var(--fs-2) var(--mono);padding:0}
|
|
1683
|
+
/* a real session's conversation (c3-session-chat.js): taller than the demo feed, the agent's markdown inside its bubble */
|
|
1684
|
+
.c3 .c3t-panel .feed.conv{max-height:min(460px,55vh);padding-right:4px}
|
|
1685
|
+
.c3 .c3t-panel .m.me{white-space:pre-wrap}
|
|
1686
|
+
.c3 .c3t-panel .m.bot .c3t-md{display:grid;gap:6px}
|
|
1687
|
+
.c3 .c3t-panel .m.bot .c3t-md>*{margin:0}
|
|
1688
|
+
.c3 .c3t-panel .m.bot ul,.c3 .c3t-panel .m.bot ol{padding-left:18px}
|
|
1689
|
+
.c3 .c3t-panel .m.bot code{font:var(--fs-2) var(--mono)}
|
|
1690
|
+
.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
1691
|
.c3 .c3t-tl2 .tlr{display:flex;gap:var(--sp-2);align-items:baseline;padding:6px 0;border-bottom:1px solid var(--border)}
|
|
1668
1692
|
.c3 .c3t-tl2 .tlr .lab{width:78px}
|
|
1669
1693
|
.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.
|
|
12
|
-
*
|
|
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) ?
|
|
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
|
-
|
|
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) => {
|
|
68
|
-
|
|
69
|
-
|
|
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) =>
|
|
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
|
*
|
package/workspace/server.js
CHANGED
|
@@ -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
|
-
|
|
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);
|