create-pmos 0.126.0 → 0.127.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/guide/layout.md +2 -2
- package/workspace/lib/git-info.js +19 -1
- package/workspace/lib/session-origin.js +35 -10
- package/workspace/lib/transcript-read.js +17 -4
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/server.js +30 -3
- package/workspace/test-files.json +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.127.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-11T02:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-11T02:52:51Z"}
|
|
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-11T02:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-11T02:52:51Z"}
|
|
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-11T02:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-11T02:52:51Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Guide: layout"
|
|
4
4
|
description: "The frame: one top bar, the place on screen, the detail and the agent dock; the phone tabs and lists; the keys; and the older layout."
|
|
5
|
-
timestamp: 2026-10-
|
|
5
|
+
timestamp: 2026-10-11
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Guide: layout
|
|
@@ -51,7 +51,7 @@ The dock holds your terminals, documents, conversations, skills and web pages. I
|
|
|
51
51
|
|
|
52
52
|
- Press ⌘J (Ctrl-J), or the button on the rail, to open the dock. Press ⌘J again, or the fold button in the dock, to fold it to the rail.
|
|
53
53
|
- The dock never opens by itself. A tab that opens while the dock is a rail waits in the dock until you open it.
|
|
54
|
-
- The dock lists the sessions of the repository by initiative. A session is in the group of the initiative it is recorded on, or of its `initiative/<id>` branch. Then come **Not linked** (no initiative) and **Automatic** (anything you did not start yourself, for example a schedule, the day run, a note's summary or bullets, or a Jira push). **Automatic** is hidden until you press **Show automatic** at the foot of the list (or beside the group select in the strip). A session in front, or one held on a permission question, is always shown. An agent that has finished and reads "needs you" is hidden like the rest. Finished automatic sessions remove themselves a minute after a clean exit, and one with no output for 60 minutes is ended and removed. A failed one stays. When a session has been removed, its terminal tab closes too, also when the workspace was stopped for an update, so the tab does not come back as a row under **Open**. The setting is in `state/session-policy.json`: `finish` (true or false)
|
|
54
|
+
- The dock lists the sessions of the repository by initiative. A session is in the group of the initiative it is recorded on, or of its `initiative/<id>` branch. Then come **Not linked** (no initiative) and **Automatic** (anything you did not start yourself, for example a schedule, the day run, a note's summary or bullets, or a Jira push). **Automatic** is hidden until you press **Show automatic** at the foot of the list (or beside the group select in the strip). A session in front, or one held on a permission question, is always shown. An agent that has finished and reads "needs you" is hidden like the rest. Finished automatic sessions remove themselves a minute after a clean exit, and one with no output for 60 minutes is ended and removed. A failed one stays for a day. When a schedule starts a new run, it ends its previous run first, unless that run is still working or waits for your answer. Any session that has ended, also one you started, is removed after 7 days. Its conversation is kept when it ends. A session you started is never ended for you. When a session has been removed, its terminal tab closes too, also when the workspace was stopped for an update, so the tab does not come back as a row under **Open**. The setting is in `state/session-policy.json`: `finish` (true or false), `idleMinutes` (5 or more, or 0 for off) and `keepExitedDays` (1 to 365, or 0 to keep ended sessions). Then **Open** lists the tabs that are not sessions, for example documents and skills. Each one shows its title, what it is (for example PRD, Document or Web page) and its branch when it is not main. **✕** on an Open row closes the tab only. A terminal keeps running.
|
|
55
55
|
- Each session row starts with a mark for its state: ● working, ◼ quiet, ◆ needs you, ◇ maybe blocked, ○ ended. Under the name, the row shows the state when the session is not working, the type of work when one is set, and the branch when it is not main and not the initiative's own branch. Running sessions come first and ended sessions last. Press the row to bring the session to the front.
|
|
56
56
|
- Press the name of a group to fold it or open it. The number beside it counts its sessions. **Automatic** is folded while none of its sessions is running, because each run leaves an ended session. The group of the session in front always stays open, and the workspace keeps your choice for each group.
|
|
57
57
|
- **⋯** and **✕** show on the row in front, on the row under the pointer and on the row with the keyboard focus. On a touch screen they always show.
|
|
@@ -251,6 +251,24 @@ function removeWorktree(root, dir) {
|
|
|
251
251
|
return { ok: true, dir: target.dir, branch: target.branch, pruned: false };
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
// { ok, current, detached } — only the HEAD half of branchInfo, with the same failure codes. The
|
|
255
|
+
// workspace list and the session heartbeat need the branch name and nothing else; branchInfo also
|
|
256
|
+
// lists every branch, runs `git status --porcelain` and lists the worktrees, which on a large repo
|
|
257
|
+
// is most of the cost and was paid for every workspace every 15 seconds. `top` may be passed when
|
|
258
|
+
// the caller already resolved the repo root, saving one more shell-out.
|
|
259
|
+
function headInfo(root, top) {
|
|
260
|
+
const t = top === undefined ? repoRoot(root) : top;
|
|
261
|
+
if (!t) {
|
|
262
|
+
if (!isRepo(root)) return { ok: false, code: 'no-git', error: 'not a git repository' };
|
|
263
|
+
return { ok: false, code: 'git-error', error: 'git not available' };
|
|
264
|
+
}
|
|
265
|
+
const head = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
266
|
+
if (head.failed) return { ok: false, code: 'git-error', error: 'git not available' };
|
|
267
|
+
if (head.code !== 0) return { ok: false, code: 'git-error', error: head.err || 'could not read HEAD' };
|
|
268
|
+
const detached = head.out === 'HEAD';
|
|
269
|
+
return { ok: true, current: detached ? null : head.out, detached };
|
|
270
|
+
}
|
|
271
|
+
|
|
254
272
|
// { ok, current, branches[], detached, dirty, repoRoot, subdir, linked, worktrees[] }. A non-repo
|
|
255
273
|
// or a failed git returns ok:false with a reason — NEVER a fabricated branch (a wrong branch chip
|
|
256
274
|
// is worse than none; anti-drift). `subdir` is the workspace's path INSIDE the repo ('' at the
|
|
@@ -284,5 +302,5 @@ function branchInfo(root) {
|
|
|
284
302
|
};
|
|
285
303
|
}
|
|
286
304
|
|
|
287
|
-
module.exports = { isRepo, repoRoot, validBranchName, branchInfo, checkout, worktrees, worktreePath,
|
|
305
|
+
module.exports = { isRepo, repoRoot, validBranchName, branchInfo, headInfo, checkout, worktrees, worktreePath,
|
|
288
306
|
addWorktree, worktreeSafety, removeWorktree, originUrl, isGithubRemote };
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Next step, Hand to agent, a skill draft and an alignment are NOT automatic: the PM pressed those, and often talks on.
|
|
9
9
|
* Any other origin is automatic.
|
|
10
|
+
*
|
|
11
|
+
* A live session the PM started is never ended here. Once ANY session has ended, it expires after the policy's
|
|
12
|
+
* keepExitedDays (default 7): its conversation was recorded when it exited, and a list that only grows costs memory,
|
|
13
|
+
* disk, a bigger push every 2 seconds, and blocks forgetting a workspace (workspace-session-lifecycle).
|
|
10
14
|
*/
|
|
11
15
|
'use strict';
|
|
12
16
|
|
|
@@ -24,8 +28,12 @@ function isAutomatic(origin) {
|
|
|
24
28
|
return !(MANUAL_EXACT.includes(o) || MANUAL_PREFIX.some((p) => o.startsWith(p)));
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
const DEFAULT_POLICY = Object.freeze({ finish: true, idleMinutes: 60 });
|
|
31
|
+
const DEFAULT_POLICY = Object.freeze({ finish: true, idleMinutes: 60, keepExitedDays: 7 });
|
|
28
32
|
const FINISH_GRACE_MS = 60 * 1000; // a finished job stays this long, so a PM watching it sees it end
|
|
33
|
+
// A job that FAILED stays long enough for the PM to see it on the next look at the workspace, then goes. Kept for ever,
|
|
34
|
+
// a schedule firing at a moved repo added one dead row per fire, each with its scrollback in memory and on disk.
|
|
35
|
+
const FAILED_KEEP_MS = 24 * 3600 * 1000;
|
|
36
|
+
const KEEP_DAYS_MAX = 365;
|
|
29
37
|
const IDLE_MIN = 5;
|
|
30
38
|
const IDLE_MAX = 7 * 24 * 60;
|
|
31
39
|
|
|
@@ -38,7 +46,14 @@ function normalizePolicy(raw) {
|
|
|
38
46
|
else if (typeof r.idleMinutes === 'number' && Number.isFinite(r.idleMinutes)) {
|
|
39
47
|
idle = Math.min(IDLE_MAX, Math.max(IDLE_MIN, Math.round(r.idleMinutes)));
|
|
40
48
|
}
|
|
41
|
-
|
|
49
|
+
// keepExitedDays: how long ANY ended session (the PM's too) stays in the list. Its conversation is recorded when it
|
|
50
|
+
// exits, so removing the row loses no work. 0 or null = keep for ever.
|
|
51
|
+
let keep = DEFAULT_POLICY.keepExitedDays;
|
|
52
|
+
if (r.keepExitedDays === null || r.keepExitedDays === 0) keep = null;
|
|
53
|
+
else if (typeof r.keepExitedDays === 'number' && Number.isFinite(r.keepExitedDays)) {
|
|
54
|
+
keep = Math.min(KEEP_DAYS_MAX, Math.max(1, Math.round(r.keepExitedDays)));
|
|
55
|
+
}
|
|
56
|
+
return { finish, idleMinutes: idle, keepExitedDays: keep };
|
|
42
57
|
}
|
|
43
58
|
|
|
44
59
|
/** Did the session end cleanly? The note the server writes on exit reads "exited (code 0)". Anything else is kept. */
|
|
@@ -49,23 +64,33 @@ function endedCleanly(s) {
|
|
|
49
64
|
/**
|
|
50
65
|
* What the sweep should do with one session row: 'remove' (it has ended and may go), 'end' (it is idle: stop it, and
|
|
51
66
|
* it is removed on a later sweep), or null (leave it).
|
|
52
|
-
* Row fields read: origin, state, shown, note, lastOutputAt, autoDelete ('never' opts one
|
|
67
|
+
* Row fields read: origin, state, shown, note, lastOutputAt, exitedAt, autoDelete ('never' opts one out), autoEnded.
|
|
68
|
+
* `shown` is only read for a live automatic session past its idle limit; the caller may pass a function for it so the
|
|
69
|
+
* transcript lookup behind it runs only then (see needsShown).
|
|
53
70
|
*/
|
|
54
71
|
function decide(s, policy, now) {
|
|
55
|
-
if (!s ||
|
|
72
|
+
if (!s || s.autoDelete === 'never') return null;
|
|
73
|
+
const exitedAt = Date.parse(s.exitedAt || '') || 0;
|
|
74
|
+
// Every ended session, whoever started it, expires after keepExitedDays. A live session the PM started is never ended.
|
|
75
|
+
if (s.state === 'exited' && policy.keepExitedDays && exitedAt && now - exitedAt >= policy.keepExitedDays * 86400000) return 'remove';
|
|
76
|
+
if (!isAutomatic(s.origin)) return null;
|
|
56
77
|
const last = Number(s.lastOutputAt) || 0;
|
|
57
|
-
if (!last) return null; // no clock for it (restored from disk): never guess an age
|
|
58
|
-
const age = now - last;
|
|
59
78
|
if (s.state === 'exited') {
|
|
79
|
+
// The exit time is the better clock for an ended job; the output clock is the fallback for a row from before it.
|
|
80
|
+
const age = now - (exitedAt || last);
|
|
81
|
+
if (!exitedAt && !last) return null; // no clock for it: never guess an age
|
|
60
82
|
if (!policy.finish && !s.autoEnded) return null;
|
|
61
|
-
if (!s.autoEnded && !endedCleanly(s)) return null; // a failed job
|
|
83
|
+
if (!s.autoEnded && !endedCleanly(s)) return age >= FAILED_KEEP_MS ? 'remove' : null; // a failed job: the PM's eye first
|
|
62
84
|
return age >= FINISH_GRACE_MS ? 'remove' : null;
|
|
63
85
|
}
|
|
86
|
+
if (!last) return null; // no clock for it (restored from disk): never guess an age
|
|
64
87
|
if (!policy.idleMinutes) return null;
|
|
88
|
+
if (now - last < policy.idleMinutes * 60 * 1000) return null;
|
|
65
89
|
// A call waiting for permission is a real ask. 'needs' (the agent's turn is over) is NOT: a finished agent always reads
|
|
66
90
|
// 'needs you', so exempting it would keep every finished job for ever. The idle clock still gives a late question time.
|
|
67
|
-
|
|
68
|
-
|
|
91
|
+
const shown = typeof s.shown === 'function' ? s.shown() : s.shown;
|
|
92
|
+
if (shown === 'blocked') return null;
|
|
93
|
+
return 'end';
|
|
69
94
|
}
|
|
70
95
|
|
|
71
|
-
module.exports = { isAutomatic, normalizePolicy, decide, endedCleanly, DEFAULT_POLICY, FINISH_GRACE_MS, MANUAL_EXACT, MANUAL_PREFIX };
|
|
96
|
+
module.exports = { isAutomatic, normalizePolicy, decide, endedCleanly, DEFAULT_POLICY, FINISH_GRACE_MS, FAILED_KEEP_MS, MANUAL_EXACT, MANUAL_PREFIX };
|
|
@@ -66,13 +66,21 @@ function slugFor(cwd) {
|
|
|
66
66
|
* Default descendant lister: ONE `ps` snapshot → a ppid→children map → the direct children of
|
|
67
67
|
* `pid`. Injected in tests so the walk is exercised without real processes. Returns [] on any
|
|
68
68
|
* failure — a resolver miss must degrade to the refusal, never throw.
|
|
69
|
+
*
|
|
70
|
+
* The snapshot is shared for PS_SNAPSHOT_MS. The walk asks for the children of every pid it visits,
|
|
71
|
+
* and the server resolves every shell pane on each 2s heartbeat, so an uncached lister ran one
|
|
72
|
+
* synchronous `ps` per visited pid per pane per push: 10 `ps` runs per push with six shell panes,
|
|
73
|
+
* each one blocking the thread that carries keystrokes. One snapshot answers all of them.
|
|
69
74
|
*/
|
|
70
|
-
|
|
75
|
+
const PS_SNAPSHOT_MS = 1000;
|
|
76
|
+
let psSnap = null; // { at, kids: Map<ppid, pid[]> }
|
|
77
|
+
function psChildrenMap(now = Date.now()) {
|
|
78
|
+
if (psSnap && now - psSnap.at < PS_SNAPSHOT_MS) return psSnap.kids;
|
|
71
79
|
let out;
|
|
72
80
|
try {
|
|
73
81
|
out = cp.execFileSync('ps', ['-Ao', 'pid=,ppid='], { encoding: 'utf8', timeout: 2000 });
|
|
74
82
|
} catch {
|
|
75
|
-
return
|
|
83
|
+
return null; // not cached: tmux or ps can be transiently busy, and the next call tries again
|
|
76
84
|
}
|
|
77
85
|
const kids = new Map();
|
|
78
86
|
for (const line of out.split('\n')) {
|
|
@@ -83,7 +91,12 @@ function defaultChildren(pid) {
|
|
|
83
91
|
if (!kids.has(pp)) kids.set(pp, []);
|
|
84
92
|
kids.get(pp).push(p);
|
|
85
93
|
}
|
|
86
|
-
|
|
94
|
+
psSnap = { at: now, kids };
|
|
95
|
+
return kids;
|
|
96
|
+
}
|
|
97
|
+
function defaultChildren(pid) {
|
|
98
|
+
const kids = psChildrenMap();
|
|
99
|
+
return (kids && kids.get(Number(pid))) || [];
|
|
87
100
|
}
|
|
88
101
|
|
|
89
102
|
/** Breadth-first descendants of `pid`, bounded to `maxDepth` levels. `listChildren(pid)->pids`. */
|
|
@@ -303,4 +316,4 @@ function readHarness(file, clean = (x) => x) {
|
|
|
303
316
|
}
|
|
304
317
|
|
|
305
318
|
module.exports = { findTranscript, readHarness, prettyTool, slugFor, resolvePid, descendants,
|
|
306
|
-
MAX_BYTES, MAX_WALK_DEPTH };
|
|
319
|
+
MAX_BYTES, MAX_WALK_DEPTH, PS_SNAPSHOT_MS, defaultChildren };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.127.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.127.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
package/workspace/server.js
CHANGED
|
@@ -639,7 +639,9 @@ function repoIdentity(root) {
|
|
|
639
639
|
// a fault: a folder registered by path legitimately has no origin, and null is the honest answer.
|
|
640
640
|
try { remote = gitInfo.originUrl(root); } catch { remote = null; }
|
|
641
641
|
try {
|
|
642
|
-
|
|
642
|
+
// headInfo, not branchInfo: only the branch name is used here, and branchInfo's branch list,
|
|
643
|
+
// `git status` and worktree list cost ~5 more shell-outs per workspace per TTL (session lifecycle).
|
|
644
|
+
const bi = gitInfo.headInfo(root, tree || null);
|
|
643
645
|
if (bi.ok) branch = bi.detached ? null : bi.current;
|
|
644
646
|
// R1/A2 — carry the CODE and the MESSAGE, not a boolean. branchInfo already separates the two
|
|
645
647
|
// failures and says so in its own comment: `no-git` is an ordinary folder and "deserves no
|
|
@@ -2387,6 +2389,23 @@ function reportToMarkdown(surface) {
|
|
|
2387
2389
|
}
|
|
2388
2390
|
// Fire one schedule: run its skill → contract → deliverable → stamp → file to needs-you. Same path
|
|
2389
2391
|
// for a scheduled tick and for run-now (R7). Pure of PTYs in v1 (deterministic report skill).
|
|
2392
|
+
// A schedule that fires again while its previous run is still open ends that run first, unless it is still producing
|
|
2393
|
+
// output or holds a call waiting for the PM. Before, every fire added a session beside the last one, and each stayed
|
|
2394
|
+
// up to the idle limit (workspace-session-lifecycle). Only automatic origins are ever touched.
|
|
2395
|
+
function endSupersededRuns(origin, now = Date.now()) {
|
|
2396
|
+
const ended = [];
|
|
2397
|
+
if (!sessionOrigin.isAutomatic(origin)) return ended;
|
|
2398
|
+
for (const s of sessions.values()) {
|
|
2399
|
+
if (s.origin !== origin || !s.pty || s.autoDelete === 'never') continue;
|
|
2400
|
+
if (harness.deriveState(s.state, s.lastOutputAt, now).state === 'working') continue;
|
|
2401
|
+
if (sessionShown(s, now) === 'blocked') continue;
|
|
2402
|
+
s.autoEnded = true; s.note = 'ended: the schedule started a new run';
|
|
2403
|
+
if (s.tmux) tmuxKillSession(s.tmux);
|
|
2404
|
+
try { s.pty.kill(); } catch {}
|
|
2405
|
+
ended.push(s.id);
|
|
2406
|
+
}
|
|
2407
|
+
return ended;
|
|
2408
|
+
}
|
|
2390
2409
|
function fireSchedule(sched) {
|
|
2391
2410
|
const w = workspaces.get(sched.ws) || homeWorkspace();
|
|
2392
2411
|
const root = w ? w.root : SELF_ROOT;
|
|
@@ -2412,6 +2431,7 @@ function fireSchedule(sched) {
|
|
|
2412
2431
|
// The schedule's recorded profile travels with the process it creates (profiles R8.3). Only an
|
|
2413
2432
|
// AGENT-kind task has a PTY, which is why `profile_applies` is false for the skill-kind rows
|
|
2414
2433
|
// below rather than the control simply being hidden.
|
|
2434
|
+
endSupersededRuns('schedule:' + sched.id);
|
|
2415
2435
|
const s = spawnSession({ name: sched.name, agent: task.agent || 'claude', ws: sched.ws,
|
|
2416
2436
|
origin: 'schedule:' + sched.id, initialInput: prompt ? prompt + '\r' : null,
|
|
2417
2437
|
profile: sched.profile == null ? null : sched.profile,
|
|
@@ -2502,6 +2522,8 @@ function recordConversation(s) {
|
|
|
2502
2522
|
}
|
|
2503
2523
|
function persist(s) {
|
|
2504
2524
|
if (s.state === 'exited' && !s._txSaved) { s._txSaved = true; try { recordConversation(s); } catch {} }
|
|
2525
|
+
// When it ended: the clock the sweep's keepExitedDays reads. A live session has none (a respawn clears it).
|
|
2526
|
+
if (s.state === 'exited') { if (!s.exitedAt) s.exitedAt = nowISO(); } else if (s.exitedAt) delete s.exitedAt;
|
|
2505
2527
|
else if (s.state !== 'exited') s._txSaved = false;
|
|
2506
2528
|
// note IS persisted — a restored session must still say why it exited. `root` is persisted
|
|
2507
2529
|
// so a session survives even if the workspace registry is lost (it re-registers its root).
|
|
@@ -2509,6 +2531,7 @@ function persist(s) {
|
|
|
2509
2531
|
state: s.state, note: s.note || '', created: s.created, tmux: s.tmux || null, origin: s.origin || null };
|
|
2510
2532
|
if (s.autoDelete) meta.autoDelete = s.autoDelete;
|
|
2511
2533
|
if (s.autoEnded) meta.autoEnded = true;
|
|
2534
|
+
if (s.exitedAt) meta.exitedAt = s.exitedAt;
|
|
2512
2535
|
// The breakdown item a Hand to agent session is bound to, so a restart keeps the binding (pm-os-absorb Run 1).
|
|
2513
2536
|
if (s.plan_item) meta.plan_item = s.plan_item;
|
|
2514
2537
|
// pm-os-absorb Run 2 (R25): the SHA-256 of the agent's PMOS_SESSION token, never the token (D08). A restored session
|
|
@@ -2601,6 +2624,8 @@ function restorePersisted() {
|
|
|
2601
2624
|
} else {
|
|
2602
2625
|
s.state = 'exited';
|
|
2603
2626
|
s.note = meta.note || 'restored after server restart — scrollback preserved; ↻ respawn to start a new process';
|
|
2627
|
+
// A record from before exitedAt existed: its last write is when it last changed, the closest truthful clock.
|
|
2628
|
+
if (!s.exitedAt) { try { s.exitedAt = new Date(fs.statSync(path.join(SESS_DIR, f)).mtimeMs).toISOString(); } catch {} }
|
|
2604
2629
|
}
|
|
2605
2630
|
const n = Number(String(meta.id).split('-')[1]); if (Number.isFinite(n) && n >= seq) seq = n + 1;
|
|
2606
2631
|
} catch {}
|
|
@@ -2897,7 +2922,9 @@ function sweepAutomaticSessions(now = Date.now()) {
|
|
|
2897
2922
|
const policy = readSessionPolicy();
|
|
2898
2923
|
const done = { ended: [], removed: [] };
|
|
2899
2924
|
for (const s of [...sessions.values()]) {
|
|
2900
|
-
|
|
2925
|
+
// `shown` is a function: decide() asks for it only for a live automatic session past its idle limit, so the sweep no
|
|
2926
|
+
// longer resolves a transcript (a `ps` walk for a shell pane) for every session every 30 seconds.
|
|
2927
|
+
const act = sessionOrigin.decide({ ...s, shown: () => sessionShown(s, now) }, policy, now);
|
|
2901
2928
|
if (act === 'end' && s.pty) {
|
|
2902
2929
|
s.autoEnded = true; s.note = 'ended: idle too long (automatic session)';
|
|
2903
2930
|
if (s.tmux) tmuxKillSession(s.tmux);
|
|
@@ -8986,7 +9013,7 @@ if (require.main === module) {
|
|
|
8986
9013
|
});
|
|
8987
9014
|
});
|
|
8988
9015
|
}
|
|
8989
|
-
module.exports = { server, online: { ONLINE, applyOnline, tunnel, publicHost }, spawnSession, removeSession, renameSession, sessions, libraryTree, readTextDoc,
|
|
9016
|
+
module.exports = { server, online: { ONLINE, applyOnline, tunnel, publicHost }, spawnSession, removeSession, sweepAutomaticSessions, endSupersededRuns, renameSession, sessions, libraryTree, readTextDoc,
|
|
8990
9017
|
registerWorkspace, forgetWorkspace, workspaces, library, initiativeIndex, fsList,
|
|
8991
9018
|
// R1 — exported so the suite can assert the CARRY-THROUGH: branchInfo separates an ordinary
|
|
8992
9019
|
// non-repo folder from a real git fault, and the whole legibility defect was that distinction
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"learn.test.js",
|
|
54
54
|
"session-flag.test.js",
|
|
55
55
|
"session-origin.test.js",
|
|
56
|
+
"session-lifecycle.test.js",
|
|
56
57
|
"tab-gone.test.js",
|
|
57
58
|
"live-notes.test.js",
|
|
58
59
|
"plan-items.test.js",
|
|
@@ -172,6 +173,7 @@
|
|
|
172
173
|
"learn.test.js": 3,
|
|
173
174
|
"session-flag.test.js": 1,
|
|
174
175
|
"session-origin.test.js": 1,
|
|
176
|
+
"session-lifecycle.test.js": 2,
|
|
175
177
|
"tab-gone.test.js": 1,
|
|
176
178
|
"live-notes.test.js": 3,
|
|
177
179
|
"pm-shell-test.js": 6,
|