create-pmos 0.82.0 → 0.83.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/session-origin.js +65 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +2 -0
- package/workspace/public/pm/dock-sessions.js +56 -12
- package/workspace/public/pm/frame-copy.js +6 -1
- package/workspace/public/pm/state.js +2 -0
- package/workspace/server.js +54 -1
- 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.83.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-07T16:01:13Z"}
|
|
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-07T16:01:13Z"}
|
|
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-07T16:01:13Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -50,9 +50,9 @@ The dock holds your terminals, documents, conversations, skills and web pages. I
|
|
|
50
50
|
|
|
51
51
|
- 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.
|
|
52
52
|
- The dock never opens by itself. A tab that opens while the dock is a rail waits in the dock until you open it.
|
|
53
|
-
- 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 **
|
|
53
|
+
- 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** (started by the workspace itself, for example by a schedule or the day run). **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 that waits on you, is always shown. 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. The setting is in `state/session-policy.json`: `finish` (true or false) and `idleMinutes` (5 or more, or 0 for off). 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.
|
|
54
54
|
- 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.
|
|
55
|
-
- Press the name of a group to fold it or open it. The number beside it counts its sessions. **
|
|
55
|
+
- 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.
|
|
56
56
|
- **⋯** 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.
|
|
57
57
|
- Press **⋯** on a row to change the name, the initiative or the type of work.
|
|
58
58
|
- Press **✕** on a row to end the session. The row asks "End “name”?". Press **End** or **Cancel**. When you end the last running session on its own working copy, the message offers **Remove working copy**. This removes the folder only. The branch stays. If the folder has unsaved work, the workspace keeps it and tells you why.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/* lib/session-origin.js — which sessions the PM did not start, and when they may end themselves.
|
|
2
|
+
*
|
|
3
|
+
* Every session records an `origin` at spawn (null for one the PM opened). Some origins are the workspace acting on its
|
|
4
|
+
* own: a schedule firing, the day run's tasks and checks, a background read, a doc sync. Those sessions pile up in the list
|
|
5
|
+
* and, when the job is done, have nothing left to say. This module is the one place that names them and decides what may
|
|
6
|
+
* be done to them. Pure: the server passes in the rows and the clock, so the rules are testable without a server.
|
|
7
|
+
*
|
|
8
|
+
* Next step, Hand to agent, a skill draft and an alignment are NOT automatic: the PM pressed those, and often talks on.
|
|
9
|
+
*/
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const AUTOMATIC_EXACT = Object.freeze(['day-task', 'day-check', 'my-work-read', 'docs:sync', 'docs:check']);
|
|
13
|
+
const AUTOMATIC_PREFIX = Object.freeze(['schedule:']);
|
|
14
|
+
|
|
15
|
+
/** True when the workspace, not the PM, started a session with this origin. */
|
|
16
|
+
function isAutomatic(origin) {
|
|
17
|
+
const o = String(origin || '');
|
|
18
|
+
if (!o) return false;
|
|
19
|
+
return AUTOMATIC_EXACT.includes(o) || AUTOMATIC_PREFIX.some((p) => o.startsWith(p));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_POLICY = Object.freeze({ finish: true, idleMinutes: 60 });
|
|
23
|
+
const FINISH_GRACE_MS = 60 * 1000; // a finished job stays this long, so a PM watching it sees it end
|
|
24
|
+
const IDLE_MIN = 5;
|
|
25
|
+
const IDLE_MAX = 7 * 24 * 60;
|
|
26
|
+
|
|
27
|
+
/** A stored or posted policy, made safe. Anything unusable falls back to the default for that key. idleMinutes 0 or null = off. */
|
|
28
|
+
function normalizePolicy(raw) {
|
|
29
|
+
const r = raw && typeof raw === 'object' ? raw : {};
|
|
30
|
+
const finish = typeof r.finish === 'boolean' ? r.finish : DEFAULT_POLICY.finish;
|
|
31
|
+
let idle = DEFAULT_POLICY.idleMinutes;
|
|
32
|
+
if (r.idleMinutes === null || r.idleMinutes === 0) idle = null;
|
|
33
|
+
else if (typeof r.idleMinutes === 'number' && Number.isFinite(r.idleMinutes)) {
|
|
34
|
+
idle = Math.min(IDLE_MAX, Math.max(IDLE_MIN, Math.round(r.idleMinutes)));
|
|
35
|
+
}
|
|
36
|
+
return { finish, idleMinutes: idle };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Did the session end cleanly? The note the server writes on exit reads "exited (code 0)". Anything else is kept. */
|
|
40
|
+
function endedCleanly(s) {
|
|
41
|
+
return /^exited \(code 0\)$/.test(String(s.note || '').trim());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What the sweep should do with one session row: 'remove' (it has ended and may go), 'end' (it is idle: stop it, and
|
|
46
|
+
* it is removed on a later sweep), or null (leave it).
|
|
47
|
+
* Row fields read: origin, state, shown, note, lastOutputAt, autoDelete ('never' opts one job out), autoEnded.
|
|
48
|
+
*/
|
|
49
|
+
function decide(s, policy, now) {
|
|
50
|
+
if (!s || !isAutomatic(s.origin) || s.autoDelete === 'never') return null;
|
|
51
|
+
const last = Number(s.lastOutputAt) || 0;
|
|
52
|
+
if (!last) return null; // no clock for it (restored from disk): never guess an age
|
|
53
|
+
const age = now - last;
|
|
54
|
+
if (s.state === 'exited') {
|
|
55
|
+
if (!policy.finish && !s.autoEnded) return null;
|
|
56
|
+
if (!s.autoEnded && !endedCleanly(s)) return null; // a failed job needs the PM's eye
|
|
57
|
+
return age >= FINISH_GRACE_MS ? 'remove' : null;
|
|
58
|
+
}
|
|
59
|
+
if (!policy.idleMinutes) return null;
|
|
60
|
+
// waiting on a person is not idle: the session is asking something
|
|
61
|
+
if (s.shown === 'needs' || s.shown === 'blocked') return null;
|
|
62
|
+
return age >= policy.idleMinutes * 60 * 1000 ? 'end' : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { isAutomatic, normalizePolicy, decide, endedCleanly, DEFAULT_POLICY, FINISH_GRACE_MS, AUTOMATIC_EXACT, AUTOMATIC_PREFIX };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.83.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.83.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* working on which initiative, and ending a session, naming it or starting one on a branch took a trip to a separate sheet.
|
|
5
5
|
* This module is the dock's list of the repository's SESSIONS:
|
|
6
6
|
*
|
|
7
|
-
* railNodes(repo, otherRows) the side list: one group per initiative, then "Not linked", then "
|
|
7
|
+
* railNodes(repo, otherRows) the side list: one group per initiative, then "Not linked", then "Automatic" (hidden unless
|
|
8
|
+
* the PM shows it), then the
|
|
8
9
|
* open tabs that are not sessions (documents, skills, views) under "Open"
|
|
9
10
|
* stripNodes(repo, otherNodes) the strip on top: a group select, the group's sessions as chips, + and ✕
|
|
10
11
|
* listedSessionIds(repo) the sessions the list shows, so the caller does not draw their tabs twice
|
|
@@ -12,7 +13,9 @@
|
|
|
12
13
|
* the one new-session sheet: name, initiative, where it runs, and how it starts
|
|
13
14
|
*
|
|
14
15
|
* The initiative of a session is the one it recorded (spawn or the PM's word), else the one its branch names
|
|
15
|
-
* (initiative/<id>). A session started
|
|
16
|
+
* (initiative/<id>). A session the workspace started itself (a schedule, the day run, a doc sync: the server marks it
|
|
17
|
+
* `automatic`) is in "Automatic" whatever it is for, and that group is hidden until the PM turns on Show automatic; the
|
|
18
|
+
* session in front is always shown. The server also ends and removes finished or idle automatic sessions (lib/session-origin.js). Ending a session asks in the row;
|
|
16
19
|
* when it was the last live session on its own working copy, the toast offers to remove that copy (the branch is kept; the
|
|
17
20
|
* server refuses unsaved work and says why). Live mode only.
|
|
18
21
|
*/
|
|
@@ -63,28 +66,51 @@ function sessionsOfRepo(repo, trees) {
|
|
|
63
66
|
return (Array.isArray(state.sessions) ? state.sessions : []).filter((s) => s && in_.has(s.ws));
|
|
64
67
|
}
|
|
65
68
|
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
const isAuto = (s) => !!(s && (s.automatic || String(s.origin || '').startsWith('schedule:')));
|
|
70
|
+
|
|
71
|
+
/** Every automatic session of the repository, shown or not. */
|
|
72
|
+
export function automaticSessions(repo) {
|
|
73
|
+
return sessionsOfRepo(repo, treesOf(repo)).filter(isAuto);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** [{ key, title, initiative, automatic, sessions }]: initiatives in board order, then Not linked, then Automatic when shown. */
|
|
77
|
+
export function groupsOf(repo, { all = false } = {}) {
|
|
68
78
|
const trees = treesOf(repo);
|
|
69
79
|
const order = ((boardOf() || {})[repo] || []).flatMap((g) => g.slugs || []);
|
|
70
80
|
const rank = (slug) => { const i = order.indexOf(slug); return i < 0 ? order.length : i; };
|
|
71
81
|
const by = new Map(); const none = []; const sched = [];
|
|
72
82
|
for (const s of sessionsOfRepo(repo, trees)) {
|
|
73
|
-
if (
|
|
83
|
+
if (isAuto(s)) { sched.push(s); continue; }
|
|
74
84
|
const i = initiativeOf(s, trees);
|
|
75
85
|
if (!i) { none.push(s); continue; }
|
|
76
86
|
if (!by.has(i)) by.set(i, []);
|
|
77
87
|
by.get(i).push(s);
|
|
78
88
|
}
|
|
79
89
|
const out = [...by.keys()].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))
|
|
80
|
-
.map((i) => ({ key: 'i:' + i, title: titleOf(i), initiative: i,
|
|
81
|
-
if (none.length) out.push({ key: 'none', title: C().notLinked, initiative: null,
|
|
82
|
-
|
|
90
|
+
.map((i) => ({ key: 'i:' + i, title: titleOf(i), initiative: i, automatic: false, sessions: by.get(i) }));
|
|
91
|
+
if (none.length) out.push({ key: 'none', title: C().notLinked, initiative: null, automatic: false, sessions: none });
|
|
92
|
+
// hidden by default; the session in front is never hidden, and neither is one that is waiting on the PM
|
|
93
|
+
const front = frontSessionId();
|
|
94
|
+
const show = all || state.dsShowAuto || sched.some((s) => s.id === front || s.shown === 'needs' || s.shown === 'blocked');
|
|
95
|
+
if (sched.length && show) out.push({ key: 'auto', title: C().automatic, initiative: null, automatic: true, sessions: sched });
|
|
83
96
|
return out;
|
|
84
97
|
}
|
|
85
98
|
|
|
99
|
+
/** Ids the list owns, shown or hidden, so the caller does not draw a hidden session's tab under "Open". */
|
|
86
100
|
export function listedSessionIds(repo) {
|
|
87
|
-
return new Set(groupsOf(repo).flatMap((g) => g.sessions.map((s) => s.id)));
|
|
101
|
+
return new Set(groupsOf(repo, { all: true }).flatMap((g) => g.sessions.map((s) => s.id)));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function setShowAuto(on) { setState({ dsShowAuto: !!on }); }
|
|
105
|
+
|
|
106
|
+
/** The Show automatic switch, or '' when the repository has no automatic sessions. `compact` drops the count into the title. */
|
|
107
|
+
function autoToggle(repo, cls) {
|
|
108
|
+
const n = automaticSessions(repo).length;
|
|
109
|
+
if (!n) return '';
|
|
110
|
+
const on = !!state.dsShowAuto;
|
|
111
|
+
return button({ label: on ? C().hideAuto : C().showAuto(n), variant: 'ghost', size: 'sm', icon: 'bot',
|
|
112
|
+
onClick: () => setShowAuto(!on),
|
|
113
|
+
attrs: { class: cls, 'data-ds': 'auto-toggle', 'aria-pressed': on ? 'true' : 'false', title: C().autoHint } });
|
|
88
114
|
}
|
|
89
115
|
|
|
90
116
|
const frontTab = () => (state.tabs || []).find((t) => t.id === state.active) || null;
|
|
@@ -337,7 +363,22 @@ function sessionRow(repo, s, trees, front) {
|
|
|
337
363
|
export function groupFolded(g) {
|
|
338
364
|
const m = state.dsFolded || {};
|
|
339
365
|
if (Object.prototype.hasOwnProperty.call(m, g.key)) return !!m[g.key];
|
|
340
|
-
return !!g.
|
|
366
|
+
return !!g.automatic && g.sessions.every(isEnded);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Remove every ended session of the Automatic group in one press (the server also does this on its own for clean exits). */
|
|
370
|
+
function clearEndedButton(g) {
|
|
371
|
+
const ended = g.sessions.filter(isEnded);
|
|
372
|
+
if (!ended.length) return '';
|
|
373
|
+
return iconButton({ icon: 'x', ariaLabel: C().clearEnded(ended.length), size: 'sm', attrs: { 'data-ds': 'auto-clear', class: 'fr-ds-head__new' },
|
|
374
|
+
onClick: async () => {
|
|
375
|
+
if (!dataMod) return;
|
|
376
|
+
const tabIds = ended.flatMap((s) => (tabsMod && typeof tabsMod.tabIdsOfSession === 'function' ? tabsMod.tabIdsOfSession(s.id) : []));
|
|
377
|
+
let n = 0;
|
|
378
|
+
for (const s of ended) { const o = await dataMod.removeSessionById(s.id); if (o && o.ok !== false && o.removed !== false) n += 1; }
|
|
379
|
+
if (tabsMod && typeof tabsMod.closeTabsById === 'function') tabsMod.closeTabsById(tabIds);
|
|
380
|
+
toast({ text: C().clearedEnded(n) });
|
|
381
|
+
} });
|
|
341
382
|
}
|
|
342
383
|
|
|
343
384
|
function groupHead(repo, g, folded) {
|
|
@@ -352,7 +393,7 @@ function groupHead(repo, g, folded) {
|
|
|
352
393
|
return el('div', { class: 'fr-ds-head', 'data-ds-group': g.key },
|
|
353
394
|
toggle,
|
|
354
395
|
el('span', { class: 'fr-ds-head__count', 'aria-hidden': 'true' }, count),
|
|
355
|
-
g.
|
|
396
|
+
g.automatic ? clearEndedButton(g) : iconButton({ icon: 'plus', ariaLabel: g.initiative ? C().newFor(g.title) : C().newTitle, size: 'sm',
|
|
356
397
|
onClick: () => openNewSessionSheet(repo, { initiative: g.initiative }), attrs: { 'data-ds': 'group-new', class: 'fr-ds-head__new' } }));
|
|
357
398
|
}
|
|
358
399
|
|
|
@@ -375,7 +416,9 @@ export function railNodes(repo, otherRows) {
|
|
|
375
416
|
el('div', { class: 'fr-ds-head fr-ds-head--plain' }, el('h3', { class: 'fr-ds-head__title' }, C().openTabs),
|
|
376
417
|
el('span', { class: 'fr-ds-head__count', 'aria-hidden': 'true' }, String(otherRows.length))), ...otherRows));
|
|
377
418
|
}
|
|
378
|
-
|
|
419
|
+
const sw = autoToggle(repo, 'fr-ds-autotoggle');
|
|
420
|
+
if (sw) out.push(el('div', { class: 'fr-ds-autobar' }, sw));
|
|
421
|
+
if (!groups.length && !sw && !(otherRows && otherRows.length)) out.push(el('p', { class: 'fr-ds-note' }, C().none));
|
|
379
422
|
return out;
|
|
380
423
|
}
|
|
381
424
|
|
|
@@ -407,6 +450,7 @@ export function stripNodes(repo, otherNodes) {
|
|
|
407
450
|
attrs: { class: 'fr-ds-chip', 'data-ds-session': s.id, 'aria-pressed': on ? 'true' : 'false', title: s.name + ' · ' + metaOf(s, trees) } }));
|
|
408
451
|
}
|
|
409
452
|
} else if (g) out.push(...otherNodes);
|
|
453
|
+
out.push(autoToggle(repo, 'fr-ds-tool'));
|
|
410
454
|
out.push(iconButton({ icon: 'plus', ariaLabel: g && g.initiative ? C().newFor(g.title) : C().newTitle,
|
|
411
455
|
onClick: () => openNewSessionSheet(repo, { initiative: g && g.initiative }), attrs: { 'data-ds': 'strip-new', 'data-fr': 'session', class: 'fr-ds-push' } }));
|
|
412
456
|
out.push(iconButton({ icon: 'x', ariaLabel: C().endFront, attrs: { 'data-ds': 'strip-end', class: 'fr-ds-tool' }, onClick: () => {
|
|
@@ -76,7 +76,12 @@ export const COPY = {
|
|
|
76
76
|
openTab: (title, what) => 'Open ' + title + ' · ' + what,
|
|
77
77
|
ds: {
|
|
78
78
|
notLinked: 'Not linked',
|
|
79
|
-
|
|
79
|
+
automatic: 'Automatic',
|
|
80
|
+
showAuto: (n) => 'Show automatic (' + n + ')',
|
|
81
|
+
hideAuto: 'Hide automatic',
|
|
82
|
+
autoHint: 'Sessions the workspace started itself (schedules, the day run, doc sync). Finished or idle ones remove themselves.',
|
|
83
|
+
clearEnded: (n) => 'Remove ' + n + ' ended automatic session' + (n === 1 ? '' : 's'),
|
|
84
|
+
clearedEnded: (n) => 'Removed ' + n + ' automatic session' + (n === 1 ? '' : 's') + '.',
|
|
80
85
|
openTabs: 'Open',
|
|
81
86
|
none: 'No sessions yet. Press Session to start one.',
|
|
82
87
|
group: 'Show the sessions of',
|
|
@@ -129,6 +129,7 @@ function initialBase() {
|
|
|
129
129
|
newSessIsolate: null, newSessError: null, newSessBusy: false,
|
|
130
130
|
newSessBranches: null, preflightProfile: null, profiles: null, branchInfo: null,
|
|
131
131
|
col0: true, col1: true, col2: true, scratchOpen: false, scratchText: '', copied: false,
|
|
132
|
+
dsShowAuto: false, // show the dock's Automatic group (sessions the workspace started itself) — persisted
|
|
132
133
|
dsFolded: {}, // the dock's session groups the PM folded (true) or opened (false), by group key — persisted
|
|
133
134
|
tabsVertical: false, // the cockpit's tabs as a vertical list instead of a strip — persisted, per browser
|
|
134
135
|
/* The scratchpad drawer's height in px (panel-tabs.js). 96 is the prototype's literal and stays
|
|
@@ -240,6 +241,7 @@ const PERSIST = {
|
|
|
240
241
|
// the cockpit's tabs as a vertical list (true) or the horizontal strip (false, the default)
|
|
241
242
|
tabsVertical: isBool,
|
|
242
243
|
// the dock's session groups, folded (true) or open (false) by the PM; a group with no entry takes its default
|
|
244
|
+
dsShowAuto: isBool,
|
|
243
245
|
dsFolded: isFlagMap,
|
|
244
246
|
/* A restored height is re-validated on the way IN, not merely on the way out: a corrupted or
|
|
245
247
|
* hand-edited record must not be able to render a drawer with no textarea in it, or one taller
|
package/workspace/server.js
CHANGED
|
@@ -69,6 +69,7 @@ ensureSpawnHelperExecutable();
|
|
|
69
69
|
const { checkSurface } = require('./lib/surface-contract');
|
|
70
70
|
const { createSinkGuard } = require('./lib/sink.js');
|
|
71
71
|
const tsSettings = require('./lib/typesafe-settings.js');
|
|
72
|
+
const sessionOrigin = require('./lib/session-origin'); // which sessions the workspace started itself, and when they may end (workspace-auto-sessions)
|
|
72
73
|
const sessionFlag = require('./lib/session-flag'); // PMOS_WORKSPACE=1 in each agent terminal (cockpit mod, M12)
|
|
73
74
|
const tsClient = require('./lib/typesafe.js');
|
|
74
75
|
const jevRoute = require('./lib/jev-route.js'); // prompt-model-routing — the bridge between prompt routing and the one TypeSafe module
|
|
@@ -2490,6 +2491,8 @@ function persist(s) {
|
|
|
2490
2491
|
// so a session survives even if the workspace registry is lost (it re-registers its root).
|
|
2491
2492
|
const meta = { id: s.id, ws: s.ws, root: s.root, name: s.name, agent: s.agent, cwd: s.cwd,
|
|
2492
2493
|
state: s.state, note: s.note || '', created: s.created, tmux: s.tmux || null, origin: s.origin || null };
|
|
2494
|
+
if (s.autoDelete) meta.autoDelete = s.autoDelete;
|
|
2495
|
+
if (s.autoEnded) meta.autoEnded = true;
|
|
2493
2496
|
// The breakdown item a Hand to agent session is bound to, so a restart keeps the binding (pm-os-absorb Run 1).
|
|
2494
2497
|
if (s.plan_item) meta.plan_item = s.plan_item;
|
|
2495
2498
|
// pm-os-absorb Run 2 (R25): the SHA-256 of the agent's PMOS_SESSION token, never the token (D08). A restored session
|
|
@@ -2570,6 +2573,7 @@ function restorePersisted() {
|
|
|
2570
2573
|
const s = {
|
|
2571
2574
|
...meta, ws: w ? w.id : wsId(SELF_ROOT), root: w ? w.root : SELF_ROOT,
|
|
2572
2575
|
pty: null, buf, tmux: meta.tmux || null, cols: 80, rows: 24, clients: new Set(),
|
|
2576
|
+
lastOutputAt: Date.now(), // a restored session's idle clock starts at boot, never from a missing value
|
|
2573
2577
|
};
|
|
2574
2578
|
sessions.set(meta.id, s);
|
|
2575
2579
|
if (TMUX && meta.tmux && tmuxHasSession(meta.tmux)) {
|
|
@@ -2765,6 +2769,8 @@ function sessionList() {
|
|
|
2765
2769
|
id: s.id, ws: s.ws, name: s.name, agent: s.agent, state: s.state, created: s.created,
|
|
2766
2770
|
model: 'model' in s ? s.model : null, model_line: modelLib.line('model' in s ? s.model : null),
|
|
2767
2771
|
cwd: path.relative(s.root, s.cwd) || '.', note: s.note || '', origin: s.origin || null,
|
|
2772
|
+
automatic: sessionOrigin.isAutomatic(s.origin), // the workspace started it, not the PM: the dock hides these by default
|
|
2773
|
+
auto_delete: s.autoDelete || null,
|
|
2768
2774
|
plan_item: s.plan_item || null, // Hand to agent: the breakdown item this session is bound to (pm-os-absorb Run 1)
|
|
2769
2775
|
// No `token` and no `tokenHash` here, ever: the page never sees a session token (F5; r25-guards.test.js checks it).
|
|
2770
2776
|
// Derived presentation state, JOINED from both sources: the cadence clock above and the
|
|
@@ -2861,6 +2867,41 @@ function heartbeat() {
|
|
|
2861
2867
|
}
|
|
2862
2868
|
try { const _hb = setInterval(heartbeat, LIVENESS_MS); if (_hb.unref) _hb.unref(); } catch {}
|
|
2863
2869
|
|
|
2870
|
+
// ── Clean-up of sessions the workspace started itself (workspace-auto-sessions). ─────────────────
|
|
2871
|
+
// Only AUTOMATIC origins (lib/session-origin.js) are ever touched; a session the PM started is never ended or removed here.
|
|
2872
|
+
// The policy is a small file the PM can change through /api/session/policy; unreadable or absent means the defaults.
|
|
2873
|
+
const SESSION_POLICY_FILE = () => path.join(STATE, 'session-policy.json');
|
|
2874
|
+
function readSessionPolicy() {
|
|
2875
|
+
let raw = null;
|
|
2876
|
+
try { raw = JSON.parse(fs.readFileSync(SESSION_POLICY_FILE(), 'utf8')); } catch { raw = null; }
|
|
2877
|
+
return sessionOrigin.normalizePolicy(raw);
|
|
2878
|
+
}
|
|
2879
|
+
function sweepAutomaticSessions(now = Date.now()) {
|
|
2880
|
+
const policy = readSessionPolicy();
|
|
2881
|
+
const done = { ended: [], removed: [] };
|
|
2882
|
+
for (const s of [...sessions.values()]) {
|
|
2883
|
+
const act = sessionOrigin.decide({ ...s, shown: sessionShown(s, now) }, policy, now);
|
|
2884
|
+
if (act === 'end' && s.pty) {
|
|
2885
|
+
s.autoEnded = true; s.note = 'ended: idle too long (automatic session)';
|
|
2886
|
+
if (s.tmux) tmuxKillSession(s.tmux);
|
|
2887
|
+
try { s.pty.kill(); } catch {}
|
|
2888
|
+
done.ended.push(s.id);
|
|
2889
|
+
} else if (act === 'remove' && !s.pty) {
|
|
2890
|
+
if (removeSession(s.id)) done.removed.push(s.id);
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
return done;
|
|
2894
|
+
}
|
|
2895
|
+
// The presented state ('needs' = waiting on the PM) for one session, the same join the list uses.
|
|
2896
|
+
function sessionShown(s, now) {
|
|
2897
|
+
try {
|
|
2898
|
+
const d = harness.deriveState(s.state, s.lastOutputAt, now);
|
|
2899
|
+
const hx = transcriptHarness(s);
|
|
2900
|
+
return sessionState.presented(d.state, hx && hx.turn);
|
|
2901
|
+
} catch { return null; }
|
|
2902
|
+
}
|
|
2903
|
+
try { const _sw = setInterval(() => { try { sweepAutomaticSessions(); } catch {} }, Number(process.env.PMOS_SESSION_SWEEP_MS || 30000)); if (_sw.unref) _sw.unref(); } catch {}
|
|
2904
|
+
|
|
2864
2905
|
// Trim the ring from the FRONT at a LINE boundary. A blind slice cuts wherever the byte count
|
|
2865
2906
|
// lands — which is routinely INSIDE an escape sequence, and the truncated tail then replays as
|
|
2866
2907
|
// literal rubble ("5;203m") the next time a client attaches and we write the buffer to xterm.js;
|
|
@@ -3249,7 +3290,7 @@ function sendToSession(id, text) {
|
|
|
3249
3290
|
try { firstInput.deliver(s, safeFirstInput(t) + '\r'); return true; } catch { return false; }
|
|
3250
3291
|
}
|
|
3251
3292
|
|
|
3252
|
-
function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative, modelAsk, planItem, workType }) {
|
|
3293
|
+
function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative, modelAsk, planItem, workType, autoDelete }) {
|
|
3253
3294
|
const id = nextSessionId();
|
|
3254
3295
|
/* pm-os-absorb Run 1 — every AGENT session gets a token, `<id>.<8 hex>`, in its environment as PMOS_SESSION, and
|
|
3255
3296
|
* PMOS_BIN, this workspace's own bin/pmos.js. `pmos item` run inside it is then attributed `agent:<token>` (the token
|
|
@@ -3297,6 +3338,8 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
3297
3338
|
// Hand to agent: the breakdown item this session was started for, or null. Set only by /api/plan-items/handoff/go.
|
|
3298
3339
|
base.plan_item = planItem || null;
|
|
3299
3340
|
base.token = sessionToken;
|
|
3341
|
+
// 'never' opts this one job out of the automatic clean-up (lib/session-origin.js); anything else is the workspace policy.
|
|
3342
|
+
if (autoDelete === 'never') base.autoDelete = 'never';
|
|
3300
3343
|
const wt = workTypeOrNull(workType);
|
|
3301
3344
|
if (wt) base.workType = wt;
|
|
3302
3345
|
|
|
@@ -5591,6 +5634,16 @@ async function handleRequest(req, res) {
|
|
|
5591
5634
|
const out = createBranchOnly({ ws: body.ws, name: body.name, from: body.from || null, worktree: body.worktree !== false });
|
|
5592
5635
|
return json(res, out, out.ok ? 200 : 400);
|
|
5593
5636
|
}
|
|
5637
|
+
if (p === '/api/session/policy' && (req.method === 'GET' || req.method === 'POST')) {
|
|
5638
|
+
if (req.method === 'POST') {
|
|
5639
|
+
let body = {};
|
|
5640
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5641
|
+
const next = sessionOrigin.normalizePolicy({ ...readSessionPolicy(), ...body });
|
|
5642
|
+
fs.mkdirSync(STATE, { recursive: true });
|
|
5643
|
+
fs.writeFileSync(SESSION_POLICY_FILE(), JSON.stringify(next, null, 2) + '\n');
|
|
5644
|
+
}
|
|
5645
|
+
return json(res, { ok: true, policy: readSessionPolicy(), automatic_origins: [...sessionOrigin.AUTOMATIC_EXACT, ...sessionOrigin.AUTOMATIC_PREFIX.map((x) => x + '*')] });
|
|
5646
|
+
}
|
|
5594
5647
|
// End a session and remove it, with an answer. The WebSocket kill/remove messages return nothing,
|
|
5595
5648
|
// so a refusal or a slow exit left the PM clicking a control that seemed to do nothing.
|
|
5596
5649
|
if (p === '/api/session/remove' && req.method === 'POST') {
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"proactive-prepare.test.js",
|
|
44
44
|
"learn.test.js",
|
|
45
45
|
"session-flag.test.js",
|
|
46
|
+
"session-origin.test.js",
|
|
46
47
|
"live-notes.test.js",
|
|
47
48
|
"plan-items.test.js",
|
|
48
49
|
"plan-tree.test.js",
|
|
@@ -144,6 +145,7 @@
|
|
|
144
145
|
"proactive-prepare.test.js": 9,
|
|
145
146
|
"learn.test.js": 3,
|
|
146
147
|
"session-flag.test.js": 1,
|
|
148
|
+
"session-origin.test.js": 1,
|
|
147
149
|
"live-notes.test.js": 3,
|
|
148
150
|
"pm-shell-test.js": 6,
|
|
149
151
|
"pm-repos-test.js": 3,
|