create-pmos 0.190.0 → 0.192.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-driver-fixture.js +1 -1
- package/workspace/c3-driver-test.js +3 -3
- package/workspace/c3-phone-test.js +22 -1
- package/workspace/c3-work-test.js +4 -1
- package/workspace/guide/console3-sessions.md +11 -0
- package/workspace/guide/initiative-driver.md +3 -3
- package/workspace/guide/phone-companion.md +4 -4
- package/workspace/lib/driver-cli.js +2 -3
- package/workspace/lib/driver-run.js +1 -1
- package/workspace/lib/driver.js +7 -15
- package/workspace/lib/headless.js +81 -7
- package/workspace/lib/routes-driver.js +1 -1
- package/workspace/lib/routes-report-setups.js +3 -0
- package/workspace/lib/routes-runs.js +30 -3
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-driver.js +4 -6
- package/workspace/public/pm/c3-focus.css +51 -0
- package/workspace/public/pm/c3-focus.js +83 -3
- package/workspace/public/pm/run-card.js +1 -0
- package/workspace/public/pm/sessions-panel.js +1 -1
- package/workspace/server.js +37 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.192.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-13T07:20:02Z"}
|
|
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-13T07:20:02Z"}
|
|
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-13T07:20:02Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -43,7 +43,7 @@ async function start({ name = 'c3-driver' } = {}) {
|
|
|
43
43
|
const tI = { kind: 'initiative', ws, id: 'drv-ui' };
|
|
44
44
|
const now = Date.now();
|
|
45
45
|
// Today's run must fall on today: a run 5 hours back is yesterday's between midnight and 05:00, and the strip then reads
|
|
46
|
-
// "0
|
|
46
|
+
// "0 runs today". So the same-day timeline is squeezed into the time since midnight when that is shorter.
|
|
47
47
|
const H = Math.min(HOUR, Math.floor((now - new Date(now).setHours(0, 0, 0, 0)) / 6));
|
|
48
48
|
const w = (line, at) => D.append(ledger, line, { now: at });
|
|
49
49
|
const prop = (id, target, what, data, why, sources, at, run) => { w({ ev: 'proposal', id, target, what, data, why, sources: sources || [], run, by: 'agent:sess-4.0a1b2c3d' }, at); return id; };
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* LIVE mode: a real server and a scratch repository whose driver.jsonl this suite seeds (c3-driver-fixture.js; PMOS_DRIVER=off, so no
|
|
5
5
|
* run starts and every line the page shows is one the fixture wrote). A real Chromium drives /c3.html.
|
|
6
6
|
*
|
|
7
|
-
* strip Initiative · Overview shows the Agent strip: the count, Pause, "1
|
|
7
|
+
* strip Initiative · Overview shows the Agent strip: the count, Pause, "1 run today", the status, the last action, the
|
|
8
8
|
* blocker line, one row per open proposal; a row opens to its text and sources (a URL source is a link)
|
|
9
9
|
* runs the runs chip opens a sheet that lists today's run
|
|
10
10
|
* decide Keep on a problem proposal posts /api/driver/keep and the row turns "Kept by you"; Send back asks for a note and posts it;
|
|
@@ -58,7 +58,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
|
|
|
58
58
|
const rows = await st(() => [...document.querySelectorAll('[data-drv-row]')].map((r) => r.dataset.drvRow));
|
|
59
59
|
ok('S1 the Agent strip draws one row per open proposal, in the order the agent filed them', rows.join() === [ids.problem, ids.evidence, ids.sizing, ids.move].join(), rows.join());
|
|
60
60
|
const head = await st(() => document.querySelector('[data-drv="strip"] .sh2').textContent);
|
|
61
|
-
ok('S2 the header says AGENT 4, has Pause and "1
|
|
61
|
+
ok('S2 the header says AGENT 4, has Pause and "1 run today", with no limit', /Agent\s*4/i.test(head) && /Pause/.test(head) && /1 run today/.test(head) && !/ of \d+ runs/.test(head), head);
|
|
62
62
|
const body = await st(() => document.querySelector('[data-drv="strip"]').textContent);
|
|
63
63
|
ok('S3 the status line says the agent is idle and waiting on you, with the last action', /Agent is idle · waiting on you/.test(body) && /The oldest proposal is 2 days old/.test(body) && /Last: filed 4 proposals/.test(body), body.slice(0, 300));
|
|
64
64
|
ok('S4 the blocker line names who stopped the agent', /Stopped by: you\./.test(await st(() => document.querySelector('[data-drv="blocker"]').textContent)));
|
|
@@ -70,7 +70,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
|
|
|
70
70
|
await page.click('[data-drv="runs"]');
|
|
71
71
|
await sel('[data-drv="runs-sheet"]');
|
|
72
72
|
const runs = await st(() => document.querySelector('[data-shell="sheet"]').textContent);
|
|
73
|
-
ok('S7 the runs chip opens a sheet with today\'s run', /Agent runs today/.test(runs) && /1
|
|
73
|
+
ok('S7 the runs chip opens a sheet with today\'s run', /Agent runs today/.test(runs) && /1 run today/.test(runs) && /no daily limit/.test(runs) && /Filed 4 proposals/.test(runs), runs.slice(0, 300));
|
|
74
74
|
await page.keyboard.press('Escape');
|
|
75
75
|
|
|
76
76
|
/* ── decide ── */
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* nodesk there is no Full console; a phone that chose it before 2026-10-13 shows the companion again
|
|
17
17
|
* focus Record on a meeting opens focus in one column: the clock, the now line, Topics, Captured, Note and Your slides as sheets,
|
|
18
18
|
* a context chip in a sheet, Ask and Stop at the foot, nothing wider than the screen; Back to your day closes it and recording goes on
|
|
19
|
+
* two sides focus is two fixed halves that never scroll (v6): the room's latest line large over the one before it, and PMOS's newest
|
|
20
|
+
* note alone below; ‹ › steps back through earlier notes, "N newer" returns; held notes open in a sheet and pull one in
|
|
19
21
|
* narrow a long title and a long list of people never widen the companion past the screen
|
|
20
22
|
* desktop at 1280 wide no companion is drawn
|
|
21
23
|
* 620 the companion shows up to 640 wide, where the phone styling starts
|
|
@@ -141,7 +143,8 @@ const writes = (p) => (window.__c3demoWrites || []).filter((w) => w.path === p |
|
|
|
141
143
|
await waitFor(page, () => !document.querySelector('[data-shell="sheet"] [data-fc="note-sheet"]'), null, 3000);
|
|
142
144
|
await page.fill('[data-c3focus] [data-fc="ask-input"]', 'What did Budi ask?');
|
|
143
145
|
await page.press('[data-c3focus] [data-fc="ask-input"]', 'Enter');
|
|
144
|
-
ok('record: Ask PMOS quietly
|
|
146
|
+
ok('record: Ask PMOS quietly shows the answer with your question, as the newest note', await waitFor(page, () => { const s = document.querySelector('[data-c3focus] [data-fc="stream"]'); const at = s?.querySelector('[data-fc="pg-at"]')?.textContent || ''; const k = at.match(/^(\d+) of (\d+)$/);
|
|
147
|
+
return /You asked: What did Budi ask/.test(s?.textContent || '') && !!k && k[1] === k[2]; }, null, 5000));
|
|
145
148
|
await page.click('[data-c3focus] [data-fc="leave"]');
|
|
146
149
|
ok('record: Back to your day closes focus and keeps recording', await waitFor(page, () => !document.querySelector('[data-c3focus]') && !!document.querySelector('[data-c3ph-recbar] [data-c3ph-stop]'), null, 5000));
|
|
147
150
|
ok('record: the bar has Focus', !!(await page.$('[data-c3ph-recbar] [data-c3ph-focus-bar]')));
|
|
@@ -172,6 +175,24 @@ const writes = (p) => (window.__c3demoWrites || []).filter((w) => w.path === p |
|
|
|
172
175
|
ok('focus: no stage on the page itself, no desktop Leave focus or End meeting', await page.evaluate(() => !document.querySelector('[data-c3focus] [data-fc="stage"]') && !/Leave focus|End meeting/.test(document.querySelector('[data-c3focus]').textContent)));
|
|
173
176
|
ok('focus: nothing is wider than the phone', await page.evaluate(() => { const f = document.querySelector('[data-c3focus]'); return f.scrollWidth <= innerWidth + 1 && [...f.querySelectorAll('*')].every((n) => n.getBoundingClientRect().right <= innerWidth + 1 || n.closest('.fc-cxs')); }),
|
|
174
177
|
await page.evaluate(() => [...document.querySelectorAll('[data-c3focus] *')].filter((n) => n.getBoundingClientRect().right > innerWidth + 1 && !n.closest('.fc-cxs')).slice(0, 3).map((n) => n.className + ':' + Math.round(n.getBoundingClientRect().right)).join(' ')));
|
|
178
|
+
ok('two sides: the room shows its latest line large, the one before it faint', await page.evaluate(() => { const r = document.querySelector('[data-c3focus] [data-fc="room"]'); const now = r && r.querySelector('[data-fc="said-now"]'); const prev = r && r.querySelector('[data-fc="said-prev"]');
|
|
179
|
+
return !!now && !!prev && parseFloat(getComputedStyle(now).fontSize) > parseFloat(getComputedStyle(prev).fontSize) && !!r.querySelector('[data-fc="gist"]'); }));
|
|
180
|
+
ok('two sides: PMOS shows one note, the newest, with a pager', await page.evaluate(() => { const m = document.querySelector('[data-c3focus] [data-fc="pmos"]'); const at = m.querySelector('[data-fc="pg-at"]')?.textContent || ''; const k = at.match(/^(\d+) of (\d+)$/);
|
|
181
|
+
return m.querySelectorAll('[data-fc="note-card"]').length === 1 && !!k && k[1] === k[2] && +k[2] > 1; }));
|
|
182
|
+
ok('two sides: neither half scrolls and the page fits the screen', await page.evaluate(() => { const f = document.querySelector('[data-c3focus]'); const ft = f.querySelector('[data-fc="phone-foot"]').getBoundingClientRect();
|
|
183
|
+
return ['room', 'pmos'].every((k) => { const n = f.querySelector('[data-fc="' + k + '"]'); return n.scrollHeight <= n.clientHeight + 1 && getComputedStyle(n).overflowY !== 'auto'; }) && ft.bottom <= innerHeight + 1; }));
|
|
184
|
+
const firstNote = await page.textContent('[data-c3focus] [data-fc="note-card"] .fc-b');
|
|
185
|
+
await page.click('[data-c3focus] [data-fc="pg-prev"]');
|
|
186
|
+
ok('two sides: ‹ steps back to an earlier note and offers the newer one', await waitFor(page, (t) => { const c = document.querySelector('[data-c3focus] [data-fc="note-card"] .fc-b'); return !!c && c.textContent !== t && !!document.querySelector('[data-c3focus] [data-fc="pg-newest"]'); }, firstNote, 3000));
|
|
187
|
+
await page.click('[data-c3focus] [data-fc="pg-newest"]');
|
|
188
|
+
ok('two sides: "newer" goes back to the newest note', await waitFor(page, (t) => document.querySelector('[data-c3focus] [data-fc="note-card"] .fc-b')?.textContent === t && !document.querySelector('[data-c3focus] [data-fc="pg-newest"]'), firstNote, 3000));
|
|
189
|
+
if (await page.$('[data-c3focus] [data-fc="held"]')) {
|
|
190
|
+
const before = await page.textContent('[data-c3focus] [data-fc="pg-at"]');
|
|
191
|
+
await page.click('[data-c3focus] [data-fc="held"]');
|
|
192
|
+
ok('two sides: the held chip opens what PMOS is holding', await waitFor(page, () => !!document.querySelector('[data-shell="sheet"] [data-fc="held-sheet"] [data-fc="pull"]'), null, 3000));
|
|
193
|
+
await page.click('[data-shell="sheet"] [data-fc="pull"]');
|
|
194
|
+
ok('two sides: pulling one in shows it as the newest note', await waitFor(page, (b) => { const at = document.querySelector('[data-c3focus] [data-fc="pg-at"]')?.textContent || ''; const k = at.match(/^(\d+) of (\d+)$/); return !document.querySelector('[data-shell="sheet"] [data-fc="held-sheet"]') && at !== b && !!k && k[1] === k[2] && /early/.test(document.querySelector('[data-c3focus] [data-fc="note-card"]')?.textContent || ''); }, before, 3000));
|
|
195
|
+
} else ok('two sides: the demo meeting holds a note', false);
|
|
175
196
|
ok('focus: the address does not change', await page.evaluate(() => !new URL(location.href).searchParams.has('focus')));
|
|
176
197
|
await page.click('[data-c3focus] [data-fc="topics"]');
|
|
177
198
|
ok('focus: Topics opens as a sheet over focus', await waitFor(page, () => !!document.querySelector('[data-shell="sheet"] [data-fc="topics-sheet"]') && !!document.querySelector('[data-c3focus]'), null, 5000));
|
|
@@ -87,7 +87,10 @@ const W = '[data-pm-work]';
|
|
|
87
87
|
if (!ready) return finish(1);
|
|
88
88
|
|
|
89
89
|
/* the six views and their counts (R1) */
|
|
90
|
-
const
|
|
90
|
+
const readBar = () => st((w) => [...document.querySelectorAll(w + ' [data-pm-lenses] [data-lens]')].map((b) => [b.getAttribute('data-lens'), b.textContent.replace(/\d+$/, '').trim()]), W);
|
|
91
|
+
// A redraw puts a new bar in the header and the quiet layout moves it beside Add work 50 ms later; read it once it has settled.
|
|
92
|
+
await until(async () => (await readBar()).length === VIEWS.length, 3000, 100);
|
|
93
|
+
const bar = await readBar();
|
|
91
94
|
ok('the bar has the six views in order: Inbox, Today, Upcoming, Waiting, Someday, Review', JSON.stringify(bar.map((b) => b[0])) === JSON.stringify(VIEWS) && JSON.stringify(bar.map((b) => b[1])) === JSON.stringify(LABELS), JSON.stringify(bar));
|
|
92
95
|
const c0 = await counts();
|
|
93
96
|
ok('each view shows its count (3, 5, 6, 2, 2, 3)', JSON.stringify(VIEWS.map((v) => c0[v])) === '[3,5,6,2,2,3]', JSON.stringify(c0));
|
|
@@ -91,6 +91,17 @@ If the connection to the workspace server is down, the message is not sent. The
|
|
|
91
91
|
|
|
92
92
|
**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.
|
|
93
93
|
|
|
94
|
+
## Background runs
|
|
95
|
+
|
|
96
|
+
Some jobs run in the background, with no terminal: the Initiative Driver, a refresh of Jira status or My work, and the other jobs PMOS starts by itself. They are listed under **Automatic**.
|
|
97
|
+
|
|
98
|
+
- While a background run works, open it to see each step: the tool it uses and what it does (for example `› Bash pmos driver propose …`), the words of the agent, and a step that failed.
|
|
99
|
+
- When a background run ends well, its row and its card show the first line of the agent's last message, for example "Filed 3 proposals".
|
|
100
|
+
- When a background run does not end well, its row and its card give the reason: you stopped it, it ran past the time limit, the process was ended, it exited with an error code, it reported an error, or it used a tool that is not allowed in a background run.
|
|
101
|
+
- If PMOS restarts while a background run works, its result cannot be read. The row and the card say "Interrupted: PMOS restarted while this run was working". Run the job again.
|
|
102
|
+
|
|
103
|
+
A background run does not send replies to its card as a terminal session does. It files its work with its own commands, for example the Driver's proposals on the initiative.
|
|
104
|
+
|
|
94
105
|
## End a session
|
|
95
106
|
|
|
96
107
|
1. Press the row to open it.
|
|
@@ -38,8 +38,8 @@ The sweep starts a run for a blocker the agent can work on, at most once per blo
|
|
|
38
38
|
|
|
39
39
|
## The limits
|
|
40
40
|
|
|
41
|
-
-
|
|
42
|
-
- At most 3 runs at the same time. The others wait.
|
|
41
|
+
- There is no daily limit on runs.
|
|
42
|
+
- At most 3 runs at the same time, and 1 per initiative or Work task. The others wait.
|
|
43
43
|
- A run that fails or times out counts against the day. Its reason is in the ledger.
|
|
44
44
|
- A paused initiative or Work task starts no run.
|
|
45
45
|
- `PMOS_DRIVER=off` in the environment of the server starts none.
|
|
@@ -52,7 +52,7 @@ In Discover, the agent writes the problem statement, the evidence, the opportuni
|
|
|
52
52
|
|
|
53
53
|
## Where you see the agent
|
|
54
54
|
|
|
55
|
-
- **The Agent strip.** Open an initiative and stay on Overview. The Agent section is at the top. It shows the state of the agent (working, idle, waiting on you, paused or off), the last thing it did, and what stops it ("Stopped by: ..."). Each open proposal is one row with Keep, Send back and Drop. Press a row to read its text and its sources. A source that is a link opens in a new tab. Pause and Resume are in the header. The text "1
|
|
55
|
+
- **The Agent strip.** Open an initiative and stay on Overview. The Agent section is at the top. It shows the state of the agent (working, idle, waiting on you, paused or off), the last thing it did, and what stops it ("Stopped by: ..."). Each open proposal is one row with Keep, Send back and Drop. Press a row to read its text and its sources. A source that is a link opens in a new tab. Pause and Resume are in the header. The text "1 run today" opens the list of today's runs.
|
|
56
56
|
- **Keep, Send back and Drop on a row.** A kept row stays in the strip, marked "Kept by you", until you leave the page. Send back asks for a one-line note. A kept stage move changes the stage on the page at once. These three acts cannot be undone: each adds one line to the ledger.
|
|
57
57
|
- **One line in Work.** A Work task with an open proposal shows one line under its title, for example "Agent drafted a follow-up to Dana. Nothing is sent." Press Review to read the draft. Keep copies the text so you can send it yourself, and records your decision. If the browser does not allow copying, the text is selected and you press Ctrl+C. A suggested next step shows Keep and Drop in the line. Shadow and Off show no line.
|
|
58
58
|
- **Needs you.** In Briefing, the agent has two kinds of row: "Agent has 3 drafts on ..." and "... is waiting on you". Press a row to open the initiative. A long title wraps inside the card.
|
|
@@ -46,14 +46,14 @@ The red button records a conversation or a meeting with the same recorder as **R
|
|
|
46
46
|
|
|
47
47
|
## Focus
|
|
48
48
|
|
|
49
|
-
Focus
|
|
49
|
+
Focus shows the meeting on one screen. You do not scroll it. From the top:
|
|
50
50
|
|
|
51
51
|
- **Back to your day**, the title, and the clock: the time since the start and the time left. Under them, the bar of what the room talked about.
|
|
52
52
|
- The topic that the room is on now, and how long it has been on it.
|
|
53
53
|
- **Topics**, **Captured**, **Note** and **Your slides** (or **Material**). Each one opens a sheet over focus. **Note** shows what the live note has found so far, or the last lines of the transcript.
|
|
54
|
-
- **What's being said**, with context. Tap a context to see its detail.
|
|
55
|
-
-
|
|
56
|
-
- At the bottom, **Ask PMOS quietly** and **Stop**. Only you see your question and the answer. **Stop** asks before it stops the recording.
|
|
54
|
+
- **The room**: the last line that was said, in large text, with the line before it in faint text. It changes when someone speaks. Under it, **What's being said**, with context. Tap a context to see its detail.
|
|
55
|
+
- **PMOS, to you**: only the newest note that PMOS wrote for you, in large text, with the line that it answers. Tap **‹** to see earlier notes. If a new note comes while you look at an earlier one, tap **1 newer** to go to it. PMOS holds some notes for the right moment. Tap **Holding** to see them, and tap **Now** to see one now.
|
|
56
|
+
- At the bottom, **Ask PMOS quietly** and **Stop**. Only you see your question and the answer. The answer shows as the newest note, under your question. **Stop** asks before it stops the recording.
|
|
57
57
|
|
|
58
58
|
A recording that is not on the calendar, for example a talk in the corridor, opens the same focus without topics and slides.
|
|
59
59
|
|
|
@@ -149,11 +149,10 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
|
|
|
149
149
|
const mine = [...state.proposals.values()].filter((p) => D.targetKey(p.target) === D.targetKey(t.target));
|
|
150
150
|
const day = D.localDay(now == null ? Date.now() : now);
|
|
151
151
|
const used = D.runsOnDay(state, t.target, day);
|
|
152
|
-
const
|
|
153
|
-
const out = { target: t.target, paused: D.isPaused(state, t.target), mode: D.modeOf(state, t.target.kind === 'work' ? 'work' : null), runs_today: used, runs_limit: limit,
|
|
152
|
+
const out = { target: t.target, paused: D.isPaused(state, t.target), mode: D.modeOf(state, t.target.kind === 'work' ? 'work' : null), runs_today: used, runs_limit: null,
|
|
154
153
|
proposals: mine.map((p) => ({ id: p.id, what: p.what, state: p.state, why: p.why, note: p.note, at: p.at })) };
|
|
155
154
|
if (json) { say(JSON.stringify({ ok: true, ...out })); return 0; }
|
|
156
|
-
say(`${t.target.kind} ${t.target.id} · mode ${out.mode}${out.paused ? ' · paused' : ''} · ${used}
|
|
155
|
+
say(`${t.target.kind} ${t.target.id} · mode ${out.mode}${out.paused ? ' · paused' : ''} · ${used} ${used === 1 ? 'run' : 'runs'} today`);
|
|
157
156
|
for (const p of out.proposals) say(` ${p.id} · ${p.what} · ${p.state}${p.note ? ` · "${p.note}"` : ''} — ${p.why}`);
|
|
158
157
|
if (!out.proposals.length) say(' no proposals');
|
|
159
158
|
return 0;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* 3. finds the wakes: a `created` placement line (the page, Triage accept and `pmos initiative new` all write one), a board
|
|
10
10
|
* `move` line, a proposal the PM sent back with a note. Each key wakes once (the ledger's `wake` line makes it idempotent);
|
|
11
11
|
* 4. every 30 minutes inside the day-check window, and outside quiet hours, runs the unblock sweep (no model, lib/driver.js);
|
|
12
|
-
* 5. starts the queued runs the rules allow (mode, pause,
|
|
12
|
+
* 5. starts the queued runs the rules allow (mode, pause, 3 at once) through spawnSession with origin `drive:<kind>:<id>`
|
|
13
13
|
* and the skill prompt, which lib/headless.js turns into a `claude -p` run with a fixed tool list.
|
|
14
14
|
* `PMOS_DRIVER=off` does none of it. A run ends in a `run-end` line with its reason when it fails or times out.
|
|
15
15
|
*
|
package/workspace/lib/driver.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The driver is a short headless run for one initiative or one Work task. It reads the record, files what it found as
|
|
5
5
|
* PROPOSALS, and ends. The PM keeps, sends back or drops each proposal. This file is the pure core: the shape of every
|
|
6
|
-
* line in <state folder>/driver.jsonl, the fold, the
|
|
6
|
+
* line in <state folder>/driver.jsonl, the fold, the limits, the modes, the wake queue and the unblock sweep. It reads the
|
|
7
7
|
* clock only through the `now` its callers pass. The only I/O is the thin read/append pair at the bottom.
|
|
8
8
|
*
|
|
9
9
|
* LEDGER · KINDS · WHATS · MODES · DEFAULT_MODE · WAKES · BUDGET
|
|
@@ -28,15 +28,15 @@
|
|
|
28
28
|
* correction { proposal, run, kind: 'return'|'drop', by } one per return or drop; the north star counts it
|
|
29
29
|
* pause|resume { target, by } · mode { board, mode, by } PM only, as above. `board` '*' is every board.
|
|
30
30
|
* wake { key, target, board, reason: created|moved|answered|stuck, blocker? } queued: a run should start
|
|
31
|
-
* run-start { run, target, board, wakes: [key], session } one per run
|
|
31
|
+
* run-start { run, target, board, wakes: [key], session } one per run
|
|
32
32
|
* run-end { run, ok, reason?, agent_run } agent_run is the run record the correction rate reads
|
|
33
33
|
* refused { target, wakes: [key], reason } a wake the rules turned away, recorded
|
|
34
34
|
* blocker { key, day, target, board, code, owner: 'pm'|'agent', text } the sweep's finding, once per key per day
|
|
35
35
|
* epoch { } the first scan of this ledger: older events do not wake
|
|
36
36
|
*
|
|
37
|
-
* BUDGET (PM decision 4
|
|
38
|
-
*
|
|
39
|
-
* a free slot stays queued. Several wakes of one target are answered by ONE run.
|
|
37
|
+
* BUDGET (PM decision 4, as Wawan corrected it on 2026-10-13): no run limit per day, on an initiative, a Work task or in total.
|
|
38
|
+
* At most 3 runs at once and one per target at a time. A wake the mode or a pause turns away is recorded as `refused`; a
|
|
39
|
+
* wake that only waits for a free slot stays queued. Several wakes of one target are answered by ONE run.
|
|
40
40
|
*
|
|
41
41
|
* THE FACTORY NEVER WRITES HERE (D65): the CLI and the routes pass the actor from the call path. This ledger is not an
|
|
42
42
|
* initiative record, an acceptance, an anchor or a stage move; Keep writes those through their own writers.
|
|
@@ -57,7 +57,7 @@ const MODES = Object.freeze(['off', 'shadow', 'approve']);
|
|
|
57
57
|
const DEFAULT_MODE = 'approve';
|
|
58
58
|
const WAKES = Object.freeze(['created', 'moved', 'answered', 'stuck']);
|
|
59
59
|
const EVENTS = Object.freeze(['proposal', 'keep', 'return', 'drop', 'correction', 'pause', 'resume', 'mode', 'wake', 'run-start', 'run-end', 'refused', 'blocker', 'epoch']);
|
|
60
|
-
const BUDGET = Object.freeze({
|
|
60
|
+
const BUDGET = Object.freeze({ concurrent: 3 });
|
|
61
61
|
const DAY = 86400000;
|
|
62
62
|
const PM_WAIT_MS = DAY; // an open proposal older than this makes the PM the blocker
|
|
63
63
|
const SWEEP_EVERY_MS = 30 * 60 * 1000; // the sweep's cadence inside the day-check window
|
|
@@ -76,7 +76,7 @@ const pm = (by) => typeof by === 'string' && by.startsWith('pm:');
|
|
|
76
76
|
|
|
77
77
|
const targetKey = (t) => `${t.kind}:${t.ws}:${t.id}`;
|
|
78
78
|
const wsIdOf = (root) => crypto.createHash('sha1').update(path.resolve(root)).digest('hex').slice(0, 10);
|
|
79
|
-
/** The local calendar day of a time in ms, YYYY-MM-DD. The
|
|
79
|
+
/** The local calendar day of a time in ms, YYYY-MM-DD. The runs of a day are listed per local day. */
|
|
80
80
|
function localDay(ms) {
|
|
81
81
|
const d = new Date(ms);
|
|
82
82
|
const p = (n) => String(n).padStart(2, '0');
|
|
@@ -242,14 +242,12 @@ function openProposals(state, target) {
|
|
|
242
242
|
return state.order.map((id) => state.proposals.get(id)).filter((p) => p.state === 'open' && (!want || targetKey(p.target) === want));
|
|
243
243
|
}
|
|
244
244
|
const runsOnDay = (state, target, day) => [...state.runs.values()].filter((r) => targetKey(r.target) === targetKey(target) && r.start && localDay(Date.parse(r.start)) === day).length;
|
|
245
|
-
const budgetFor = (target) => (target.kind === 'work' ? BUDGET.workPerDay : BUDGET.initiativePerDay);
|
|
246
245
|
|
|
247
246
|
/** May a run start for this target now? `running` is the number of driver runs alive. A closed rule refuses (and the
|
|
248
247
|
* caller records it); a full house only waits. */
|
|
249
248
|
function admit(state, { target, board }, { now, running = 0 } = {}) {
|
|
250
249
|
if (modeOf(state, board) === 'off') return { ok: false, refuse: 'mode-off' };
|
|
251
250
|
if (isPaused(state, target)) return { ok: false, refuse: 'paused' };
|
|
252
|
-
if (runsOnDay(state, target, localDay(now)) >= budgetFor(target)) return { ok: false, refuse: 'budget' };
|
|
253
251
|
if (running >= BUDGET.concurrent) return { ok: false, wait: 'concurrency' };
|
|
254
252
|
return { ok: true };
|
|
255
253
|
}
|
|
@@ -266,18 +264,12 @@ function plan(state, { now, running = 0, activeTargets = [] } = {}) {
|
|
|
266
264
|
}
|
|
267
265
|
const start = []; const refused = [];
|
|
268
266
|
const active = new Set(activeTargets);
|
|
269
|
-
// Plan on a scratch copy of the run counts so four wakes of one target never plan four runs.
|
|
270
|
-
const planned = new Map();
|
|
271
267
|
let live = running;
|
|
272
268
|
for (const g of groups.values()) {
|
|
273
269
|
if (active.has(targetKey(g.target))) continue;
|
|
274
270
|
const verdict = admit(state, g, { now, running: live });
|
|
275
271
|
if (verdict.refuse) { refused.push({ target: g.target, wakes: g.wakes, reason: verdict.refuse }); continue; }
|
|
276
272
|
if (verdict.wait) continue;
|
|
277
|
-
const day = localDay(now);
|
|
278
|
-
const used = runsOnDay(state, g.target, day) + (planned.get(targetKey(g.target)) || 0);
|
|
279
|
-
if (used >= budgetFor(g.target)) { refused.push({ target: g.target, wakes: g.wakes, reason: 'budget' }); continue; }
|
|
280
|
-
planned.set(targetKey(g.target), (planned.get(targetKey(g.target)) || 0) + 1);
|
|
281
273
|
live++;
|
|
282
274
|
start.push(g);
|
|
283
275
|
}
|
|
@@ -6,10 +6,17 @@
|
|
|
6
6
|
* kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | 'drive' | 'scan-web' | 'scan-repo' | 'scan-research-web' | 'scan-research-repo' | 'report-setup' | 'report-find' | null (null: open a pane, as before)
|
|
7
7
|
* argsFor(kind, { modelArgs, addDirs }) → the argv after `claude`
|
|
8
8
|
* parseResult(stdout) → { ok, text, isError, turns, durationMs, costUsd, denied[] }
|
|
9
|
+
* streamLines(line) → { log: [lines], result: object|null } one stream-json event, for the live log
|
|
9
10
|
* logLines(result, exitCode) → the lines the read-only log pane shows at the end
|
|
11
|
+
* outcomeOf(result, { code, signal, timedOut, stopped, timeoutMin }) → { ok, headline, why, turns, durationMs, denied } what the run card says
|
|
10
12
|
*
|
|
11
13
|
* Pure: no I/O, no process. server.js spawnSession starts the child and keeps the session row.
|
|
12
14
|
*
|
|
15
|
+
* initiative background-run-outcome: the run prints `--output-format stream-json`, one JSON event per line, so the log shows
|
|
16
|
+
* each step while the run works (streamLines) and not only when it ends. The last event is the same result object that
|
|
17
|
+
* `--output-format json` prints, so parseResult reads either. outcomeOf is what the Sessions card says about a finished run:
|
|
18
|
+
* its first line of text when it ended well, or the real reason when it did not.
|
|
19
|
+
*
|
|
13
20
|
* A headless run uses the PM's own login, model choice (D75), profile and connectors, exactly as a pane does.
|
|
14
21
|
* It cannot ask for permission, so each kind gets a FIXED tool list here; a connector tool runs only where the
|
|
15
22
|
* PM's own Claude Code settings already allow it, and anything else is denied and reported. No bypass flag is
|
|
@@ -114,7 +121,8 @@ function kindFor({ agent, origin, skill, env } = {}) {
|
|
|
114
121
|
function argsFor(kind, { modelArgs = [], addDirs = [] } = {}) {
|
|
115
122
|
const tools = TOOLS[kind];
|
|
116
123
|
if (!tools) throw new Error('unknown headless kind: ' + kind);
|
|
117
|
-
|
|
124
|
+
// stream-json needs --verbose in print mode; it adds the step events, and the last line is the same result object as `json`.
|
|
125
|
+
const argv = ['-p', '--output-format', 'stream-json', '--verbose', '--no-session-persistence', ...modelArgs, '--allowedTools', tools.join(',')];
|
|
118
126
|
// A web run gets no directory at all, whatever the caller passes: it must not be able to read the repository.
|
|
119
127
|
if (!WEB_KINDS.includes(kind)) for (const d of addDirs) if (d) argv.push('--add-dir', String(d));
|
|
120
128
|
return argv;
|
|
@@ -125,13 +133,23 @@ function timeoutMs(env) {
|
|
|
125
133
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_TIMEOUT_MS;
|
|
126
134
|
}
|
|
127
135
|
|
|
128
|
-
/**
|
|
136
|
+
/** One JSON object per line, or null. */
|
|
137
|
+
function jsonOf(line) {
|
|
138
|
+
const t = String(line || '').trim();
|
|
139
|
+
if (!t.startsWith('{')) return null;
|
|
140
|
+
try { const o = JSON.parse(t); return o && typeof o === 'object' ? o : null; } catch { return null; }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The result object on stdout: the whole of it (`--output-format json`), else the last line whose type is `result`
|
|
144
|
+
* (`stream-json`), else the last line that parses. Anything unreadable is not a result. */
|
|
129
145
|
function parseResult(stdout) {
|
|
130
146
|
const text = String(stdout || '').trim();
|
|
131
|
-
let o =
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
147
|
+
let o = jsonOf(text);
|
|
148
|
+
if (!o || (o.type && o.type !== 'result')) {
|
|
149
|
+
const lines = text.split('\n');
|
|
150
|
+
o = null;
|
|
151
|
+
for (let i = lines.length - 1; i >= 0 && !o; i--) { const x = jsonOf(lines[i]); if (x && x.type === 'result') o = x; }
|
|
152
|
+
if (!o) { const at = text.lastIndexOf('\n{'); const x = jsonOf(at >= 0 ? text.slice(at + 1) : ''); if (x && (!x.type || x.type === 'result')) o = x; }
|
|
135
153
|
}
|
|
136
154
|
if (!o || typeof o !== 'object') return { ok: false, text: text.slice(-2000), isError: true, turns: null, durationMs: null, costUsd: null, denied: [] };
|
|
137
155
|
const denied = Array.isArray(o.permission_denials)
|
|
@@ -147,6 +165,62 @@ function parseResult(stdout) {
|
|
|
147
165
|
};
|
|
148
166
|
}
|
|
149
167
|
|
|
168
|
+
const oneLine = (v, max) => { const t = String(v == null ? '' : v).replace(/\s+/g, ' ').trim(); return t.length > max ? t.slice(0, max - 1) + '…' : t; };
|
|
169
|
+
// The one argument that says what a tool call does: the command, the file, the pattern, the address.
|
|
170
|
+
function toolWhat(input) {
|
|
171
|
+
const i = input && typeof input === 'object' ? input : {};
|
|
172
|
+
for (const k of ['command', 'file_path', 'path', 'pattern', 'url', 'query', 'description', 'prompt']) if (typeof i[k] === 'string' && i[k].trim()) return oneLine(i[k], 160);
|
|
173
|
+
return '';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The live log lines for one stream-json event, and the result when the event is the result. A line that is not an event
|
|
177
|
+
* (a warning the CLI printed) is shown as it is. Text the agent wrote is shown, cut to one short line per block. */
|
|
178
|
+
function streamLines(line) {
|
|
179
|
+
const raw = String(line || '').replace(/\r$/, '');
|
|
180
|
+
if (!raw.trim()) return { log: [], result: null };
|
|
181
|
+
const o = jsonOf(raw);
|
|
182
|
+
if (!o) return { log: [oneLine(raw, 300)], result: null };
|
|
183
|
+
if (o.type === 'result') return { log: [], result: o };
|
|
184
|
+
if (o.type === 'system' && o.subtype === 'init') return { log: [`Started${o.model ? ' · ' + oneLine(o.model, 60) : ''}`], result: null };
|
|
185
|
+
const content = o.message && Array.isArray(o.message.content) ? o.message.content : [];
|
|
186
|
+
const log = [];
|
|
187
|
+
if (o.type === 'assistant') {
|
|
188
|
+
for (const c of content) {
|
|
189
|
+
if (!c || typeof c !== 'object') continue;
|
|
190
|
+
if (c.type === 'tool_use') { const w = toolWhat(c.input); log.push(`› ${oneLine(c.name, 60)}${w ? ' ' + w : ''}`); }
|
|
191
|
+
else if (c.type === 'text' && String(c.text || '').trim()) log.push(oneLine(c.text, 300));
|
|
192
|
+
}
|
|
193
|
+
} else if (o.type === 'user') {
|
|
194
|
+
for (const c of content) {
|
|
195
|
+
if (!c || c.type !== 'tool_result' || !c.is_error) continue;
|
|
196
|
+
const body = Array.isArray(c.content) ? c.content.map((x) => (x && x.text) || '').join(' ') : c.content;
|
|
197
|
+
log.push(` ✗ ${oneLine(body, 200) || 'the tool call failed'}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return { log, result: null };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** What the run card says about a finished background run. `ok` only when the process ended by itself with code 0 and its
|
|
204
|
+
* result is not an error. The headline is the first line of the agent's last message; the reason, when it failed, is the
|
|
205
|
+
* real one: stopped by the PM, the timeout, a signal, the exit code, an error result, or output that was not a result. */
|
|
206
|
+
function outcomeOf(r, { code = null, signal = null, timedOut = false, stopped = false, timeoutMin = null } = {}) {
|
|
207
|
+
const res = r || { ok: false, text: '', isError: true, denied: [] };
|
|
208
|
+
const first = String(res.text || '').split('\n').map((x) => x.replace(/^[#>*\-\s]+/, '').trim()).find(Boolean) || '';
|
|
209
|
+
const denied = Array.isArray(res.denied) ? res.denied : [];
|
|
210
|
+
const base = { turns: res.turns == null ? null : res.turns, durationMs: res.durationMs == null ? null : res.durationMs, denied };
|
|
211
|
+
const ok = !timedOut && !stopped && !signal && code === 0 && res.ok && !res.isError;
|
|
212
|
+
if (ok) return { ok: true, headline: oneLine(first || 'Finished. It wrote no summary.', 160), why: null, ...base };
|
|
213
|
+
let why;
|
|
214
|
+
if (stopped) why = 'Stopped by you.';
|
|
215
|
+
else if (timedOut) why = `Stopped: still running after ${timeoutMin || Math.round(DEFAULT_TIMEOUT_MS / 60000)} minutes.`;
|
|
216
|
+
else if (signal) why = `The process was ended (${signal}).`;
|
|
217
|
+
else if (!res.ok) why = `It exited with code ${code} and printed no result${res.text ? ': ' + oneLine(res.text.split('\n').filter(Boolean).pop(), 200) : '.'}`;
|
|
218
|
+
else if (res.isError) why = `The run reported an error${first ? ': ' + oneLine(first, 200) : '.'}`;
|
|
219
|
+
else why = `It exited with code ${code}.`;
|
|
220
|
+
if (denied.length) why += ` Not allowed in a background run: ${denied.join(', ')}.`;
|
|
221
|
+
return { ok: false, headline: null, why, ...base };
|
|
222
|
+
}
|
|
223
|
+
|
|
150
224
|
function logLines(r, exitCode) {
|
|
151
225
|
const out = [];
|
|
152
226
|
if (r.text) out.push(...String(r.text).split('\n'));
|
|
@@ -158,4 +232,4 @@ function logLines(r, exitCode) {
|
|
|
158
232
|
return out;
|
|
159
233
|
}
|
|
160
234
|
|
|
161
|
-
module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, SCAN_WEB_ORIGINS, SCAN_REPO_ORIGINS, SCAN_RESEARCH_WEB_ORIGINS, SCAN_RESEARCH_REPO_ORIGINS, WEB_KINDS, REPORT_SETUP_ORIGINS, REPORT_FIND_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, logLines, switchedOff };
|
|
235
|
+
module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, SCAN_WEB_ORIGINS, SCAN_REPO_ORIGINS, SCAN_RESEARCH_WEB_ORIGINS, SCAN_RESEARCH_REPO_ORIGINS, WEB_KINDS, REPORT_SETUP_ORIGINS, REPORT_FIND_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, streamLines, outcomeOf, logLines, switchedOff };
|
|
@@ -62,7 +62,7 @@ function stripOf(state, target, { now = Date.now(), board: asked = null } = {})
|
|
|
62
62
|
const open = D.openProposals(state, target);
|
|
63
63
|
return {
|
|
64
64
|
target, board, mode: D.modeOf(state, board), paused: D.isPaused(state, target),
|
|
65
|
-
budget: { used: D.runsOnDay(state, target, day), limit:
|
|
65
|
+
budget: { used: D.runsOnDay(state, target, day), limit: null, concurrent: D.BUDGET.concurrent },
|
|
66
66
|
today: runs.filter((r) => r.start && D.localDay(Date.parse(r.start)) === day).map((r) => ({ run: r.run, start: r.start, end: r.end, ok: r.ok, reason: r.reason, proposals: [...state.proposals.values()].filter((p) => p.run === r.run).length })),
|
|
67
67
|
running: running ? { run: running.run, since: running.start } : null,
|
|
68
68
|
last: ended ? { run: ended.run, at: ended.end, ok: ended.ok, reason: ended.reason, proposals: [...state.proposals.values()].filter((p) => p.run === ended.run).length } : null,
|
|
@@ -181,6 +181,9 @@ function startRun(ctx, w, file, ask, extra) {
|
|
|
181
181
|
const s = ctx.spawnSession({ name: 'Report setup', agent: 'claude', ws: w.id, origin, initialInput: askPrompt(skill, ask, extra),
|
|
182
182
|
extraEnv: { PMOS_REPORT_FILE: file, PMOS_REPORT_RUN: `setup:${ask.id}` },
|
|
183
183
|
onDone: (r) => {
|
|
184
|
+
// A run that a Change has replaced ends after the new run started: its end must not settle the ask (the new run's proposal is
|
|
185
|
+
// still to come), and it must not drop the new run from `runs`.
|
|
186
|
+
if (s && runs.get(ask.id) !== s.id) return;
|
|
184
187
|
runs.delete(ask.id);
|
|
185
188
|
const res = (r && r.result) || {};
|
|
186
189
|
const ok = !!(r && !r.timedOut && !r.stopped && r.code === 0 && res.ok && !res.isError);
|
|
@@ -39,7 +39,8 @@ function sessionsOf(sessDir, live) {
|
|
|
39
39
|
const exitedAt = Date.parse(meta.exitedAt);
|
|
40
40
|
out.push({ id: meta.id, plan_item: meta.plan_item || null, name: meta.name || meta.id, origin: meta.origin || 'manual', cardId: meta.plan_item || null,
|
|
41
41
|
startedAt: meta.created || null, endedAt: exited ? (Number.isFinite(exitedAt) ? exitedAt : st.mtimeMs) : null,
|
|
42
|
-
shown: row ? row.shown || null : null, label: row ? row.label || null : null
|
|
42
|
+
shown: row ? row.shown || null : null, label: row ? row.label || null : null,
|
|
43
|
+
headless: meta.headless || null, outcome: meta.outcome || (row && row.outcome) || null, note: (row && row.note) || meta.note || '' });
|
|
43
44
|
}
|
|
44
45
|
return out;
|
|
45
46
|
}
|
|
@@ -61,6 +62,31 @@ function pendingFor(run, hookLines, answers) {
|
|
|
61
62
|
return out;
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
/* initiative background-run-outcome. A background run (`claude -p`, lib/headless.js) never writes `pmos reply` lines: it files
|
|
66
|
+
* its work through its own verbs (`pmos driver propose`, a skill's filing). Folding its empty reply file always gave "ended
|
|
67
|
+
* without a usable result", even for a run that did its work. Its card comes from how the process ended instead: the agent's
|
|
68
|
+
* own first line when it ended well, or the real reason when it did not. A run recorded before the outcome was kept is read
|
|
69
|
+
* from its note. */
|
|
70
|
+
function outcomeFromNote(note) {
|
|
71
|
+
const n = String(note || '');
|
|
72
|
+
if (n === 'exited (code 0)') return { ok: true, headline: 'Finished. Open the session to read what it did.', why: null };
|
|
73
|
+
if (/^restored after server restart|^interrupted/.test(n)) return { ok: false, why: 'Interrupted: PMOS restarted while this run was working, so its result was not read. Run it again.' };
|
|
74
|
+
if (/^stopped: still running after/.test(n)) return { ok: false, why: n.charAt(0).toUpperCase() + n.slice(1) + '.' };
|
|
75
|
+
if (n === 'ended by the PM') return { ok: false, why: 'Stopped by you.' };
|
|
76
|
+
if (n === 'exited (the run reported an error)') return { ok: false, why: 'The run reported an error. Open the session to read it.' };
|
|
77
|
+
if (n) return { ok: false, why: 'It ' + n.replace(/^exited/, 'exited with') .replace(/\(code (\S+)\)/, 'code $1').replace(/\((signal [^)]+)\)/, '$1').replace(/\s+/g, ' ') + '.' };
|
|
78
|
+
return { ok: false, why: 'It ended without saying why. Open the session to read its log.' };
|
|
79
|
+
}
|
|
80
|
+
function headlessCard(card, s) {
|
|
81
|
+
const o = s.outcome || outcomeFromNote(s.note);
|
|
82
|
+
const facts = [o.turns != null ? o.turns + ' turns' : '', o.durationMs != null ? Math.round(o.durationMs / 1000) + ' s' : ''].filter(Boolean).join(' · ');
|
|
83
|
+
if (o.ok) {
|
|
84
|
+
return { ...card, state: 'done', title: 'Result', failure: null, recover: [], problems: [], fallback: null, nextMoves: [], claims: [],
|
|
85
|
+
result: { type: 'note', headline: o.headline, note: [o.headline, facts, (o.denied || []).length ? 'Not allowed in a background run: ' + o.denied.join(', ') + '.' : ''].filter(Boolean).join(' · '), flags: [], suggest: [] } };
|
|
86
|
+
}
|
|
87
|
+
return { ...card, state: 'failed', title: 'Failed', result: null, claims: [], failure: { code: 'background', why: o.why }, recover: ['open_session'], nextMoves: [] };
|
|
88
|
+
}
|
|
89
|
+
|
|
64
90
|
function lastAtOf(lines) {
|
|
65
91
|
const t = lines.map((l) => l && l.at && Date.parse(l.at)).filter(Number.isFinite);
|
|
66
92
|
return t.length ? Math.max(...t) : 0;
|
|
@@ -79,7 +105,8 @@ function view(ctx, now = Date.now()) {
|
|
|
79
105
|
if (!live && !recent) continue;
|
|
80
106
|
const lines = readLines(path.join(ctx.runtimeDir, 'replies', s.id + '.jsonl'));
|
|
81
107
|
const run = runFor({ session: s.id, plan_item: s.plan_item });
|
|
82
|
-
|
|
108
|
+
let card = cardFor(lines, { run, extra: runAnswers.asEvents(run, answers), ended: !live, ctx: { now, lastAt: lastAtOf(lines) } });
|
|
109
|
+
if (s.headless && !live && !card.result) card = headlessCard(card, s);
|
|
83
110
|
card.asked = card.ask ? card.ask.id : null;
|
|
84
111
|
card.permissions = pendingFor(s.id, readLines(path.join(ctx.runtimeDir, 'hooks', s.id + '.jsonl')), answers);
|
|
85
112
|
cards[s.id] = card;
|
|
@@ -125,4 +152,4 @@ async function handle(req, res, url, ctx) {
|
|
|
125
152
|
return true;
|
|
126
153
|
}
|
|
127
154
|
|
|
128
|
-
module.exports = { prefixes, handle, view, sessionsOf };
|
|
155
|
+
module.exports = { prefixes, handle, view, sessionsOf, headlessCard, outcomeFromNote };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.192.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.192.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -122,7 +122,6 @@ export function openSendBack(ctx, p, onSent) {
|
|
|
122
122
|
/** Plain words for the state of the agent on this initiative, from GET /api/driver/strip. */
|
|
123
123
|
export function statusOf(d, now = Date.now()) {
|
|
124
124
|
const open = d.proposals || [];
|
|
125
|
-
const used = (d.budget && d.budget.used) || 0; const limit = (d.budget && d.budget.limit) || 3;
|
|
126
125
|
const oldest = open.map((p) => daysOld(p.at, now)).reduce((a, b) => Math.max(a, b), 0);
|
|
127
126
|
let last = 'No run yet.';
|
|
128
127
|
if (d.last) last = d.last.ok ? 'Last: ' + (d.last.proposals ? 'filed ' + plural(d.last.proposals, 'proposal') : 'looked and found nothing new') + ' · ' + whenLabel(d.last.at, new Date(now))
|
|
@@ -130,7 +129,6 @@ export function statusOf(d, now = Date.now()) {
|
|
|
130
129
|
if (d.paused) return { ic: 'pause', b: 'Agent is paused', sub: 'It files nothing until you resume.', last };
|
|
131
130
|
if (d.running) return { ic: 'spark', b: 'Agent is working · started ' + whenLabel(d.running.since, new Date(now)), sub: 'It works inside the limits you set and files each result as a proposal.', last };
|
|
132
131
|
if (open.length) return { ic: 'wait', b: 'Agent is idle · waiting on you', sub: oldest >= 1 ? 'The oldest proposal is ' + plural(oldest, 'day') + ' old.' : plural(open.length, 'proposal') + ' wait for your decision.', last };
|
|
133
|
-
if (used >= limit) return { ic: 'clock', b: 'Agent is idle · used its runs for today', sub: 'It runs up to ' + limit + ' times a day on each initiative. It starts again tomorrow.', last };
|
|
134
132
|
return { ic: 'spark', b: 'Agent is idle', sub: 'It starts again when something changes: a stage move, a new initiative, or a note from you.', last };
|
|
135
133
|
}
|
|
136
134
|
/** The line that says what stops the agent, or ''. A blocker the PM owns names the PM. */
|
|
@@ -186,14 +184,14 @@ export function driverStrip(ctx, row) {
|
|
|
186
184
|
}
|
|
187
185
|
function openRuns() {
|
|
188
186
|
const d = S.data; const runs = (d && d.today) || [];
|
|
189
|
-
const used = d && d.budget ? d.budget.used : runs.length;
|
|
187
|
+
const used = d && d.budget ? d.budget.used : runs.length;
|
|
190
188
|
ctx.openSheet({ title: 'Agent runs today', icon: 'clock', body: el('div', { class: 'drv-sheet', 'data-drv': 'runs-sheet' },
|
|
191
|
-
note(row.title + ' · ' + used
|
|
189
|
+
note(row.title + ' · ' + plural(used, 'run') + ' today'),
|
|
192
190
|
runs.length ? chgl(runs.map((r) => el('li', { 'data-drv-run': r.run }, icon('clock', 'sm'),
|
|
193
191
|
el('span', null, el('b', null, whenLabel(r.start)), ' ', r.end ? (r.ok ? 'Run ended' : 'Run did not finish') : 'Running now'),
|
|
194
192
|
tag(r.end ? (r.ok ? (r.proposals ? 'Filed ' + plural(r.proposals, 'proposal') : 'Nothing new') : (r.reason || 'Failed')) : 'Running now'))))
|
|
195
193
|
: el('div', { class: 'fold' }, icon('info', 'sm'), 'The agent has not run on this initiative today.'),
|
|
196
|
-
el('div', { class: 'fold' }, icon('info', 'sm'), '
|
|
194
|
+
el('div', { class: 'fold' }, icon('info', 'sm'), 'There is no daily limit. The agent runs when something changes, at most 3 at once across all initiatives.'),
|
|
197
195
|
el('div', { class: 'iacts chips2' }, pbtn('outline', 'Board settings', 'settings', { onclick: () => { closeAnd(() => ctx.openBoardSettings()); } }))) });
|
|
198
196
|
}
|
|
199
197
|
const closeAnd = (fn) => { import('./c3-sheet.js').then((m) => { m.closeSheet(); fn(); }); };
|
|
@@ -227,7 +225,7 @@ export function driverStrip(ctx, row) {
|
|
|
227
225
|
if (S.err || !d) { put(wrap, sect({ ic: 'spark', title: 'Agent', attrs: { 'aria-label': 'Agent' } },
|
|
228
226
|
S.err ? el('div', { class: 'fold w', role: 'alert', 'data-drv': 'error' }, icon('alert', 'sm'), S.err, ' ', pbtn('outline', 'Try again', null, { onclick: load })) : note('Reading the agent'))); return; }
|
|
229
227
|
if (!d.ledger) { put(wrap, sect({ ic: 'spark', title: 'Agent', attrs: { 'aria-label': 'Agent' } }, el('div', { class: 'fold', 'data-drv': 'no-ledger' }, icon('info', 'sm'), 'This repository has no state folder (pmos/state, state or .pmos), so the agent has nowhere to keep its drafts.'))); return; }
|
|
230
|
-
const used = d.budget.used
|
|
228
|
+
const used = plural(d.budget.used, 'run') + ' today';
|
|
231
229
|
const right = el('div', { class: 'chips2' },
|
|
232
230
|
d.mode === 'approve' ? (d.paused ? pbtn('outline', 'Resume', 'next', { 'data-drv-act': 'resume', onclick: () => pause(false) }) : pbtn('outline', 'Pause', 'pause', { 'data-drv-act': 'pause', onclick: () => pause(true) })) : null,
|
|
233
231
|
lk({ 'data-drv': 'runs', onclick: openRuns }, icon('clock', 'sm'), used));
|
|
@@ -251,3 +251,54 @@
|
|
|
251
251
|
[data-shell="sheet"] [data-fc="stage"] .fc-card { display: flex; gap: var(--sp-2); align-items: center; margin-top: var(--sp-2); padding: var(--sp-2); border: 1px solid var(--border); border-radius: 8px; background: var(--card); flex-wrap: wrap; }
|
|
252
252
|
[data-shell="sheet"] [data-fc="stage"] .fc-card .nb { display: grid; gap: 2px; flex: 1 1 160px; min-width: 0; overflow-wrap: anywhere; }
|
|
253
253
|
[data-shell="sheet"] [data-fc="stage"] .seg { flex-wrap: wrap; }
|
|
254
|
+
/* phone mode, two sides (v6): the room on top, PMOS to you below, each showing only its latest. Nothing scrolls. */
|
|
255
|
+
.c3focus.fc-phone .fc-page { gap: 6px; }
|
|
256
|
+
.c3focus.fc-phone .fc-side { display: flex; align-items: center; gap: 6px; font-size: 11px; letter-spacing: .6px; text-transform: uppercase; color: var(--muted-foreground); flex: none; }
|
|
257
|
+
.c3focus.fc-phone .fc-side .fc-dot { width: 6px; height: 6px; }
|
|
258
|
+
.c3focus.fc-phone .fc-room, .c3focus.fc-phone .fc-me { min-height: 0; display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border-radius: 8px; overflow: hidden; }
|
|
259
|
+
.c3focus.fc-phone .fc-room { flex: 1.15 1 0; border: 1px solid var(--border); background: var(--card); }
|
|
260
|
+
.c3focus.fc-phone .fc-said { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; justify-content: flex-end; gap: 4px; overflow: hidden; container-type: size; }
|
|
261
|
+
.c3focus.fc-phone .fc-said p { margin: 0; flex: none; }
|
|
262
|
+
.c3focus.fc-phone .fc-sp { font-size: var(--fs-1); color: var(--muted-foreground); }
|
|
263
|
+
.c3focus.fc-phone .fc-said-prev { display: flex; gap: 6px; align-items: baseline; opacity: .55; font-size: var(--fs-2); min-width: 0; }
|
|
264
|
+
.c3focus.fc-phone .fc-said-prev.you { justify-content: flex-end; }
|
|
265
|
+
.c3focus.fc-phone .fc-said-prev .fc-st { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
266
|
+
.c3focus.fc-phone .fc-said-now { display: grid; gap: 1px; font-size: 20px; line-height: 1.3; font-weight: 500; animation: fcsaidin .35s ease-out; }
|
|
267
|
+
.c3focus.fc-phone .fc-said-now .fc-sp { font-size: var(--fs-2); font-weight: 600; color: var(--foreground); }
|
|
268
|
+
.c3focus.fc-phone .fc-said-now .fc-st { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
269
|
+
.c3focus.fc-phone .fc-said-now.you { text-align: right; }
|
|
270
|
+
.c3focus.fc-phone .fc-said-now.you .fc-sp { color: var(--acc); }
|
|
271
|
+
.c3focus.fc-phone .fc-said-now.dim { font-size: var(--fs-2); font-weight: 400; }
|
|
272
|
+
@container (max-height: 96px) { .c3focus.fc-phone .fc-said-prev { display: none; } }
|
|
273
|
+
@keyframes fcsaidin { from { opacity: 0; transform: translateY(6px); } }
|
|
274
|
+
.c3focus.fc-phone .fc-room .fc-bandwrap { flex: none; border-top: 1px solid var(--border); padding-top: 6px; }
|
|
275
|
+
.c3focus.fc-phone .fc-room .fc-gist { padding: 0; border: 0; background: none; }
|
|
276
|
+
.c3focus.fc-phone .fc-room .fc-gist p > span { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
277
|
+
.c3focus.fc-phone .fc-room .fc-gist p > .ic { display: none; }
|
|
278
|
+
.c3focus.fc-phone .fc-room .fc-gl { display: inline; font-size: 11px; letter-spacing: .6px; text-transform: uppercase; }
|
|
279
|
+
.c3focus.fc-phone .fc-me { flex: 1.5 1 0; background: color-mix(in oklab, var(--acc) 9%, var(--card)); border: 1px solid color-mix(in oklab, var(--acc) 30%, var(--border)); }
|
|
280
|
+
.c3focus.fc-phone .fc-me.fc-arr { animation: fcarr .9s ease-out; }
|
|
281
|
+
@keyframes fcarr { 0% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--acc) 45%, transparent); } 100% { box-shadow: 0 0 0 10px transparent; } }
|
|
282
|
+
@media (prefers-reduced-motion: reduce) { .c3focus.fc-phone .fc-me.fc-arr, .c3focus.fc-phone .fc-said-now { animation: none; } }
|
|
283
|
+
.c3focus.fc-phone .fc-one { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 6px; overflow: hidden; }
|
|
284
|
+
.c3focus.fc-phone .fc-one > * { flex: none; }
|
|
285
|
+
.c3focus.fc-phone .fc-mh { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
286
|
+
.c3focus.fc-phone .fc-mh .fc-side { color: var(--acc); }
|
|
287
|
+
.c3focus.fc-phone .fc-pg { display: flex; align-items: center; gap: 2px; font-size: var(--fs-1); color: var(--muted-foreground); font-variant-numeric: tabular-nums; }
|
|
288
|
+
.c3focus.fc-phone .fc-pg .ib { width: 32px; height: 32px; min-width: 0; padding: 0; }
|
|
289
|
+
.c3focus.fc-phone .fc-pg .ib:disabled { opacity: .3; }
|
|
290
|
+
.c3focus.fc-phone .fc-onote { flex: 1 1 auto !important; min-height: 0; display: flex; flex-direction: column; gap: 6px; overflow: hidden; }
|
|
291
|
+
.c3focus.fc-phone .fc-onote > * { flex: none; }
|
|
292
|
+
.c3focus.fc-phone .fc-cath { display: flex; align-items: center; gap: 8px; font-size: var(--fs-2); }
|
|
293
|
+
.c3focus.fc-phone .fc-cath b { color: var(--acc); }
|
|
294
|
+
.c3focus.fc-phone .fc-newer { margin-left: auto; min-height: 28px; padding: 2px 10px; border-radius: 6px; background: var(--acc); color: var(--background); font-size: var(--fs-1); font-weight: 600; }
|
|
295
|
+
.c3focus.fc-phone .fc-onote .fc-re { font-size: var(--fs-1); font-style: italic; color: var(--muted-foreground); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
296
|
+
.c3focus.fc-phone .fc-one .fc-b { margin: 0; font-size: 16px; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
|
297
|
+
.c3focus.fc-phone .fc-onote .fc-acts2 { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
298
|
+
.c3focus.fc-phone .fc-onote .fc-meta { font-size: var(--fs-1); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
299
|
+
.c3focus.fc-phone .fc-onote .fc-meta > span + span::before { content: ' · '; }
|
|
300
|
+
.c3focus.fc-phone .fc-dots { display: flex; justify-content: center; gap: 5px; margin-top: auto; }
|
|
301
|
+
.c3focus.fc-phone .fc-pdot { width: 6px; height: 6px; border-radius: 50%; background: color-mix(in oklab, var(--acc) 25%, transparent); }
|
|
302
|
+
.c3focus.fc-phone .fc-on.fc-pdot { background: var(--acc); }
|
|
303
|
+
.c3focus.fc-phone .fc-heldc { align-self: flex-start; min-height: 30px; padding: 2px 10px; border-radius: 6px; border: 1px dashed color-mix(in oklab, var(--acc) 45%, var(--border)); font-size: var(--fs-1); color: var(--muted-foreground); }
|
|
304
|
+
.c3focus.fc-phone:not([data-fc-state="ok"]) .fc-room, .c3focus.fc-phone:not([data-fc-state="ok"]) .fc-me { display: none; }
|
|
@@ -110,7 +110,12 @@ export function openFocus({ event, date, pop, back, phone, note, meet, mtitle }
|
|
|
110
110
|
? el('div', { class: 'fc-page' },
|
|
111
111
|
r.top = el('header', { class: 'fc-phd', 'data-fc': 'phone-head' }),
|
|
112
112
|
r.now = el('div', { class: 'fc-phnow', 'data-fc': 'now-line' }),
|
|
113
|
-
|
|
113
|
+
el('section', { class: 'fc-room', 'data-fc': 'room', 'aria-label': 'The room' },
|
|
114
|
+
el('span', { class: 'fc-side mono' }, el('span', { class: 'fc-dot' }), 'The room'),
|
|
115
|
+
r.tick = el('div', { class: 'fc-said', 'aria-live': 'polite', 'data-fc': 'ticker' }),
|
|
116
|
+
r.band = el('div', { class: 'fc-bandwrap' })),
|
|
117
|
+
r.me = el('section', { class: 'fc-me', 'data-fc': 'pmos', 'aria-label': 'PMOS to you. Only you see it.' },
|
|
118
|
+
r.stream = el('div', { class: 'fc-one', 'data-fc': 'stream' })),
|
|
114
119
|
el('div', { class: 'fc-phft', 'data-fc': 'phone-foot' }, r.form, r.act = el('span', { class: 'fc-phact' })))
|
|
115
120
|
: pop
|
|
116
121
|
? el('div', { class: 'fc-page' },
|
|
@@ -128,7 +133,7 @@ export function openFocus({ event, date, pop, back, phone, note, meet, mtitle }
|
|
|
128
133
|
el('div', { class: 'fc-s' }, r.stage = el('div', { class: 'fc-stagewrap' }), r.cap = el('div', { class: 'fc-capwrap' })),
|
|
129
134
|
el('aside', { class: 'fc-w', 'aria-label': 'Conversation' }, convo)));
|
|
130
135
|
root.append(page);
|
|
131
|
-
F = { id, date: day, ev0: ev, root, r, sig: {}, data: null, offset: 0, ui: { open: new Set(), folded: new Set(), pulled: [], stage: 0, picker: false, newTopic: false, addTopic: false, heldOpen: false, pending: new Map(), failed: new Map(), pill: false },
|
|
136
|
+
F = { id, date: day, ev0: ev, root, r, sig: {}, data: null, offset: 0, ui: { ni: null, open: new Set(), folded: new Set(), pulled: [], stage: 0, picker: false, newTopic: false, addTopic: false, heldOpen: false, pending: new Map(), failed: new Map(), pill: false },
|
|
132
137
|
pop: !!pop, back: !!back, phone: !!phone, noteId: id ? '' : String(note || ''), meet: String(meet || ''), mtitle: String(mtitle || ''), choices: null,
|
|
133
138
|
timers: {}, askTimers: new Map(), sheet: null, sheetOpen: false, status: 'reading', error: '',
|
|
134
139
|
opener: document.activeElement && document.activeElement !== document.body ? document.activeElement : null, seenNotes: 0 };
|
|
@@ -567,6 +572,7 @@ function askBubble(a, err) {
|
|
|
567
572
|
}
|
|
568
573
|
|
|
569
574
|
function drawConversation(d) {
|
|
575
|
+
if (F.phone) { drawPhRoom(d); drawPhMe(d); return; }
|
|
570
576
|
const tail = (d.tail || []).slice(F.pop ? -1 : -2);
|
|
571
577
|
region('tick', tail, () => tail.map((x) => el('p', null, el('span', { class: 'mono' }, x.who), ' ' + x.text)));
|
|
572
578
|
const notes = d.notes || [];
|
|
@@ -606,6 +612,76 @@ function drawConversation(d) {
|
|
|
606
612
|
F.seenNotes = nNotes;
|
|
607
613
|
}
|
|
608
614
|
|
|
615
|
+
/* ── phone mode: two sides, like a translator app in conversation mode (v6) ─────────────────────────────────────────
|
|
616
|
+
The scrolling conversation was hard to follow live, so nothing scrolls. The room gets the top: its latest line, large,
|
|
617
|
+
and the one before it, faint. PMOS gets the bottom: only its newest note, large. ‹ › steps back through earlier notes;
|
|
618
|
+
a note that arrives while you look back shows as "1 newer". Held notes wait behind one chip. */
|
|
619
|
+
|
|
620
|
+
function drawPhRoom(d) {
|
|
621
|
+
const tail = (d.tail || []).slice(-2);
|
|
622
|
+
region('tick', tail, () => {
|
|
623
|
+
if (!tail.length) return [el('p', { class: 'fc-said-now dim', 'data-fc': 'said-now' }, recState(d) === 'recording' ? 'Listening to the room.' : 'PMOS hears the room when it records.')];
|
|
624
|
+
const line = (x, cls, fc) => el('p', { class: cls + (x.who === 'You' ? ' you' : ''), 'data-fc': fc }, el('span', { class: 'fc-sp mono' }, x.who), el('span', { class: 'fc-st' }, x.text));
|
|
625
|
+
const cur = tail[tail.length - 1]; const prev = tail.length > 1 ? tail[0] : null;
|
|
626
|
+
return [prev ? line(prev, 'fc-said-prev', 'said-prev') : null, line(cur, 'fc-said-now', 'said-now')];
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
/** What PMOS has shown you, oldest first: notes, notes you pulled in early, and questions not answered yet (an answer carries its question). */
|
|
630
|
+
function phItems(d) {
|
|
631
|
+
const notes = d.notes || [];
|
|
632
|
+
const pulled = F.ui.pulled.map((id) => notes.find((n) => n.id === id)).filter(Boolean);
|
|
633
|
+
const shown = notes.filter((n) => n.show !== 'hold').concat(pulled.map((n) => ({ ...n, at: F.pulledAt && F.pulledAt[n.id] || n.at, pulledWhy: 'You asked for it early. PMOS was holding it ' + (n.when || 'for later') + '.' })));
|
|
634
|
+
const asks = (d.asks || []).slice();
|
|
635
|
+
for (const [id, p] of F.ui.pending) if (!asks.some((a) => a.id === id)) asks.push({ id, at: p.at, question: p.question, state: 'working' });
|
|
636
|
+
const answered = new Set(shown.map((n) => n.ask).filter(Boolean));
|
|
637
|
+
const qOf = new Map(asks.map((a) => [a.id, a.question]));
|
|
638
|
+
return [...shown.map((n) => ({ kind: 'note', at: n.at, n, q: n.ask ? qOf.get(n.ask) || '' : '' })), ...asks.filter((a) => !answered.has(a.id) && a.state !== 'answered').map((a) => ({ kind: 'ask', at: a.at, a }))]
|
|
639
|
+
.map((x, i) => ({ ...x, i })).sort((a, b) => a.at - b.at || a.i - b.i);
|
|
640
|
+
}
|
|
641
|
+
function drawPhMe(d) {
|
|
642
|
+
const items = phItems(d); const N = items.length; const last = N - 1;
|
|
643
|
+
const i = F.ui.ni == null ? last : Math.max(0, Math.min(F.ui.ni, last));
|
|
644
|
+
const newer = F.ui.ni == null ? 0 : last - i;
|
|
645
|
+
const held = (d.notes || []).filter((n) => n.show === 'hold' && !F.ui.pulled.includes(n.id));
|
|
646
|
+
const comp = d.companion || {}; const recording = recState(d) === 'recording';
|
|
647
|
+
const x = items[i];
|
|
648
|
+
const sig = [items.map((y) => y.kind === 'note' ? [y.n.id, y.n.pulledWhy ? 1 : 0, y.q] : [y.a.id, y.a.state]), i, newer, held.map((n) => n.id), recording, !!comp.on, comp.error || '', [...F.ui.failed]];
|
|
649
|
+
region('stream', sig, () => {
|
|
650
|
+
const head = el('div', { class: 'fc-mh' }, el('span', { class: 'fc-side mono' }, icon('spark', 'sm'), 'PMOS, to you'),
|
|
651
|
+
N > 1 ? el('span', { class: 'fc-pg' }, ib('Earlier note', 'prev', { 'data-fc': 'pg-prev', ...(i === 0 ? { disabled: '' } : {}) }), el('span', { class: 'mono', 'data-fc': 'pg-at' }, (i + 1) + ' of ' + N), ib('Later note', 'next', { 'data-fc': 'pg-next', ...(i === last ? { disabled: '' } : {}) })) : null);
|
|
652
|
+
let body;
|
|
653
|
+
if (!x) body = el('p', { class: 'fc-b dim', 'data-fc': 'listening' }, !recording ? 'PMOS listens when the meeting records.' : comp.on === false ? 'PMOS is listening to the transcript only. The companion is off, so it adds no notes.' : 'Listening. A note shows here when it helps.');
|
|
654
|
+
else if (x.kind === 'ask') {
|
|
655
|
+
const err = F.ui.failed.get(x.a.id);
|
|
656
|
+
body = el('div', { class: 'fc-onote', 'data-fc': 'ask-q' }, el('span', { class: 'fc-re' }, 'You asked: ' + x.a.question),
|
|
657
|
+
el('p', { class: 'fc-b dim', role: 'status' }, err ? 'PMOS could not answer: ' + err : 'PMOS is reading the record.'));
|
|
658
|
+
} else {
|
|
659
|
+
const n = x.n; const why = n.pulledWhy || n.why;
|
|
660
|
+
body = el('div', { class: 'fc-onote', 'data-fc': 'note-card', 'data-cat': n.cat },
|
|
661
|
+
el('span', { class: 'fc-cath' }, el('b', null, n.cat), el('span', { class: 'dim mono' }, hhmm(n.at)),
|
|
662
|
+
newer ? button({ variant: 'ghost', label: [newer + ' newer', icon('next', 'sm')], attrs: { class: 'fc-newer', 'data-fc': 'pg-newest', title: 'Show the newest note' } }) : null),
|
|
663
|
+
x.q ? el('span', { class: 'fc-re' }, 'You asked: ' + x.q) : n.heard ? el('span', { class: 'fc-re' }, n.heard) : null,
|
|
664
|
+
el('p', { class: 'fc-b' }, n.body),
|
|
665
|
+
(n.acts || []).length ? el('div', { class: 'fc-acts2' }, ...n.acts.map(actBtn)) : null,
|
|
666
|
+
el('span', { class: 'fc-meta dim' }, why ? el('span', null, el('b', null, 'Why now'), ' ' + why) : null, n.src ? el('span', null, 'from ' + n.src) : null));
|
|
667
|
+
}
|
|
668
|
+
return [head, body,
|
|
669
|
+
N > 1 ? el('div', { class: 'fc-dots', 'aria-hidden': 'true' }, ...items.map((_, k) => el('i', { class: 'fc-pdot' + (k === i ? ' fc-on' : '') }))) : null,
|
|
670
|
+
held.length ? button({ variant: 'ghost', label: [icon('clock', 'sm'), 'Holding ' + held.length + ' for later'], attrs: { class: 'fc-heldc', 'data-fc': 'held', title: 'What PMOS is holding for the right moment' } }) : null,
|
|
671
|
+
comp.error ? el('p', { class: 'dim fc-note', role: 'status' }, comp.error) : null];
|
|
672
|
+
});
|
|
673
|
+
if (N > F.seenNotes && F.ui.ni == null && F.seenNotes) { const m = F.r.me; m.classList.remove('fc-arr'); void m.offsetWidth; m.classList.add('fc-arr'); }
|
|
674
|
+
F.seenNotes = N;
|
|
675
|
+
}
|
|
676
|
+
function heldBuild() {
|
|
677
|
+
const held = (F.data.notes || []).filter((n) => n.show === 'hold' && !F.ui.pulled.includes(n.id));
|
|
678
|
+
return [el('div', { class: 'fc-heldsheet', 'data-fc': 'held-sheet' },
|
|
679
|
+
el('p', { class: 'dim fc-note' }, 'Each one waits for the moment it fits. Pull one in now if you want it early.'),
|
|
680
|
+
held.length ? el('ul', { class: 'list', role: 'list' }, ...held.map((n) => el('li', { class: 'nrow' }, el('span', { class: 'ki' }, icon('clock')),
|
|
681
|
+
el('div', { class: 'nb' }, el('span', { class: 'tt' }, n.cat), n.when ? el('div', { class: 'dim fc-note' }, 'Shows ' + n.when) : null),
|
|
682
|
+
pbtn('outline', 'Now', null, { 'data-fc': 'pull', 'data-id': n.id, title: 'Show it now' })))) : el('p', { class: 'dim fc-note' }, 'Nothing is held.'))];
|
|
683
|
+
}
|
|
684
|
+
|
|
609
685
|
function askForm() {
|
|
610
686
|
const input = el('input', { type: 'text', 'aria-label': 'Ask PMOS, only you see it', placeholder: 'Ask PMOS quietly', maxlength: '500', autocomplete: 'off', 'data-fc': 'ask-input' });
|
|
611
687
|
const form = el('form', { class: 'fc-in', 'data-fc': 'ask-form' }, input, ib('Ask', 'next', { type: 'submit', 'data-fc': 'ask' }));
|
|
@@ -622,7 +698,7 @@ async function ask(question) {
|
|
|
622
698
|
const r = await pmPost('/api/meeting-focus/ask', body);
|
|
623
699
|
if (F !== f) return;
|
|
624
700
|
if (!r || !r.ok) { toast({ text: (r && r.error) || 'The question was not sent.', tone: 'error' }); return; }
|
|
625
|
-
f.ui.pending.set(r.id, { question, at });
|
|
701
|
+
f.ui.pending.set(r.id, { question, at }); f.ui.ni = null;
|
|
626
702
|
draw();
|
|
627
703
|
const t = setInterval(async () => {
|
|
628
704
|
const s = await pmGet('/api/meeting-focus/ask?id=' + encodeURIComponent(r.id));
|
|
@@ -800,6 +876,9 @@ function onClick(ev) {
|
|
|
800
876
|
if (k === 'open-link' || k === 'present') { openUrl(t.dataset.url); return; }
|
|
801
877
|
if (k === 'start') { startRecording(); return; }
|
|
802
878
|
if (k === 'end') { endMeeting(); return; }
|
|
879
|
+
if (k === 'pg-prev' || k === 'pg-next') { const N = phItems(d).length; const i = F.ui.ni == null ? N - 1 : F.ui.ni; const j = k === 'pg-prev' ? Math.max(0, i - 1) : i + 1; F.ui.ni = j >= N - 1 ? null : j; drawConversation(d); return; }
|
|
880
|
+
if (k === 'pg-newest') { F.ui.ni = null; drawConversation(d); return; }
|
|
881
|
+
if (k === 'held' && F.phone) { showSheet('held', 'PMOS is holding', 'clock', heldBuild); return; }
|
|
803
882
|
if (k === 'fold') { F.ui.folded.add(t.dataset.id); drawConversation(d); return; }
|
|
804
883
|
if (k === 'unfold') { const id = t.dataset.id; if (F.ui.open.has(id)) F.ui.open.delete(id); else F.ui.open.add(id); F.ui.folded.delete(id); drawConversation(d); return; }
|
|
805
884
|
if (k === 'pull') {
|
|
@@ -830,5 +909,6 @@ document.addEventListener('click', (ev) => {
|
|
|
830
909
|
if (k === 'material-pick') { F.ui.stage = Number(t.dataset.i) || 0; refreshSheet(true); return; }
|
|
831
910
|
if (k === 'note-tab') { F.ui.noteTab = t.dataset.tab === 'said' ? 'said' : 'note'; refreshSheet(true); return; }
|
|
832
911
|
if (k === 'stop-keep') { closeSheet(); return; }
|
|
912
|
+
if (k === 'pull') { const id = t.dataset.id; F.ui.pulled.push(id); (F.pulledAt ||= {})[id] = nowMs(); F.ui.ni = null; closeSheet(); drawConversation(F.data); return; }
|
|
833
913
|
if (k === 'stop-ok') { closeSheet(); endMeeting().then(() => { if (F && F.phone) closeFocus(); }); return; }
|
|
834
914
|
});
|
|
@@ -81,6 +81,7 @@ function failBox(ctx, card, ui) {
|
|
|
81
81
|
else if (code === 'blocked') { title = 'Blocked'; why = card.failure.why; }
|
|
82
82
|
else if (code === 'api_error') { title = 'The turn failed on an API error'; why = 'Nothing on the card changed. Retrying usually works.'; retry = true; }
|
|
83
83
|
else if (code === 'format') { title = 'Recorded in a newer format'; why = card.failure.why; }
|
|
84
|
+
else if (code === 'background') { title = 'The background run did not finish its work'; why = card.failure.why; retry = false; }
|
|
84
85
|
else if (code === 'crashed') { title = 'The session ended with an error'; why = 'It exited with a non-zero code. Nothing on the card changed.'; retry = true; }
|
|
85
86
|
else { title = 'The session ended without a usable result'; why = 'Nothing on the card changed.'; retry = true; }
|
|
86
87
|
} else return null;
|
|
@@ -31,7 +31,7 @@ const liveWord = (s, c) => (plain(s, c) ? s.label : stateWord(c || { state: 'wor
|
|
|
31
31
|
function outcomeOf(card, meta) {
|
|
32
32
|
if (meta && meta.out) return meta.out;
|
|
33
33
|
if (!card) return 'ended';
|
|
34
|
-
if (card.state === 'done' && card.result) return 'Result: ' + card.result.type;
|
|
34
|
+
if (card.state === 'done' && card.result) return card.result.headline || ('Result: ' + card.result.type);
|
|
35
35
|
if (card.state === 'failed') return (card.failure && card.failure.why) || 'Failed';
|
|
36
36
|
return card.title || 'ended';
|
|
37
37
|
}
|
package/workspace/server.js
CHANGED
|
@@ -2639,6 +2639,7 @@ function persist(s) {
|
|
|
2639
2639
|
if (s.autoDelete) meta.autoDelete = s.autoDelete;
|
|
2640
2640
|
if (s.autoEnded) meta.autoEnded = true;
|
|
2641
2641
|
if (s.headless) meta.headless = s.headless;
|
|
2642
|
+
if (s.outcome) meta.outcome = s.outcome; // a background run's end, for its card (initiative background-run-outcome)
|
|
2642
2643
|
if (s.exitedAt) meta.exitedAt = s.exitedAt;
|
|
2643
2644
|
// The breakdown item a Hand to agent session is bound to, so a restart keeps the binding (pm-os-absorb Run 1).
|
|
2644
2645
|
if (s.plan_item) meta.plan_item = s.plan_item;
|
|
@@ -2729,6 +2730,14 @@ function restorePersisted() {
|
|
|
2729
2730
|
// seed: this server was DOWN, so output produced in the gap never reached our ring buffer.
|
|
2730
2731
|
// Control mode replays no history, so take tmux's — it is the superset (R7 + scrollback).
|
|
2731
2732
|
attachPty(s, { seed: true }); // the process is alive — reconnect to it, live
|
|
2733
|
+
} else if (meta.headless && meta.state !== 'exited') {
|
|
2734
|
+
// A background run that was still working when this server stopped. Its process (detached) may have finished on its own,
|
|
2735
|
+
// but its output went nowhere: say so on the row, in its log and on its card, instead of a blank "Running in the background".
|
|
2736
|
+
s.state = 'exited';
|
|
2737
|
+
s.note = 'interrupted: PMOS restarted while this background run was working';
|
|
2738
|
+
s.outcome = { ok: false, headline: null, why: 'Interrupted: PMOS restarted while this run was working, so its result was not read. Run it again.', turns: null, durationMs: null, denied: [] };
|
|
2739
|
+
s.buf += `\r\n\x1b[38;5;203m[${s.note}]\x1b[0m\r\n`;
|
|
2740
|
+
if (!s.exitedAt) { try { s.exitedAt = new Date(fs.statSync(path.join(SESS_DIR, f)).mtimeMs).toISOString(); } catch {} }
|
|
2732
2741
|
} else {
|
|
2733
2742
|
s.state = 'exited';
|
|
2734
2743
|
s.note = meta.note || 'restored after server restart — scrollback preserved; ↻ respawn to start a new process';
|
|
@@ -2949,6 +2958,7 @@ function sessionList() {
|
|
|
2949
2958
|
automatic: sessionOrigin.isAutomatic(s.origin), // the workspace started it, not the PM: the dock hides these by default
|
|
2950
2959
|
auto_delete: s.autoDelete || null,
|
|
2951
2960
|
headless: s.headless || null, // workspace-headless-jobs: a background run with no terminal ('notes' or 'skill')
|
|
2961
|
+
outcome: s.outcome || null, // background-run-outcome: how a finished background run ended (lib/headless.js outcomeOf)
|
|
2952
2962
|
plan_item: s.plan_item || null, // Hand to agent: the breakdown item this session is bound to (pm-os-absorb Run 1)
|
|
2953
2963
|
// No `token` and no `tokenHash` here, ever: the page never sees a session token (F5; r25-guards.test.js checks it).
|
|
2954
2964
|
// Derived presentation state, JOINED from both sources: the cadence clock above and the
|
|
@@ -3497,9 +3507,23 @@ function startHeadless(base, workdir, kind, launchArgs, env, initialInput, addDi
|
|
|
3497
3507
|
say(`\x1b[38;5;203m✗ ${s.note}\x1b[0m\n`); persist(s); pushSessions(); pushWorkspaces(); return s;
|
|
3498
3508
|
}
|
|
3499
3509
|
s.child = child;
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3510
|
+
// initiative background-run-outcome: stdout is stream-json, one event per line. Each step goes into the log as it happens,
|
|
3511
|
+
// so the session shows what the run is doing. Only the result line and a short tail are kept for the end.
|
|
3512
|
+
let out = '', err = '', timedOut = false, part = '', resultLine = '';
|
|
3513
|
+
const TAIL = 64 * 1024;
|
|
3514
|
+
const dim = (t) => `\x1b[38;5;244m${t}\x1b[0m`;
|
|
3515
|
+
const feed = (line) => {
|
|
3516
|
+
const ev = headlessLib.streamLines(line);
|
|
3517
|
+
if (ev.result) resultLine = line;
|
|
3518
|
+
if (ev.log.length) say(ev.log.map((l) => (/^›/.test(l) ? dim(l) : l)).join('\n') + '\n');
|
|
3519
|
+
};
|
|
3520
|
+
child.stdout.on('data', (d) => {
|
|
3521
|
+
s.lastOutputAt = Date.now();
|
|
3522
|
+
out = (out + d).slice(-TAIL);
|
|
3523
|
+
part += d;
|
|
3524
|
+
const lines = part.split('\n'); part = lines.pop();
|
|
3525
|
+
for (const l of lines) feed(l);
|
|
3526
|
+
});
|
|
3503
3527
|
child.stderr.on('data', (d) => { if (err.length < 65536) err += d; });
|
|
3504
3528
|
child.on('error', (e) => { err += String(e && e.message || e); });
|
|
3505
3529
|
const timer = setTimeout(() => {
|
|
@@ -3517,15 +3541,20 @@ function startHeadless(base, workdir, kind, launchArgs, env, initialInput, addDi
|
|
|
3517
3541
|
clearTimeout(timer); clearInterval(beat);
|
|
3518
3542
|
s.child = null; s.state = 'exited';
|
|
3519
3543
|
if (s.scratchDir) { try { fs.rmSync(s.scratchDir, { recursive: true, force: true }); } catch { /* the temp folder goes with the next clean-up */ } }
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
if (
|
|
3524
|
-
|
|
3544
|
+
if (part) { feed(part); part = ''; }
|
|
3545
|
+
const r = headlessLib.parseResult(resultLine || out);
|
|
3546
|
+
if (r.ok) say('\n' + headlessLib.logLines(r, code).join('\n') + '\n');
|
|
3547
|
+
else if (out.trim() && !resultLine) say(r.text.split('\n').slice(-20).join('\n') + '\n');
|
|
3548
|
+
if (err.trim()) say(dim(err.trim().split('\n').slice(-20).join('\n')) + '\n');
|
|
3549
|
+
const timeoutMin = Math.max(1, Math.round(headlessLib.timeoutMs() / 60000));
|
|
3550
|
+
s.note = timedOut ? `stopped: still running after ${timeoutMin} minutes`
|
|
3525
3551
|
: s.stoppedByPm ? 'ended by the PM'
|
|
3526
3552
|
: signal ? `exited (signal ${signal})`
|
|
3527
3553
|
: (code === 0 && r.ok && r.isError) ? 'exited (the run reported an error)'
|
|
3528
3554
|
: `exited (code ${code})`;
|
|
3555
|
+
// What the Sessions card says about this run (lib/routes-runs.js): the agent's own first line, or the real reason it failed.
|
|
3556
|
+
s.outcome = headlessLib.outcomeOf(r, { code, signal, timedOut, stopped: !!s.stoppedByPm, timeoutMin });
|
|
3557
|
+
if (!s.outcome.ok && err.trim() && !r.ok) s.outcome.why += ' ' + err.trim().split('\n').filter(Boolean).pop().slice(0, 200);
|
|
3529
3558
|
if (s.note !== 'exited (code 0)') say(`\x1b[38;5;203m[${s.note}]\x1b[0m\n`);
|
|
3530
3559
|
persist(s); pushSessions();
|
|
3531
3560
|
// page-starts-agent: a caller that started this run from the page reads its result here (a proposal, never a write).
|