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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.191.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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@0c10704
1
+ kernel-source: pmos@1e4f936
2
2
  assembled: 2026-10-13
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-13T07:07:45Z"}
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-13T07:07:45Z"}
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-13T07:07:45Z"}
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 of 3 runs used today". So the same-day timeline is squeezed into the time since midnight when that is shorter.
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 of 3 runs used today", the status, the last action, the
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 of 3 runs used today"', /Agent\s*4/i.test(head) && /Pause/.test(head) && /1 of 3 runs used today/.test(head), head);
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 of 3 runs used today/.test(runs) && /Filed 4 proposals/.test(runs), runs.slice(0, 300));
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 bar = await st((w) => [...document.querySelectorAll(w + ' [data-pm-lenses] [data-lens]')].map((b) => [b.getAttribute('data-lens'), b.textContent.replace(/\d+$/, '').trim()]), W);
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
- - At most 3 runs per initiative per day, and 1 per Work task per day.
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 of 3 runs used today" opens the list of today's runs.
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 limit = t.target.kind === 'work' ? D.BUDGET.workPerDay : D.BUDGET.initiativePerDay;
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} of ${limit} runs today`);
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, budget, 3 at once) through spawnSession with origin `drive:<kind>:<id>`
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
  *
@@ -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 budget, the modes, the wake queue and the unblock sweep. It reads 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; counts against the day's budget
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): 3 runs per initiative per local day, 1 per Work task per day, 3 at once, no daily total. A run
38
- * that fails or times out still counts. A wake the budget turns away is recorded as `refused`; a wake that only waits for
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({ initiativePerDay: 3, workPerDay: 1, concurrent: 3 });
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 budget counts per local day. */
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
- const argv = ['-p', '--output-format', 'json', '--no-session-persistence', ...modelArgs, '--allowedTools', tools.join(',')];
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
- /** The last JSON object on stdout (`--output-format json` prints one). Anything unreadable is not a result. */
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 = null;
132
- try { o = JSON.parse(text); } catch {
133
- const at = text.lastIndexOf('\n{');
134
- try { o = JSON.parse(at >= 0 ? text.slice(at + 1) : ''); } catch { o = null; }
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: target.kind === 'work' ? D.BUDGET.workPerDay : D.BUDGET.initiativePerDay, concurrent: D.BUDGET.concurrent },
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) { // a meeting without a stored id: its start and title name it
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
- return onDay.find((e) => String(e.id) === id) || onDay[0] || null;
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
- const card = cardFor(lines, { run, extra: runAnswers.asEvents(run, answers), ended: !live, ctx: { now, lastAt: lastAtOf(lines) } });
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.191.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.191.0",
9
+ "version": "0.193.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.191.0",
3
+ "version": "0.193.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -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; const limit = d && d.budget ? d.budget.limit : 3;
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 + ' of ' + limit + ' runs used today'),
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'), 'The agent runs up to ' + limit + ' times a day on each initiative. It stops at the limit and starts again tomorrow. A run that fails still counts.'),
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 + ' of ' + d.budget.limit + ' runs used today';
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 || String(ev.at || ev.start || '').slice(0, 10) || '');
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.id && !f.noteId && (e.at || e.start)) { p.set('at', e.at || e.start); if (e.title) p.set('title', e.title); }
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
- const day = String(m.start || '').slice(0, 10);
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
 
@@ -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
- let out = '', err = '', timedOut = false;
3501
- const CAP = 5 * 1024 * 1024;
3502
- child.stdout.on('data', (d) => { if (out.length < CAP) out += d; s.lastOutputAt = Date.now(); });
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
- const r = headlessLib.parseResult(out);
3521
- if (r.ok) say(headlessLib.logLines(r, code).join('\n') + '\n');
3522
- else if (out.trim()) say(r.text + '\n');
3523
- if (err.trim()) say(`\x1b[38;5;244m${err.trim().split('\n').slice(-20).join('\n')}\x1b[0m\n`);
3524
- s.note = timedOut ? `stopped: still running after ${Math.max(1, Math.round(headlessLib.timeoutMs() / 60000))} minutes`
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).