create-pmos 0.82.0 → 0.84.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.82.0",
3
+ "version": "0.84.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@a09f396
1
+ kernel-source: pmos@fea3b24
2
2
  assembled: 2026-10-07
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-07T15:34:34Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T16:12:03Z"}
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-07T15:34:34Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T16:12:03Z"}
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-07T15:34:34Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T16:12:03Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -27,16 +27,16 @@ Each place is drawn when its run is built. Until then the place says which run b
27
27
 
28
28
  ## Settings
29
29
 
30
- Settings holds settings only, in eight sections:
30
+ Settings holds settings only, in eight sections. On a wide screen they are cards in two columns, like Agenda: Repositories, New sessions, Profiles and Theme on the left, and Validate consent, People, Online and Updates on the right. On a narrow screen the cards stack in one column. Each switch (Validate consent, People, Online) turns on when you press it. **Check for updates** is at the top right of the page.
31
31
 
32
32
  - **Repositories**: every registered repository. Press a repository to make it the current one. Press its ⋯ for **Rename** or **Remove from PMOS**. Each asks in a sheet, and a remove asks you to confirm. **Add repository** shows the scan of a folder before you add it.
33
33
  - **Profiles**: the Claude profiles a session can start with. They open in the detail.
34
34
  - **New sessions**: the default CLI and the default model for a new session in the dock.
35
- - **Validate consent**: the consent of the current repository. Turn it on or off here. **Open Validate** opens the Validate page as a tab.
35
+ - **Validate consent**: the consent of the current repository. Use its switch to turn it on or off. **Open Validate** opens the Validate page as a tab.
36
36
  - **People**: whether the morning brief keeps attendee names from the calendar for the current repository. It is off until you turn it on. Turning it off also hides the names recorded before.
37
- - **Online**: puts this workspace at your own address, such as `pmos.pm-os.app`, through a Cloudflare tunnel. Enter the public address, the tunnel name, your Cloudflare team domain, the Access audience (AUD) tag and your email, then press **Turn on**. The workspace runs the tunnel and remembers the switch when it restarts. On your phone, open the address and sign in with Cloudflare Access (Google or an email code). The workspace does not ask for its token there. Only the computer that runs the workspace can change this section. A phone sees whether it is on. When a sign-in at the address is refused, this section names the check that failed, such as the email or the AUD tag. [REMOTE.md](../REMOTE.md) has the one-time Cloudflare steps.
37
+ - **Online**: puts this workspace at your own address, such as `pmos.pm-os.app`, through a Cloudflare tunnel. Enter the public address, the tunnel name, your Cloudflare team domain, the Access audience (AUD) tag and your email, then turn on the **Online** switch. The workspace runs the tunnel and remembers the switch when it restarts. On your phone, open the address and sign in with Cloudflare Access (Google or an email code). The workspace does not ask for its token there. Only the computer that runs the workspace can change this section. A phone sees whether it is on. When a sign-in at the address is refused, this section names the check that failed, such as the email or the AUD tag. [REMOTE.md](../REMOTE.md) has the one-time Cloudflare steps.
38
38
  - **Theme**: Dark or Light.
39
- - **Updates**: the version of the workspace app and the version of the kit in the current repository. Press **Check for updates** to compare them with the latest published version of PMOS. This is the only time the workspace asks the internet for a version, and it asks only when you press. When a newer version is available, one button shows: **Update to** and the version. It updates every part that is older, in this order:
39
+ - **Updates**: the version of the workspace app and the version of the kit in the current repository. Press **Check for updates** at the top of the page to compare them with the latest published version of PMOS. This is the only time the workspace asks the internet for a version, and it asks only when you press. When a newer version is available, one button shows: **Update to** and the version. It updates every part that is older, in this order:
40
40
  - **The kit** in the current repository, when it is older. This does what `npx create-pmos@latest --update` does in a terminal. It changes only the kit's own files under `pmos/` (skills, scripts, templates and guides). Your records in `state/`, `planning/` and `okf/product/` do not change. The last lines of the output show under the button. The changed files are under `pmos/`, which Sync treats as records, so Sync (or automatic sync) sends them with your other records. To read them first, look at `git diff pmos/` before you sync.
41
41
  - **The workspace app**, when it is older. This does what `pmos upgrade` does in a terminal. The button asks you first. The workspace stops, installs the new version and starts again, and the page reloads when the new version starts. The reloaded page runs the new version's code: each start gives the page files a new address, so neither the browser nor the Online address's tunnel can keep the old ones. You do not need a terminal or a hard refresh. Sessions keep running in tmux. It works on the computer that runs the workspace and at its Online address, because the page comes back there after the restart. It also works on a phone or public address that signs in with a token set in `PMOS_TOKEN`, which the workspace keeps. When the workspace made its own token at start, the token changes on the restart, so the page refuses and says where to press it. When the workspace runs from a PMOS checkout, update it with `git pull` instead.
42
42
 
@@ -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 **Scheduled** (started by a schedule). 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.
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. **Scheduled** is folded while none of its runs 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.
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.82.0",
3
+ "version": "0.84.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.82.0",
9
+ "version": "0.84.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.82.0",
3
+ "version": "0.84.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",
@@ -2359,18 +2359,22 @@
2359
2359
  .fr-body[data-fr-dock="rail"] > .fr-dock {
2360
2360
  display: none !important;
2361
2361
  }
2362
- /* Settings: one column of seven sections. */
2362
+ /* Settings: a full-width two-column grid of cards, laid out like Agenda. The grid sits in a container so it folds to one
2363
+ * column by the width of the page, not the window. */
2363
2364
  .fr-set {
2364
2365
  display: flex;
2365
2366
  flex-direction: column;
2366
- gap: 24px;
2367
- max-width: 720px;
2368
- padding: 24px 16px 48px;
2367
+ gap: 12px;
2368
+ width: 100%;
2369
+ padding: 16px 16px 48px;
2370
+ container-type: inline-size;
2369
2371
  }
2370
2372
  .fr-set__intro {
2371
2373
  display: flex;
2372
- flex-direction: column;
2373
- gap: 4px;
2374
+ align-items: center;
2375
+ justify-content: space-between;
2376
+ gap: 12px;
2377
+ min-height: 32px;
2374
2378
  }
2375
2379
  .fr-set__h1 {
2376
2380
  margin: 0;
@@ -2382,10 +2386,39 @@
2382
2386
  color: var(--muted-foreground);
2383
2387
  font-size: 12px;
2384
2388
  }
2389
+ .fr-set__grid {
2390
+ display: grid;
2391
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
2392
+ grid-template-rows: repeat(5, auto);
2393
+ gap: 12px;
2394
+ align-items: stretch;
2395
+ }
2396
+ /* Left: repositories, new sessions, profiles, theme. Right: Validate, People, Online, updates. The DOM order stays
2397
+ * repositories, profiles, sessions, validate, people, online, theme, updates; Online spans two rows so the columns stay level. */
2398
+ .fr-set__section[data-fr-section="repositories"] { grid-column: 1; grid-row: 1 / span 1; }
2399
+ .fr-set__section[data-fr-section="sessions"] { grid-column: 1; grid-row: 2 / span 1; }
2400
+ .fr-set__section[data-fr-section="profiles"] { grid-column: 1; grid-row: 3 / span 1; }
2401
+ .fr-set__section[data-fr-section="theme"] { grid-column: 1; grid-row: 4 / span 1; }
2402
+ .fr-set__section[data-fr-section="validate"] { grid-column: 2; grid-row: 1 / span 1; }
2403
+ .fr-set__section[data-fr-section="people"] { grid-column: 2; grid-row: 2 / span 1; }
2404
+ .fr-set__section[data-fr-section="online"] { grid-column: 2; grid-row: 3 / span 2; }
2405
+ .fr-set__section[data-fr-section="updates"] { grid-column: 2; grid-row: 5 / span 1; }
2406
+ @container (max-width: 700px) {
2407
+ .fr-set__grid {
2408
+ grid-template-columns: minmax(0, 1fr);
2409
+ grid-template-rows: none;
2410
+ }
2411
+ .fr-set__section[data-fr-section] { grid-column: 1; grid-row: auto; }
2412
+ }
2385
2413
  .fr-set__section {
2386
2414
  display: flex;
2387
2415
  flex-direction: column;
2388
2416
  gap: 8px;
2417
+ min-width: 0;
2418
+ padding: 10px 12px;
2419
+ border: 1px solid var(--border);
2420
+ border-radius: 8px;
2421
+ background: var(--card);
2389
2422
  }
2390
2423
  .fr-set__head {
2391
2424
  display: flex;
@@ -2395,14 +2428,27 @@
2395
2428
  min-height: 32px;
2396
2429
  }
2397
2430
  .fr-set__title {
2431
+ display: flex;
2432
+ align-items: center;
2433
+ gap: 6px;
2398
2434
  margin: 0;
2399
- font-size: 14px;
2400
- font-weight: 600;
2435
+ color: var(--muted-foreground);
2436
+ font-size: 11px;
2437
+ font-weight: 500;
2438
+ letter-spacing: 0.04em;
2439
+ text-transform: uppercase;
2401
2440
  }
2402
2441
  .fr-set__fields {
2403
2442
  display: flex;
2404
2443
  flex-wrap: wrap;
2405
- gap: 16px;
2444
+ gap: 10px;
2445
+ }
2446
+ .fr-set__fields--grid {
2447
+ display: grid;
2448
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
2449
+ }
2450
+ .fr-set__fields--grid > * {
2451
+ min-width: 0;
2406
2452
  }
2407
2453
  .fr-set__line {
2408
2454
  display: flex;
@@ -2410,6 +2456,32 @@
2410
2456
  justify-content: space-between;
2411
2457
  gap: 12px;
2412
2458
  }
2459
+ .fr-set__line--box {
2460
+ justify-content: flex-start;
2461
+ gap: 10px;
2462
+ padding: 8px 10px;
2463
+ border: 1px solid var(--border);
2464
+ border-radius: 8px;
2465
+ background: var(--background);
2466
+ font-size: 14px;
2467
+ }
2468
+ .fr-set__sw {
2469
+ display: flex;
2470
+ align-items: center;
2471
+ justify-content: space-between;
2472
+ gap: 12px;
2473
+ padding: 8px 10px;
2474
+ border: 1px solid var(--border);
2475
+ border-radius: 8px;
2476
+ background: var(--background);
2477
+ }
2478
+ .fr-set__sw-text {
2479
+ flex: 1;
2480
+ min-width: 0;
2481
+ display: grid;
2482
+ gap: 2px;
2483
+ font-size: 14px;
2484
+ }
2413
2485
  .fr-set__add {
2414
2486
  padding: 12px 0;
2415
2487
  }
@@ -2422,6 +2494,7 @@
2422
2494
  overflow: auto;
2423
2495
  padding: 8px;
2424
2496
  border: 1px solid var(--border);
2497
+ border-radius: 6px;
2425
2498
  background: var(--muted);
2426
2499
  color: var(--foreground);
2427
2500
  font-family: var(--mono);
@@ -2441,9 +2514,13 @@
2441
2514
  }
2442
2515
  }
2443
2516
  @media (pointer: coarse) {
2444
- .fr-set__head {
2517
+ .fr-set__head,
2518
+ .fr-set__intro {
2445
2519
  min-height: 44px;
2446
2520
  }
2521
+ .fr-set__sw {
2522
+ min-height: 48px;
2523
+ }
2447
2524
  /* The dock's own strip and bottom bar (panel-tabs.js) size their controls inline at 22-30px; under touch each is a
2448
2525
  * 44px target. The strip's fixed height yields to them. */
2449
2526
  .fr-dock [data-tabs="bar"] {
@@ -2547,6 +2624,8 @@
2547
2624
  opacity: 1;
2548
2625
  }
2549
2626
  }
2627
+ .fr-ds-autobar { padding: 6px 8px; }
2628
+ .fr-ds-autotoggle[aria-pressed="true"] { color: var(--foreground); }
2550
2629
  .fr-ds-confirm {
2551
2630
  display: flex;
2552
2631
  flex-wrap: wrap;
@@ -31,6 +31,7 @@ import { repoRow } from './fixture.js';
31
31
  import { sessionDefaults, setSessionDefaults } from './dock.js';
32
32
  import { COPY } from './frame-copy.js';
33
33
  import { askConfirm } from './dialogs.js';
34
+ import { icon } from './icons.js';
34
35
 
35
36
  const P = COPY.page;
36
37
  const roots = new WeakMap(); // host -> { root, sig }
@@ -104,10 +105,17 @@ async function setOnline(patch) {
104
105
  setState({});
105
106
  }
106
107
 
107
- const section = (id, title, kids, action) => el('section', { class: 'fr-set__section', 'data-fr-section': id },
108
- el('div', { class: 'fr-set__head' }, el('h2', { class: 'fr-set__title' }, title), action || null),
108
+ /* A card: a small uppercase eyebrow (icon + title) over the section's rows. The section ids and data-fr hooks are the contract. */
109
+ const section = (id, title, kids, action, iconName) => el('section', { class: 'fr-set__section', 'data-fr-section': id },
110
+ el('div', { class: 'fr-set__head' }, el('h2', { class: 'fr-set__title' }, iconName ? icon(iconName) : null, title), action || null),
109
111
  kids);
110
112
 
113
+ /** A toggle row: the state text beside a Turn on / Turn off button (the same button as before the redesign). */
114
+ function switchRow({ textEl, on, disabled, attrs, onClick }) {
115
+ const toggle = button({ label: on ? P.turnOff : P.turnOn, variant: 'outline', size: 'sm', disabled: !!disabled, attrs, onClick });
116
+ return el('div', { class: 'fr-set__sw' }, el('div', { class: 'fr-set__sw-text' }, textEl), toggle);
117
+ }
118
+
111
119
  /* ── the eight sections ────────────────────────────────────────────────────────────────────────────────────────── */
112
120
 
113
121
  function reposSection(scope) {
@@ -142,7 +150,7 @@ function reposSection(scope) {
142
150
  });
143
151
  const kids = rows.length ? rows : [empty({ text: P.reposEmpty })];
144
152
  if (addOpen) kids.push(addBoard());
145
- return section('repositories', P.repos, kids, add);
153
+ return section('repositories', P.repos, kids, add, 'git-branch');
146
154
  }
147
155
 
148
156
  function loadAdd() {
@@ -173,7 +181,7 @@ function profilesSection() {
173
181
  return section('profiles', P.profiles, [row({
174
182
  title: P.profilesRow, meta: [P.profilesMeta], onOpen: open, openLabel: P.profilesOpen, trail: [more],
175
183
  attrs: { 'data-fr-row': 'profiles' },
176
- })]);
184
+ })], null, 'bot');
177
185
  }
178
186
 
179
187
  function sessionsSection() {
@@ -190,21 +198,21 @@ function sessionsSection() {
190
198
  onCommit: (v) => { setSessionDefaults({ tier: v }); setState({}); },
191
199
  attrs: { 'data-fr': 'default-model' },
192
200
  });
193
- return section('sessions', P.sessions, [el('div', { class: 'fr-set__fields' }, cli, model), el('p', { class: 'fr-set__note' }, P.sessionsNote)]);
201
+ return section('sessions', P.sessions, [el('div', { class: 'fr-set__fields fr-set__fields--grid' }, cli, model), el('p', { class: 'fr-set__note' }, P.sessionsNote)], null, 'square-terminal');
194
202
  }
195
203
 
196
204
  function validateSection(scope) {
197
205
  const repo = scope === 'all' ? null : activeProduct();
198
206
  const ws = wsOfRepo(repo);
199
- if (!ws) return section('validate', P.validate, [empty({ text: P.validateNoRepo })]);
207
+ if (!ws) return section('validate', P.validate, [empty({ text: P.validateNoRepo })], null, 'check');
200
208
  readConsent(ws);
201
209
  const c = consentByWs.get(ws) || {};
202
210
  const known = c.on === true || c.on === false;
203
211
  const line = c.error ? P.validateUnread : !known ? '' : c.on ? P.validateOn : P.validateOff;
204
- const toggle = known ? button({
205
- label: c.on ? P.turnOff : P.turnOn, variant: 'outline', size: 'sm',
206
- attrs: { 'data-fr': 'consent-toggle' }, onClick: () => setConsent(ws, !c.on),
207
- }) : null;
212
+ const stateEl = el('span', { 'data-fr': 'consent-state', 'data-on': known ? String(c.on) : 'unread' }, line);
213
+ const toggleRow = known ? switchRow({
214
+ textEl: stateEl, label: line, on: c.on, attrs: { 'data-fr': 'consent-toggle' }, onClick: () => setConsent(ws, !c.on),
215
+ }) : el('div', { class: 'fr-set__sw' }, el('div', { class: 'fr-set__sw-text' }, stateEl));
208
216
  // Validate itself is a tool, not a place: it opens as a tab in the dock, from here only
209
217
  const open = button({
210
218
  label: P.validateOpen, variant: 'outline', size: 'sm', attrs: { 'data-fr': 'open-validate' },
@@ -214,32 +222,31 @@ function validateSection(scope) {
214
222
  const f = frame(); if (f) f.openDock();
215
223
  },
216
224
  });
217
- return section('validate', P.validate, [el('div', { class: 'fr-set__line' },
218
- el('span', { 'data-fr': 'consent-state', 'data-on': known ? String(c.on) : 'unread' }, line), toggle, open)]);
225
+ return section('validate', P.validate, [toggleRow], open, 'check');
219
226
  }
220
227
 
221
228
  function peopleSection(scope) {
222
229
  const repo = scope === 'all' ? null : activeProduct();
223
230
  const ws = wsOfRepo(repo);
224
- if (!ws) return section('people', P.people, [empty({ text: P.peopleNoRepo })]);
231
+ if (!ws) return section('people', P.people, [empty({ text: P.peopleNoRepo })], null, 'share-2');
225
232
  readPeople(ws);
226
233
  const c = peopleByWs.get(ws) || {};
227
234
  const known = c.on === true || c.on === false;
228
235
  const line = c.error ? c.error : !known ? '' : c.on ? P.peopleOn : P.peopleOff;
229
- const toggle = known ? button({
230
- label: c.on ? P.turnOff : P.turnOn, variant: 'outline', size: 'sm',
231
- attrs: { 'data-fr': 'people-calendar' }, onClick: () => setPeopleCalendar(ws, !c.on),
232
- }) : null;
233
- return section('people', P.people, [el('p', { class: 'fr-set__note' }, P.peopleNote),
234
- el('div', { class: 'fr-set__line' }, el('span', { 'data-fr': 'people-state', 'data-on': known ? String(c.on) : 'unread' }, line), toggle)]);
236
+ const stateEl = el('span', { 'data-fr': 'people-state', 'data-on': known ? String(c.on) : 'unread' }, line);
237
+ const toggleRow = known ? switchRow({
238
+ textEl: stateEl, label: line, on: c.on, attrs: { 'data-fr': 'people-calendar' }, onClick: () => setPeopleCalendar(ws, !c.on),
239
+ }) : el('div', { class: 'fr-set__sw' }, el('div', { class: 'fr-set__sw-text' }, stateEl));
240
+ return section('people', P.people, [toggleRow, el('p', { class: 'fr-set__note' }, P.peopleNote)], null, 'share-2');
235
241
  }
236
242
 
237
243
  function onlineSection() {
238
244
  readOnline(false);
239
245
  const d = online.data;
240
- if (!d) return section('online', P.online, [el('p', { class: 'fr-set__note' }, online.error || '')]);
241
- if (d.envHost) return section('online', P.online, [el('p', { class: 'fr-set__note', 'data-fr': 'online-state', 'data-on': 'env' }, P.onlineEnv(d.envHost))]);
242
- const lines = [el('p', { class: 'fr-set__note', 'data-fr': 'online-state', 'data-on': String(!!d.on) }, d.on ? P.onlineOn(d.host) : P.onlineOff)];
246
+ if (!d) return section('online', P.online, [el('p', { class: 'fr-set__note' }, online.error || '')], null, 'globe');
247
+ if (d.envHost) return section('online', P.online, [el('p', { class: 'fr-set__note', 'data-fr': 'online-state', 'data-on': 'env' }, P.onlineEnv(d.envHost))], null, 'globe');
248
+ const stateP = el('p', { class: 'fr-set__note', 'data-fr': 'online-state', 'data-on': String(!!d.on) }, d.on ? P.onlineOn(d.host) : P.onlineOff);
249
+ const lines = [];
243
250
  if (d.on) {
244
251
  const r = d.running || {};
245
252
  lines.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-tunnel' },
@@ -247,24 +254,24 @@ function onlineSection() {
247
254
  if (d.keys && !d.keys.keys) lines.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-keys' }, d.keys.lastError ? P.onlineKeysError(d.keys.lastError) : P.onlineKeysWait));
248
255
  if (d.lastRefusal) lines.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-refused', role: 'status' }, P.onlineRefused(d.lastRefusal.reason, d.lastRefusal.at)));
249
256
  }
250
- if (!d.canChange) return section('online', P.online, [...lines, el('p', { class: 'fr-set__note' }, P.onlineRemote)]);
257
+ if (!d.canChange) return section('online', P.online, [stateP, ...lines, el('p', { class: 'fr-set__note' }, P.onlineRemote)], null, 'globe');
251
258
  const f = (key, label, placeholder) => field({
252
259
  label, value: d[key] || '', placeholder,
253
260
  onCommit: (v) => setOnline({ [key]: v }),
254
261
  attrs: { 'data-fr': 'online-' + key, disabled: d.on ? true : undefined },
255
262
  });
256
- const fields = el('div', { class: 'fr-set__fields' },
263
+ const fields = el('div', { class: 'fr-set__fields fr-set__fields--grid' },
257
264
  f('host', P.onlineHost, 'pmos.example.com'), f('tunnel', P.onlineTunnel, 'pmos'), f('team', P.onlineTeam, 'myteam.cloudflareaccess.com'),
258
265
  f('audience', P.onlineAudience, ''), f('email', P.onlineEmail, 'you@example.com'));
259
266
  const blocked = !d.on && (!d.complete ? P.onlineNeedFields : !d.cloudflared ? P.onlineNoCloudflared : null);
260
- const toggle = button({
261
- label: d.on ? P.turnOff : P.turnOn, variant: 'outline', size: 'sm', disabled: !!blocked,
267
+ const toggleRow = switchRow({
268
+ textEl: stateP, label: d.on ? P.onlineOn(d.host) : P.onlineOff, on: !!d.on, disabled: !!blocked,
262
269
  attrs: { 'data-fr': 'online-toggle' }, onClick: () => setOnline({ on: !d.on }),
263
270
  });
264
- const kids = [el('p', { class: 'fr-set__note' }, P.onlineIntro), fields, el('div', { class: 'fr-set__line' }, ...lines, toggle)];
271
+ const kids = [toggleRow, ...lines, el('p', { class: 'fr-set__note' }, P.onlineIntro), fields];
265
272
  if (blocked) kids.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-blocked' }, blocked));
266
273
  if (online.note) kids.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-error', role: 'alert' }, online.note));
267
- return section('online', P.online, kids);
274
+ return section('online', P.online, kids, null, 'globe');
268
275
  }
269
276
 
270
277
  function themeSection() {
@@ -273,7 +280,7 @@ function themeSection() {
273
280
  items: [{ id: 'dark', label: P.dark }, { id: 'light', label: P.light }],
274
281
  onChange: (id) => setState({ theme: id }),
275
282
  attrs: { 'data-fr': 'theme' },
276
- })]);
283
+ })], null, 'sun');
277
284
  }
278
285
 
279
286
  function readKitStatus(ws, force) {
@@ -362,10 +369,10 @@ function updatesSection(scope) {
362
369
  const kids = [];
363
370
  const kitPresent = !!(kit && kit.present);
364
371
  const canUpdate = (cmp.appBehind && !app.fromCheckout) || (cmp.kitBehind && kitPresent);
365
- kids.push(el('div', { class: 'fr-set__line' }, el('span', { 'data-fr': 'app-version' }, P.updatesApp(app.version))));
372
+ kids.push(el('div', { class: 'fr-set__line fr-set__line--box' }, icon('panel-top'), el('span', { 'data-fr': 'app-version' }, P.updatesApp(app.version))));
366
373
  if (cmp.appBehind && app.fromCheckout) kids.push(el('p', { class: 'fr-set__note', 'data-fr': 'app-checkout' }, P.updatesCheckout));
367
374
  const kitText = scope === 'all' || !ws ? P.updatesNoRepo : !kit ? '' : kit.present ? P.updatesKit(kit.version) : P.updatesNoKit;
368
- kids.push(el('div', { class: 'fr-set__line' }, el('span', { 'data-fr': 'kit-version' }, kitText)));
375
+ kids.push(el('div', { class: 'fr-set__line fr-set__line--box' }, icon('cloud'), el('span', { 'data-fr': 'kit-version' }, kitText)));
369
376
  if (canUpdate) {
370
377
  kids.push(el('div', { class: 'fr-set__line' }, button({
371
378
  label: P.updateAllTo(upd.latest), variant: 'primary', size: 'sm', disabled: busy, attrs: { 'data-fr': 'update-all' },
@@ -378,11 +385,17 @@ function updatesSection(scope) {
378
385
  if (upd.error) kids.push(el('p', { class: 'fr-set__note fr-set__error', role: 'alert', 'data-fr': 'update-error' }, upd.error));
379
386
  if (upd.output) kids.push(el('pre', { class: 'fr-set__output', 'data-fr': 'update-output' }, upd.output));
380
387
  kids.push(el('p', { class: 'fr-set__note' }, P.updatesNote));
381
- const check = button({
382
- label: P.updatesCheck, variant: 'outline', size: 'sm', icon: 'refresh-cw', disabled: busy || !ws,
388
+ return section('updates', P.updates, kids, null, 'arrow-down');
389
+ }
390
+
391
+ /** Check for updates sits in the page header; it asks about the current repository's kit. */
392
+ function checkButton(scope) {
393
+ const repo = scope === 'all' ? null : activeProduct();
394
+ const ws = wsOfRepo(repo) || wsOfRepo(activeProduct());
395
+ return button({
396
+ label: P.updatesCheck, variant: 'outline', size: 'sm', icon: 'refresh-cw', disabled: !!upd.phase || !ws,
383
397
  attrs: { 'data-fr': 'check-updates' }, onClick: () => checkUpdates(ws),
384
398
  });
385
- return section('updates', P.updates, kids, check);
386
399
  }
387
400
 
388
401
  /* ── mount ────────────────────────────────────────────────────────────────────────────────────────────────────────── */
@@ -415,15 +428,18 @@ export function mountDestination(host, ctx) {
415
428
  if (sig === r.sig || busy(r.root)) return;
416
429
  r.sig = sig;
417
430
  setChildren(r.root, [
418
- el('header', { class: 'fr-set__intro' }, el('h1', { class: 'fr-set__h1' }, P.title), el('p', { class: 'fr-set__note' }, P.intro)),
419
- reposSection(scope),
420
- profilesSection(),
421
- sessionsSection(),
422
- validateSection(scope),
423
- peopleSection(scope),
424
- onlineSection(),
425
- themeSection(),
426
- updatesSection(scope),
431
+ el('header', { class: 'fr-set__intro' }, el('h1', { class: 'fr-set__h1' }, P.title), checkButton(scope)),
432
+ /* The DOM keeps the section order (the tests and the keyboard order read it); the grid in components.css places the
433
+ * left column (repositories, new sessions, profiles, theme) and the right (Validate, People, Online, updates). */
434
+ el('div', { class: 'fr-set__grid' },
435
+ reposSection(scope),
436
+ profilesSection(),
437
+ sessionsSection(),
438
+ validateSection(scope),
439
+ peopleSection(scope),
440
+ onlineSection(),
441
+ themeSection(),
442
+ updatesSection(scope)),
427
443
  ]);
428
444
  }
429
445
 
@@ -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 "Scheduled", then the
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 by a schedule is in "Scheduled" whatever it is for. Ending a session asks in the row;
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
- /** [{ key, title, initiative, scheduled, sessions }]: initiatives in board order, then Not linked, then Scheduled. */
67
- export function groupsOf(repo) {
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 (String(s.origin || '').startsWith('schedule:')) { sched.push(s); continue; }
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, scheduled: false, sessions: by.get(i) }));
81
- if (none.length) out.push({ key: 'none', title: C().notLinked, initiative: null, scheduled: false, sessions: none });
82
- if (sched.length) out.push({ key: 'sched', title: C().scheduled, initiative: null, scheduled: true, sessions: sched });
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.scheduled && g.sessions.every(isEnded);
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.scheduled ? '' : iconButton({ icon: 'plus', ariaLabel: g.initiative ? C().newFor(g.title) : C().newTitle, size: 'sm',
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
- if (!groups.length && !(otherRows && otherRows.length)) out.push(el('p', { class: 'fr-ds-note' }, C().none));
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
- scheduled: 'Scheduled',
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
@@ -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,