create-pmos 0.191.0 → 0.193.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-work-test.js +4 -1
- package/workspace/guide/console-calendar.md +1 -1
- package/workspace/guide/console3-sessions.md +11 -0
- package/workspace/guide/initiative-driver.md +3 -3
- 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-meeting-focus.js +23 -5
- 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.js +6 -4
- package/workspace/public/pm/c3-shell.js +1 -1
- package/workspace/public/pm/run-card.js +1 -0
- package/workspace/public/pm/sessions-panel.js +1 -1
- package/workspace/public/pm/shell-recording.js +2 -3
- 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.193.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-13T08:02:12Z"}
|
|
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-13T08:02:12Z"}
|
|
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-13T08:02:12Z"}
|
|
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 ── */
|
|
@@ -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));
|
|
@@ -137,7 +137,7 @@ When PMOS holds nothing for a past meeting, the detail says so. For a coming mee
|
|
|
137
137
|
|
|
138
138
|
### Meeting focus
|
|
139
139
|
|
|
140
|
-
**Focus** opens one calm page for a meeting that is today. It shows on the detail of a meeting that has not ended, and next to the recording on the header while a meeting is recorded. The page covers the console. **Leave focus** gives the console back. Leaving does not stop the recording. The page stays open when you reload it.
|
|
140
|
+
**Focus** opens one calm page for a meeting that is today. It shows on the detail of a meeting that has not ended, and next to the recording on the header while a meeting is recorded. The page covers the console. **Leave focus** gives the console back. Leaving does not stop the recording. The page stays open when you reload it. From the recording, the page finds the meeting by its start time and title, so it also finds a meeting that has no calendar id, or that moved on the same day after you started to record.
|
|
141
141
|
|
|
142
142
|
The page has these parts:
|
|
143
143
|
|
|
@@ -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.
|
|
@@ -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,
|
|
@@ -55,14 +55,32 @@ function linesOf(runtimeDir, keys) {
|
|
|
55
55
|
|
|
56
56
|
/* ── the meeting, its note and its brief ──────────────────────────────────────────────────────────────────────────── */
|
|
57
57
|
|
|
58
|
+
const minuteOf = (t) => { const ms = Date.parse(t); return Number.isFinite(ms) ? Math.floor(ms / 60000) : null; };
|
|
59
|
+
const normTitle = (t) => require('./calendar-memory.js').normTitle(t);
|
|
60
|
+
/** A meeting named by its start and title (a recording whose meeting has no stored id). The start is compared as an instant, not as
|
|
61
|
+
* text: a note keeps its meeting's start in UTC ("…T07:00:00.000Z") while the calendar writes it with the local offset ("…T15:00:00+08:00").
|
|
62
|
+
* Same minute and same title first, then the only meeting of that title on that local day (a meeting moved after the note linked it). */
|
|
63
|
+
function findByStart(events, by) {
|
|
64
|
+
const m = minuteOf(by.at); if (m === null) return null;
|
|
65
|
+
const rows = (events || []).filter((e) => e && e.id && minuteOf(e.at) !== null);
|
|
66
|
+
const title = by.title ? normTitle(by.title) : '';
|
|
67
|
+
const atMin = rows.filter((e) => minuteOf(e.at) === m);
|
|
68
|
+
const both = title ? atMin.filter((e) => normTitle(e.title) === title) : atMin;
|
|
69
|
+
if (both.length) return both[0];
|
|
70
|
+
if (!title) return null;
|
|
71
|
+
const day = localDay(m * 60000);
|
|
72
|
+
const named = rows.filter((e) => normTitle(e.title) === title && localDay(Date.parse(e.at)) === day);
|
|
73
|
+
return named.length === 1 ? named[0] : null;
|
|
74
|
+
}
|
|
58
75
|
function findEvent(events, id, date, by) {
|
|
59
|
-
if (!id && by && by.at)
|
|
60
|
-
const hit = (events || []).filter((e) => e && e.id && e.at === String(by.at) && (!by.title || e.title === String(by.title)));
|
|
61
|
-
return hit[0] || null;
|
|
62
|
-
}
|
|
76
|
+
if (!id && by && by.at) return findByStart(events, by);
|
|
63
77
|
const rows = (events || []).filter((e) => e && e.id && (String(e.id) === id || baseId(e.id) === baseId(id)));
|
|
64
78
|
const onDay = rows.filter((e) => !date || String(e.at || '').slice(0, 10) === date);
|
|
65
|
-
|
|
79
|
+
const hit = onDay.find((e) => String(e.id) === id) || onDay[0] || null;
|
|
80
|
+
if (hit) return hit;
|
|
81
|
+
// a date that is the UTC day of the start, not its local day (an early-morning meeting east of UTC): the start names it
|
|
82
|
+
if (by && by.at) return findByStart(events, by);
|
|
83
|
+
return rows.find((e) => String(e.id) === id) || null; // an occurrence id names one meeting whatever the day says
|
|
66
84
|
}
|
|
67
85
|
/** A recording that is not on the calendar (a talk in the corridor): the note stands in for the meeting. It has no end, no people
|
|
68
86
|
* and no brief, so focus shows what is said, PMOS's notes and Captured, and no topics from an invite. */
|
|
@@ -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.193.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.193.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));
|
|
@@ -32,6 +32,8 @@ import { toast } from './c3-toast.js';
|
|
|
32
32
|
const POLL_MS = 3000;
|
|
33
33
|
const LAST_MINUTES = 5; // the last minutes of the meeting: the captured items show under the stage
|
|
34
34
|
const pad = (n) => String(n).padStart(2, '0');
|
|
35
|
+
/** The local day of a start: a note keeps its meeting's start in UTC, and its first ten characters can be the day before. */
|
|
36
|
+
const localDayOf = (t) => { const ms = Date.parse(t); if (!Number.isFinite(ms)) return ''; const d = new Date(ms); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; };
|
|
35
37
|
const hhmm = (ms) => { const d = new Date(ms); return pad(d.getHours()) + ':' + pad(d.getMinutes()); };
|
|
36
38
|
export const baseId = (id) => String(id || '').split('@')[0];
|
|
37
39
|
/** 3:07, or 1:03:07 from an hour on; a minus sign before a negative time. Pure. */
|
|
@@ -84,7 +86,7 @@ export function closeFocus() {
|
|
|
84
86
|
const grid = host && host.querySelector('.c3grid'); if (grid) grid.inert = false;
|
|
85
87
|
const comp = host && host.querySelector('[data-c3ph]'); if (comp) comp.inert = false;
|
|
86
88
|
if (f.phone) { const st = f.phone && document.querySelector('[data-c3ph-focus-bar]'); if (st) st.focus({ preventScroll: true }); return; }
|
|
87
|
-
try { const u = new URL(location.href); if (u.searchParams.has('focus')) { u.searchParams.delete('focus'); u.searchParams.delete('fdate'); history.replaceState(history.state, '', u); } } catch { /* no history */ }
|
|
89
|
+
try { const u = new URL(location.href); if (u.searchParams.has('focus')) { u.searchParams.delete('focus'); u.searchParams.delete('fdate'); u.searchParams.delete('fat'); u.searchParams.delete('ftitle'); history.replaceState(history.state, '', u); } } catch { /* no history */ }
|
|
88
90
|
if (f.opener && document.contains(f.opener) && typeof f.opener.focus === 'function') { try { f.opener.focus({ preventScroll: true }); } catch { /* not focusable any more */ } }
|
|
89
91
|
}
|
|
90
92
|
|
|
@@ -95,7 +97,7 @@ export function openFocus({ event, date, pop, back, phone, note, meet, mtitle }
|
|
|
95
97
|
if (phone) pop = false; // the phone has the whole page, in one column
|
|
96
98
|
const resolve = !!pop && want === 'now'; // the extension names no meeting: which one this call is comes first
|
|
97
99
|
const id = resolve ? '' : want;
|
|
98
|
-
const day = String(date ||
|
|
100
|
+
const day = String(date || localDayOf(ev.at || ev.start) || '');
|
|
99
101
|
ensureCss();
|
|
100
102
|
const host = document.getElementById('c3-root') || document.body;
|
|
101
103
|
const root = el('div', { class: 'c3focus' + (pop ? ' fc-pop' : '') + (phone ? ' fc-phone' : ''), role: 'region', 'aria-label': pop ? 'PMOS meeting helper' : 'Meeting focus', 'data-c3focus': '', ...(pop ? { 'data-fc-pop': '' } : {}), ...(phone ? { 'data-fc-phone': '' } : {}) });
|
|
@@ -142,7 +144,7 @@ export function openFocus({ event, date, pop, back, phone, note, meet, mtitle }
|
|
|
142
144
|
host.append(root);
|
|
143
145
|
const grid = host.querySelector && host.querySelector('.c3grid'); if (grid) grid.inert = true;
|
|
144
146
|
const comp = host.querySelector && host.querySelector('[data-c3ph]'); if (comp) comp.inert = true; // the phone companion under the page
|
|
145
|
-
if (!pop && !phone) { try { const u = new URL(location.href); u.searchParams.set('focus', id); if (day) u.searchParams.set('fdate', day); history.replaceState(history.state, '', u); } catch { /* no history */ } } // the compact page keeps its address: a reload asks again which meeting it is
|
|
147
|
+
if (!pop && !phone) { try { const u = new URL(location.href); u.searchParams.set('focus', id); if (day) u.searchParams.set('fdate', day); if (!id && ev.at) { u.searchParams.set('fat', ev.at); if (ev.title) u.searchParams.set('ftitle', ev.title); } history.replaceState(history.state, '', u); } catch { /* no history */ } } // the compact page keeps its address: a reload asks again which meeting it is
|
|
146
148
|
draw();
|
|
147
149
|
F.timers.clock = setInterval(tick, 1000);
|
|
148
150
|
if (resolve) resolveNow(); else load();
|
|
@@ -185,7 +187,7 @@ async function load() {
|
|
|
185
187
|
if (f.id) p.set('event', f.id);
|
|
186
188
|
else if (f.noteId) p.set('note', f.noteId); // a recording with no meeting
|
|
187
189
|
if (f.date && !f.noteId) p.set('date', f.date);
|
|
188
|
-
if (!f.
|
|
190
|
+
if (!f.noteId && (e.at || e.start)) { p.set('at', e.at || e.start); if (e.title) p.set('title', e.title); } // the start and title still find the meeting when the id or the day does not
|
|
189
191
|
const j = await pmGet('/api/meeting-focus?' + p.toString());
|
|
190
192
|
if (F !== f) return;
|
|
191
193
|
if (j && j.ok) {
|
|
@@ -623,7 +623,7 @@ async function boot() {
|
|
|
623
623
|
let start = q.get('tab');
|
|
624
624
|
const saved = DEMO ? null : await pmGet('/api/settings' + (S.ws ? '?ws=' + encodeURIComponent(S.ws) : '')).catch(() => null); // demo mode's workspace is not registered
|
|
625
625
|
if (saved && saved.ok) { applyLook(root, saved.settings); if (!start) { const s = (saved.settings || []).find((x) => x.key === 'look.start'); if (s && BUILD[s.value]) start = s.value; } }
|
|
626
|
-
if (q.get('focus') || q.get('fdate')) import('./c3-focus.js').then((m) => m.openFocus({ event: { id: q.get('focus') || '' }, date: q.get('fdate') || '', pop: q.get('pop') === '1', meet: q.get('meet') || '', mtitle: q.get('mtitle') || '' })).catch(() => { /* the focus page is not available */ }); // a reload keeps the Meeting focus page open; ?pop=1 is its compact form for the Chrome extension (focus=now asks which meeting the Meet call is)
|
|
626
|
+
if (q.get('focus') || q.get('fdate')) import('./c3-focus.js').then((m) => m.openFocus({ event: { id: q.get('focus') || '', ...(q.get('fat') ? { at: q.get('fat'), title: q.get('ftitle') || '' } : {}) }, date: q.get('fdate') || '', pop: q.get('pop') === '1', meet: q.get('meet') || '', mtitle: q.get('mtitle') || '' })).catch(() => { /* the focus page is not available */ }); // a reload keeps the Meeting focus page open; ?pop=1 is its compact form for the Chrome extension (focus=now asks which meeting the Meet call is)
|
|
627
627
|
if (!S.userNav) showTab(start || 'brief', false); // a press during the read above (the gear, a tab) wins over the start page
|
|
628
628
|
new ResizeObserver(moveInd).observe(S.tabNav);
|
|
629
629
|
workCount(); briefCount(); askCount(); S.dock.start();
|
|
@@ -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
|
}
|
|
@@ -173,9 +173,8 @@ function meetingOfNote(id) {
|
|
|
173
173
|
return m && m.kind === 'event' && m.start ? m : null;
|
|
174
174
|
}
|
|
175
175
|
/** Meeting focus (initiative c3-meeting-focus): the page for the meeting being recorded. The event id is empty for a meeting without a stored id; the page then finds it by start and title. */
|
|
176
|
-
function openFocusFor(m) {
|
|
177
|
-
|
|
178
|
-
import('./c3-focus.js').then((f) => f.openFocus({ event: { id: m.event_id || '', at: m.start, end: m.end || null, title: m.title }, date: day }))
|
|
176
|
+
function openFocusFor(m) { // no date: the page takes the local day of the start (the note keeps it in UTC)
|
|
177
|
+
import('./c3-focus.js').then((f) => f.openFocus({ event: { id: m.event_id || '', at: m.start, end: m.end || null, title: m.title } }))
|
|
179
178
|
.catch(() => toast({ text: 'The focus page could not load.', tone: 'error' }));
|
|
180
179
|
}
|
|
181
180
|
|
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).
|