create-pmos 0.18.0 → 0.18.1

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/README.md CHANGED
@@ -333,6 +333,33 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
333
333
  `okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
334
334
  exactly what you're running.
335
335
 
336
+ ## What's new in 0.18.1
337
+
338
+ **Three fixes to the workspace terminal, all of them about what the window knew about a session.**
339
+ Run `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
340
+
341
+ - **Your terminal now uses its whole width.** Nothing ever told the shell how wide the browser
342
+ was. A session starts at 80 columns; the server stated its own size on attach rather than
343
+ adopting yours, and the browser only spoke up when a resize *changed* its grid — which happened
344
+ once, while the connection was still opening and there was no session to tell. So the process on
345
+ the other end kept wrapping at 80 columns inside a window three times that wide, and a
346
+ full-screen agent CLI painted into a box that was not there.
347
+ - **The window stops rebuilding itself while you type.** It repainted every panel every four
348
+ seconds whether or not anything had changed — on a project with a hundred-odd initiatives that is
349
+ a real stall, on a timer, next to a terminal you are typing into. It now repaints when something
350
+ it shows actually moves. Separately, the server rewrote every session's scrollback file to disk
351
+ every five seconds, synchronously, whether or not a byte had changed; it now writes only what
352
+ moved.
353
+ - **The scratchpad belongs to a session again.** It had become one shared buffer for the whole
354
+ page, so every terminal — in every repository — showed and overwrote the same text, and none of
355
+ it outlived the tab. Each session has its own pad again, saved on your machine, and it comes back
356
+ with the session.
357
+ - **Reopening the workspace brings your sessions back.** Open tabs are remembered and re-attach to
358
+ the exact session they were on, named on the tab so you can tell which one it is. Previously they
359
+ were forgotten on reload and had to be re-found by hand — and the re-finding matched on a session
360
+ *name*, which is not unique between repositories, so a tab in one project could silently attach
361
+ to a shell running in another. `+ session` also always starts a new session now, instead of
362
+ sometimes adopting one that was already running.
336
363
  ## What's new in 0.18.0
337
364
 
338
365
  **Several sessions in one folder now say so.** Run `npx create-pmos@latest workspace`; the version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
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. `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@83f52ff
1
+ kernel-source: pmos@9fee84a
2
2
  assembled: 2026-07-31
3
3
  mode: vendor
@@ -49,11 +49,18 @@ def review_gate_pass(verdict, score):
49
49
 
50
50
  def leading_frontmatter(path):
51
51
  """The LEADING frontmatter block only — body text cannot fake a declaration. Mirror of
52
- workspace/lib/file-mode-parse.js parseLeadingFrontmatter (agreement enforced by the fixture test)."""
52
+ workspace/lib/file-mode-parse.js parseLeadingFrontmatter (agreement enforced by the fixture test).
53
+
54
+ The value group is `(.*?)`, NOT `(.+?)`. A key written bare (`prd_path:` with nothing after it)
55
+ is legal YAML for a null value; under `.+?` the line does not match, so it reads as the first
56
+ body line, the loop breaks, and every key below it is dropped. Agreement with the JS parser was
57
+ never sufficient to catch this — both carried the same wrong regex, so the fixture compared two
58
+ identical mistakes and passed. See kit/fixtures/state/initiatives/epsilon-empty-value.md.
59
+ """
53
60
  fm = {}
54
61
  with open(path, encoding="utf-8") as fh:
55
62
  for line in fh:
56
- m = re.match(r"^([a-z_]+):\s*(.+?)\s*$", line.rstrip("\n"))
63
+ m = re.match(r"^([a-z_]+):\s*(.*?)\s*$", line.rstrip("\n"))
57
64
  if m:
58
65
  fm.setdefault(m.group(1), m.group(2))
59
66
  elif line.strip() and not line.startswith("---"):
@@ -14,10 +14,21 @@ const reviewGate = require('./review-gate');
14
14
  // The LEADING YAML-ish frontmatter block ONLY — body text must never be able to fake a declaration.
15
15
  // Mirrors metrics.py exactly: `key: value` lines (first value wins), `---` fences and blank lines are
16
16
  // skipped, and the FIRST non-frontmatter, non-fence, non-blank line ends the block.
17
+ //
18
+ // The value group is `(.*?)`, NOT `(.+?)`. A key written bare — `prd_path:` with nothing after it —
19
+ // is legal YAML for a null value, and 30 of the 136 records in planning/initiatives/ are written
20
+ // that way. Under `.+?` such a line does not match, so it is treated as the first body line, the
21
+ // loop BREAKS, and every key below it is silently dropped: parent_kr, outcome_metric, the lot. The
22
+ // spine then rendered those initiatives as "unanchored — rejected work under D59".
23
+ //
24
+ // This is not a hypothetical. metrics.py carried the identical regex and kit/fixtures/test_parsers.py
25
+ // existed to prove the two agreed — which they did, on being wrong, for as long as the fixture only
26
+ // ever compared them to each other. scripts/workflow-state.js:105 was the one correct parser and was
27
+ // compared against nothing. The fixture now asserts VALUES; see epsilon-empty-value.md.
17
28
  function parseLeadingFrontmatter(text) {
18
29
  const fm = {};
19
30
  for (const line of String(text == null ? '' : text).split('\n')) {
20
- const m = line.match(/^([a-z_]+):\s*(.+?)\s*$/);
31
+ const m = line.match(/^([a-z_]+):\s*(.*?)\s*$/);
21
32
  if (m) { if (!(m[1] in fm)) fm[m[1]] = m[2]; continue; }
22
33
  if (line.trim() && !line.startsWith('---')) break;
23
34
  }
@@ -0,0 +1,118 @@
1
+ /* workspace-repo-first · the start-work policy (PRD R1 · R3)
2
+ * ──────────────────────────────────────────────────────────────────────────────────────────────
3
+ * ONE declared rule for where a session starts, replacing a decision the server used to make per
4
+ * call from invisible state.
5
+ *
6
+ * What it replaced, and why that had to go. `spawnWithBranch` chose isolation with
7
+ *
8
+ * if (isolate === undefined || isolate === null) isolate = liveSessionCount(w.id) > 0;
9
+ *
10
+ * — so the SAME click created a worktree or moved the shared checkout depending on whether some
11
+ * other session happened to be alive at that moment. A PM cannot hold that rule in their head,
12
+ * cannot predict it, and cannot see it in the UI before pressing the button. It is not a policy;
13
+ * it is a coin flip with a plausible-looking reason.
14
+ *
15
+ * The rule this module states instead:
16
+ *
17
+ * no initiative chosen → the repository's MAIN working tree, on whatever branch it is on.
18
+ * No git operation at all. Nothing moves, no folder appears.
19
+ * an initiative chosen → that initiative's own worktree on `initiative/<id>`, created if absent
20
+ * and REUSED if present. The main tree is never moved off its branch.
21
+ *
22
+ * The PM's framing, which this encodes: keep the main checkout on main, and let work live in
23
+ * worktrees rather than accumulating branches in the shared tree.
24
+ *
25
+ * Two consequences worth stating because they are easy to get wrong:
26
+ *
27
+ * * If the MAIN tree already happens to be on `initiative/<id>`, the answer is the main tree —
28
+ * not a second folder for a branch that is already checked out. git allows one working tree
29
+ * per branch, so building a worktree here would fail, and failing is the better outcome only
30
+ * if the alternative were wrong. It isn't: the PM asked to work on that initiative and the
31
+ * repository is already there.
32
+ * * `create` is derived from whether the branch EXISTS, never assumed. `addWorktree(root, b,
33
+ * {create:true})` errors when the branch is already there, and `{create:false}` errors when it
34
+ * is not. Guessing produces a failed git operation, and a failed git operation is the one thing
35
+ * the rubric's veto forbids being followed by a spawned session.
36
+ *
37
+ * PURE ON PURPOSE. Everything it needs arrives as plain data, so the whole policy — including the
38
+ * paths that touch git — is exercisable in node with no repository, no socket and no PTY. The
39
+ * predecessor's hard floor ("a failed git operation must never start a session in the wrong
40
+ * directory") is inherited by this initiative, and a floor whose only proof is a manual click is
41
+ * not a floor.
42
+ */
43
+
44
+ const BRANCH_PREFIX = 'initiative/';
45
+
46
+ // The branch an initiative's work belongs on. One definition, so the client's preview and the
47
+ // server's git call cannot disagree about what the PM is about to get.
48
+ function branchForInitiative(id) {
49
+ const s = String(id == null ? '' : id).trim();
50
+ return s ? BRANCH_PREFIX + s : null;
51
+ }
52
+
53
+ // The repository's MAIN working tree among the registered ones. `repo_key` IS the main worktree's
54
+ // directory (repoIdentity resolves it from `git worktree list`, which names the main tree first),
55
+ // so the main tree is the one whose root equals the key. A repo where only LINKED worktrees are
56
+ // registered has no main tree on screen; the first registered tree stands in, which is the same
57
+ // fallback surfaceProducts already makes for the same reason.
58
+ function mainTreeOf(trees) {
59
+ const list = (trees || []).filter(Boolean);
60
+ if (!list.length) return null;
61
+ return list.find((t) => t.repo_key && t.root && String(t.root) === String(t.repo_key)) || list[0];
62
+ }
63
+
64
+ /* Resolve one "start work here" request to a plan.
65
+ *
66
+ * trees — the registered workspaces of ONE repository: { id, root, repo_key, branch }
67
+ * initiative — an initiative id, or null/'' for "just give me a terminal"
68
+ * branches — branch names known to the repo (branchInfo().branches), or null when unread
69
+ *
70
+ * Returns { ok, plan, wsId, branch, createBranch, isolate, why } — or { ok:false, error } when
71
+ * there is nothing to act on. `wsId` is the workspace the session is spawned against; `branch`
72
+ * non-null means a git operation is required first, and `isolate` says which kind.
73
+ */
74
+ function resolveStart({ initiative, trees, branches } = {}) {
75
+ const list = (trees || []).filter(Boolean);
76
+ if (!list.length) return { ok: false, error: 'no working tree is registered for this repository' };
77
+ const main = mainTreeOf(list);
78
+ if (!main) return { ok: false, error: 'no working tree is registered for this repository' };
79
+
80
+ const id = String(initiative == null ? '' : initiative).trim();
81
+ if (!id) {
82
+ return {
83
+ ok: true, plan: 'main-tree', wsId: main.id, branch: null, createBranch: false, isolate: false,
84
+ why: `runs in ${main.name || 'the main working tree'}${main.branch ? ` on ${main.branch}` : ''} — no branch is checked out and nothing else moves`,
85
+ };
86
+ }
87
+
88
+ const branch = branchForInitiative(id);
89
+ if (!branch) return { ok: false, error: 'an initiative id is required to derive a branch' };
90
+
91
+ // Already checked out in a LINKED worktree → go there. No git operation; the folder exists.
92
+ const existing = list.find((t) => t.id !== main.id && t.branch === branch);
93
+ if (existing) {
94
+ return {
95
+ ok: true, plan: 'reuse-worktree', wsId: existing.id, branch: null, createBranch: false, isolate: false,
96
+ why: `${id} already has its own folder — this reuses it, and creates nothing`,
97
+ };
98
+ }
99
+ // The MAIN tree is already on it. Use it rather than attempting a worktree git would refuse.
100
+ if (main.branch === branch) {
101
+ return {
102
+ ok: true, plan: 'main-tree-on-branch', wsId: main.id, branch: null, createBranch: false, isolate: false,
103
+ why: `this repository is already on ${branch} — the session runs here, and no second folder is made`,
104
+ };
105
+ }
106
+ // Otherwise: a new worktree, off the main tree, which stays exactly where it is.
107
+ // `create` is DERIVED. A null `branches` means the branch list was never read — assuming the
108
+ // branch is new would make `git worktree add -b` fail on every existing branch, so the honest
109
+ // default is to treat it as existing and let git's own error surface if it is not.
110
+ const known = Array.isArray(branches) ? branches : null;
111
+ const createBranch = known ? !known.includes(branch) : false;
112
+ return {
113
+ ok: true, plan: 'new-worktree', wsId: main.id, branch, createBranch, isolate: true,
114
+ why: `${branch} gets its own folder — ${main.name || 'the main working tree'} stays on ${main.branch || 'its branch'} and every running session stays put`,
115
+ };
116
+ }
117
+
118
+ module.exports = { resolveStart, branchForInitiative, mainTreeOf, BRANCH_PREFIX };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.18.0",
9
+ "version": "0.18.1",
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.18.0",
3
+ "version": "0.18.1",
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",
@@ -10,7 +10,7 @@
10
10
  "scripts": {
11
11
  "start": "node server.js",
12
12
  "test": "node test.js && npm run test:modules",
13
- "test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node no-network.test.js && node fixture-parity.test.mjs",
13
+ "test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node no-network.test.js && node fixture-parity.test.mjs && node start-policy.test.js",
14
14
  "test:ui": "node ui-test.js",
15
15
  "test:parity": "node design-parity.js --states",
16
16
  "test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
@@ -552,14 +552,22 @@ async function loadBoard(prod) {
552
552
  /** Publish every loaded product's board and records into state, in one patch. */
553
553
  function publishBoards() {
554
554
  const board = {};
555
+ /* Product NAME → workspace id, for every registered repository — not only the selected one.
556
+ * panel 4 keeps every open terminal live, including those belonging to a repository the PM is not
557
+ * currently looking at, and a parked tab's session must be resolved inside ITS OWN repository.
558
+ * Scoping it by the selected workspace is how a tab in one repo attached to a shell in another
559
+ * (tab-term.js `wsForTab`). */
560
+ const productWs = {};
555
561
  let records = {};
556
562
  for (const name of productOrder) {
557
563
  const p = products.get(name);
558
- if (!p || !p.board) continue;
564
+ if (!p) continue;
565
+ if (p.ws) productWs[name] = p.ws;
566
+ if (!p.board) continue;
559
567
  board[name] = p.board;
560
568
  records = { ...records, ...p.records };
561
569
  }
562
- setState({ board, records: { ...records, ...(hydrated) } });
570
+ setState({ board, productWs, records: { ...records, ...(hydrated) } });
563
571
  }
564
572
 
565
573
  /* Hydrated (fully derived) records live apart from the light ones so that a board reload cannot
@@ -617,12 +625,48 @@ function currentProductName() {
617
625
  */
618
626
 
619
627
  let pollTimer = null;
628
+ let sessionDigest = null;
629
+
630
+ /**
631
+ * WHAT THE SURFACE ACTUALLY RENDERS from a session row, as one string.
632
+ *
633
+ * Every `setState` repaints all four panels, and panel 2 rebuilds a board that is 138 initiatives
634
+ * deep on this repository. Publishing the poll unconditionally therefore tore down and rebuilt the
635
+ * whole window every four seconds, forever, whether or not anything had changed — which is a
636
+ * hundreds-of-milliseconds stall on the main thread, on a cadence, next to a terminal the PM is
637
+ * typing into. That is the jitter: input queued behind a repaint nobody asked for.
638
+ *
639
+ * The rows themselves change constantly (`lastOutputAt` moves with every byte an agent writes), so
640
+ * comparing the payload is useless. What is compared is the PROJECTION the panels read — id, name,
641
+ * workspace, derived state, the quiet reading ROUNDED THE WAY THE LABEL ROUNDS IT (minutes), and
642
+ * the fields the sidebar prints. A working agent produces an identical projection minute after
643
+ * minute, so the window stays still while it works, and repaints the moment something it shows
644
+ * actually moves.
645
+ */
646
+ function sessionProjection(rows) {
647
+ return (rows || []).map((s) => [
648
+ s.id, s.ws, s.name, s.agent, s.state, s.shown, s.note || '', s.cwd || '',
649
+ s.profile_id || '', s.profile_line || '',
650
+ // the label's own resolution, not the millisecond behind it
651
+ s.shown === 'quiet' && Number.isFinite(s.quietMs) ? Math.max(1, Math.round(s.quietMs / 60000)) : '',
652
+ // Joined on U+0000, fields and rows both. A separator that cannot occur in any of these
653
+ // values is what stops two different lists folding into one identical string and
654
+ // suppressing a repaint that was real.
655
+ ].join('\u0000')).join('\u0000\u0000');
656
+ }
620
657
 
621
658
  async function pollSessions() {
622
659
  if (FIXTURE) return;
623
660
  const out = await getJson('/api/sessions');
624
661
  if (out && Array.isArray(out.sessions)) {
625
- setState({ sessions: out.sessions, runningCount: liveCount(out.sessions) });
662
+ const digest = sessionProjection(out.sessions);
663
+ const count = liveCount(out.sessions);
664
+ // A poll that found nothing new is not a render. `runningCount` is compared too: it folds in
665
+ // `extraSess`, so a session started in this window still publishes on the very next poll.
666
+ if (digest !== sessionDigest || count !== state.runningCount) {
667
+ sessionDigest = digest;
668
+ setState({ sessions: out.sessions, runningCount: count });
669
+ }
626
670
  }
627
671
  schedulePoll();
628
672
  }
@@ -817,6 +861,46 @@ export function startSession(slug, sess) {
817
861
  setTimeout(pollSessions, 800);
818
862
  }
819
863
 
864
+ /* ── the scratchpad ─────────────────────────────────────────────────────────────────────────────
865
+ * The pad is PER SESSION, and the server has always stored it that way: `/api/scratchpad` is keyed
866
+ * by the server-issued session id (workspace-scoped, one file per session, deleted with it). What
867
+ * was lost in the four-panel port was the CLIENT half — the pad became one in-memory string for the
868
+ * whole page, so every tab in every repository showed, and overwrote, the same text.
869
+ *
870
+ * These two functions are the whole restoration. The key is `sess:<id>` or `ws:<id>`; the panel
871
+ * derives it, this file only speaks the two shapes the endpoint accepts.
872
+ */
873
+
874
+ function padParams(key) {
875
+ const k = String(key || '');
876
+ if (k.startsWith('sess:')) return { session: k.slice(5) };
877
+ if (k.startsWith('ws:')) return { ws: k.slice(3) };
878
+ return null;
879
+ }
880
+
881
+ /** panel-tabs.js — the stored text for one pad, or '' when there is none (and on any failure). */
882
+ export async function loadScratchpad(key) {
883
+ if (FIXTURE) return '';
884
+ const params = padParams(key);
885
+ if (!params) return '';
886
+ const out = await getJson('/api/scratchpad', params);
887
+ return out && out.ok !== false && typeof out.text === 'string' ? out.text : '';
888
+ }
889
+
890
+ /**
891
+ * panel-tabs.js — write one pad. A failure is reported to the console and NOT surfaced as a lost
892
+ * buffer: the text stays in `state.scratchPads`, so the PM keeps what they typed for this page even
893
+ * when the disk write refused.
894
+ */
895
+ export async function saveScratchpad(key, text) {
896
+ if (FIXTURE) return { ok: true };
897
+ const params = padParams(key);
898
+ if (!params) return { ok: false, error: 'no session or workspace to file this pad under' };
899
+ const out = await postJson('/api/scratchpad', { ...params, text });
900
+ if (out && out.ok === false) console.warn('[pm] the scratchpad was NOT saved: ' + out.error);
901
+ return out;
902
+ }
903
+
820
904
  /** tab-doc.js — `readDoc(path)`. The raw document body, or a rejected promise carrying the reason. */
821
905
  export async function readDoc(path) {
822
906
  if (FIXTURE) return '';
@@ -131,6 +131,13 @@ export function openTerm(slug, sess) {
131
131
  openTab({
132
132
  id: 'term:' + slug + ':' + sess.name, kind: 'term', title: sess.name, slug,
133
133
  repo: slugProduct()[slug], sess: sess.name, state: sess.st, profile: sess.profile || 'claude-work',
134
+ /* The SERVER's session id, when the row that was clicked carries one. This is the exact join:
135
+ * with it, the terminal attaches to precisely the PTY the PM pointed at, and a reload attaches
136
+ * to the same one again. Without it the tab falls back to matching by NAME, and names repeat
137
+ * across repositories — which is how a click in one repo opened another repo's shell.
138
+ * `spawn` is the opposite instruction, set only by `+ session`: start one, never adopt one. */
139
+ sessionId: (sess && sess.id) || null,
140
+ spawn: !!(sess && sess.spawn),
134
141
  });
135
142
  }
136
143
 
@@ -146,7 +146,32 @@ export function closeTab(id) {
146
146
  /* The data layer's optional session-start hook. In fixture mode nothing is ever started; in real
147
147
  * mode the PTY is data.js's to create, and panel 4's part is only to show the tab for it. */
148
148
  let dataMod = null;
149
- import('./data.js').then((m) => { dataMod = m; }).catch(() => { dataMod = null; });
149
+ /* The repaint matters: `syncPad` below cannot name a pad before this module exists, so the render
150
+ * that lands it is the render that first loads the PM's staged text. */
151
+ import('./data.js').then((m) => { dataMod = m; setState({}); }).catch(() => { dataMod = null; });
152
+
153
+ /**
154
+ * The next free `agent <n>`, counted against EVERY name the window can see — the server's live
155
+ * sessions, the ones started in this window, and the tabs already open — not just this page's
156
+ * counter.
157
+ *
158
+ * `startN` resets to 1 on every reload, and the name is what the terminal used to join on and what
159
+ * the tab id is built from. So after a reload the first `+ session` proposed `agent 1`, which was
160
+ * already running from before the reload: the click attached to that old session instead of
161
+ * starting a new one, and a second click collided with its own tab id. Counting past what exists
162
+ * is what makes `+ session` mean "a new one" again.
163
+ */
164
+ function nextSessionName() {
165
+ let max = state.startN - 1;
166
+ const bump = (name) => {
167
+ const m = /^agent (\d+)$/.exec(String(name || ''));
168
+ if (m) max = Math.max(max, Number(m[1]));
169
+ };
170
+ for (const s of (state.sessions || [])) bump(s && s.name);
171
+ for (const k in (state.extraSess || {})) for (const s of (state.extraSess[k] || [])) bump(s && s.name);
172
+ for (const t of (state.tabs || [])) if (t && t.kind === 'term') bump(t.sess);
173
+ return { n: max + 1, name: 'agent ' + (max + 1) };
174
+ }
150
175
 
151
176
  /**
152
177
  * `+ session` (line 2087): file a new session against the selected initiative and open its tab.
@@ -156,8 +181,11 @@ import('./data.js').then((m) => { dataMod = m; }).catch(() => { dataMod = null;
156
181
  export function newSession(slug) {
157
182
  const target = slug || selSlug();
158
183
  if (!target) return;
159
- const n = state.startN;
160
- const sess = { name: 'agent ' + n, st: 'working', label: 'working', profile: state.newSessProfile };
184
+ const { n, name } = nextSessionName();
185
+ /* `spawn` is carried on the session and thence onto the tab: it is an INSTRUCTION to start a PTY,
186
+ * and it is the only thing that authorises one. Without it the terminal resolves by name, which
187
+ * is a question about what already exists, not a request for something new. */
188
+ const sess = { name, st: 'working', label: 'working', profile: state.newSessProfile, spawn: true };
161
189
  setState((s) => ({
162
190
  startN: n + 1,
163
191
  extraSess: { ...s.extraSess, [target]: [...(s.extraSess[target] || []), sess] },
@@ -204,13 +232,118 @@ const panesRow = el('div', { style: 'flex:1;display:flex;min-height:0' });
204
232
  * One textarea, created once. Its text is written back as a CHILD TEXT NODE as well as a value,
205
233
  * because that is what the prototype's runtime does and the comparator reads text nodes — but only
206
234
  * when the two differ, so a repaint mid-sentence never moves the PM's caret.
235
+ *
236
+ * ── ONE PAD PER SESSION ─────────────────────────────────────────────────────────────────────
237
+ * The pad stages the next thing to type into a PARTICULAR agent, so it belongs to that agent's
238
+ * session. The four-panel port shipped it as one in-memory string for the whole page, which meant
239
+ * every terminal — in every repository, in every window — showed and overwrote the same buffer.
240
+ *
241
+ * The store is `state.scratchPads`, keyed by:
242
+ * `sess:<server session id>` — a terminal tab bound to a live PTY. The id is the SERVER's, so two
243
+ * sessions both called `agent 1` in two repositories are two pads.
244
+ * `ws:<workspace id>` — everything else (a doc, a task, nothing open): the repository pad.
245
+ * That is exactly the key space `/api/scratchpad` already stores under — the endpoint has been
246
+ * per-session all along, and it is the client half that went missing. `state.scratchText` remains
247
+ * the text ON SCREEN, so the copy button, `⇥ send to terminal` and the fixture render are unchanged.
248
+ *
249
+ * FIXTURE MODE IS UNTOUCHED: no key, no fetch, one pad, the prototype's own placeholder. The offline
250
+ * demo path must make no network call, and the comparator must see the design's string.
207
251
  */
252
+ const SCRATCH_PLACEHOLDER = FIXTURE
253
+ ? 'scratchpad — survives tab switches, dies with the page'
254
+ : 'scratchpad — one pad per session, saved on this machine';
255
+
208
256
  const scratchTa = el('textarea', {
209
- placeholder: 'scratchpad — survives tab switches, dies with the page',
257
+ placeholder: SCRATCH_PLACEHOLDER,
210
258
  style: 'height:96px;background:oklch(0.11 0.004 228.8);color:#c4ccce;padding:10px 14px;font-family:var(--mono);font-size:12px',
211
- onInput: (e) => setState({ scratchText: e.target.value }),
259
+ onInput: (e) => onScratchInput(e.target.value),
212
260
  });
213
261
 
262
+ /* ── pad identity, loading and autosave ─────────────────────────────────────────────────────── */
263
+
264
+ const padLoading = new Set(); // keys with a read in flight — one read per key, ever
265
+ let padTimer = null;
266
+ let padPending = null; // {key, text} not yet written
267
+
268
+ /** The pad the ACTIVE tab stages against, or null when it cannot be named yet. */
269
+ function padKey() {
270
+ if (FIXTURE) return null;
271
+ const act = activeTab();
272
+ if (act && act.kind === 'term') {
273
+ const id = (termMod && typeof termMod.sessionIdOf === 'function')
274
+ ? termMod.sessionIdOf(act) : (act.sessionId || null);
275
+ // A terminal whose session has not resolved yet has no pad of its own YET. Null holds the
276
+ // drawer on whatever it was showing rather than flashing an empty pad and then SAVING it over
277
+ // the real one the moment the session lands.
278
+ return id ? 'sess:' + id : null;
279
+ }
280
+ return state.wsId ? 'ws:' + state.wsId : null;
281
+ }
282
+
283
+ /** Write the pending pad now. Called before leaving a pad, and when the page goes away. */
284
+ function flushPad() {
285
+ if (padTimer) { clearTimeout(padTimer); padTimer = null; }
286
+ if (!padPending) return;
287
+ const { key, text } = padPending;
288
+ padPending = null;
289
+ if (dataMod && typeof dataMod.saveScratchpad === 'function') {
290
+ try { dataMod.saveScratchpad(key, text); } catch { /* the text stays in state either way */ }
291
+ }
292
+ }
293
+
294
+ function schedulePadSave(key, text) {
295
+ padPending = { key, text };
296
+ if (padTimer) clearTimeout(padTimer);
297
+ padTimer = setTimeout(flushPad, 500); // one write per pause, not one per keystroke
298
+ }
299
+
300
+ function onScratchInput(text) {
301
+ if (FIXTURE) { setState({ scratchText: text }); return; }
302
+ const key = state.scratchKey;
303
+ setState((s) => ({
304
+ scratchText: text,
305
+ scratchPads: key ? { ...s.scratchPads, [key]: text } : s.scratchPads,
306
+ }));
307
+ if (key) schedulePadSave(key, text);
308
+ }
309
+
310
+ /**
311
+ * Bring the drawer onto the pad the active tab belongs to, reading it from the server the first
312
+ * time that pad is seen. Called at the top of every render; a no-op unless the pad CHANGED.
313
+ */
314
+ function syncPad() {
315
+ if (FIXTURE || !dataMod) return; // no data layer yet — retried on the next render
316
+ const key = padKey();
317
+ if (!key || key === state.scratchKey) return;
318
+ flushPad(); // the pad being left is written before it is left
319
+ if (Object.prototype.hasOwnProperty.call(state.scratchPads, key)) {
320
+ setState({ scratchKey: key, scratchText: state.scratchPads[key] });
321
+ return;
322
+ }
323
+ setState({ scratchKey: key, scratchText: '' });
324
+ if (padLoading.has(key)) return;
325
+ padLoading.add(key);
326
+ const landed = (text) => {
327
+ padLoading.delete(key);
328
+ setState((s) => ({
329
+ scratchPads: { ...s.scratchPads, [key]: text },
330
+ // …and onto the screen only if this is still the pad on screen. A PM who switched tabs while
331
+ // the read was in flight must not have the arriving text land in the pad they moved to.
332
+ ...(s.scratchKey === key ? { scratchText: text } : {}),
333
+ }));
334
+ };
335
+ try { dataMod.loadScratchpad(key).then(landed, () => landed('')); }
336
+ catch { padLoading.delete(key); }
337
+ }
338
+
339
+ /* A pad edited in the last half-second is still worth keeping when the tab is closed or the machine
340
+ * sleeps. `pagehide` fires where `beforeunload` is unreliable (bfcache, mobile), and both are cheap. */
341
+ if (typeof window !== 'undefined' && !FIXTURE) {
342
+ window.addEventListener('pagehide', flushPad);
343
+ window.addEventListener('beforeunload', flushPad);
344
+ document.addEventListener('visibilitychange', () => { if (document.hidden) flushPad(); });
345
+ }
346
+
214
347
  const scratchCopyLabel = iv('⧉ copy');
215
348
  const scratchCopyBtn = el('button', {
216
349
  onClick: () => {
@@ -396,7 +529,13 @@ function tabNode(t, act, splitTab) {
396
529
  const bc = selected ? FX.TAB_COLORS.activeBorder : FX.TAB_COLORS.idleBorder;
397
530
  const color = act && act.id === t.id ? FX.TAB_COLORS.activeText : FX.TAB_COLORS.idleText;
398
531
  const dotColor = FX.TAB_COLORS.dot[t.kind] || FX.TAB_COLORS.dot.doc;
399
- const meta = t.kind === 'term' ? 'session' : (t.kind === 'task' ? 'task' : t.role);
532
+ /* A terminal's sub-line names the SESSION IT IS ON, not the word "session". After a reload the PM
533
+ * could not tell which session a re-opened tab had landed on, and the answer was on screen
534
+ * nowhere; the server's id is the one identifier that does not repeat across repositories. The
535
+ * fixture render keeps the design's literal — the comparator reads this text node. */
536
+ const meta = t.kind === 'term'
537
+ ? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
538
+ : (t.kind === 'task' ? 'task' : t.role);
400
539
 
401
540
  return el('div', {
402
541
  style: 'flex:none;display:flex;align-items:center;gap:8px;padding:0 10px;background:' + bg
@@ -433,6 +572,8 @@ function tabNode(t, act, splitTab) {
433
572
  * bar. The panes row renders even when nothing is open, exactly as the design does.
434
573
  */
435
574
  export function renderTabs(host) {
575
+ // Which pad the drawer is on is decided from the active tab, before anything is painted from it.
576
+ syncPad();
436
577
  const visTabs = visibleTabs();
437
578
  const act = visTabs.find((t) => t.id === state.active) || null;
438
579
  const splitTab = state.splitId
@@ -41,15 +41,37 @@ function initial() {
41
41
  col0: true, col1: true, col2: true, scratchOpen: false, scratchText: '', copied: false,
42
42
  flowsMap: null, schedList: null, agentList: null, memory: null,
43
43
  savedDocs: {}, scratchCopied: false,
44
+ /* The scratchpad is ONE PAD PER SESSION, not one per page (see panel-tabs.js). `scratchPads`
45
+ * holds every pad this window has loaded, keyed by the pad key the panel derives — `sess:<id>`
46
+ * for a terminal bound to a PTY, `ws:<id>` for the repository pad a non-terminal tab stages
47
+ * against. `scratchText` remains the text ON SCREEN, so every existing reader (the copy button,
48
+ * ⇥ send to terminal, the fixture render) is unchanged. `scratchKey` is which pad that is. */
49
+ scratchPads: {}, scratchKey: null,
44
50
  };
45
51
  }
46
52
 
47
53
  /* ── persistence ──────────────────────────────────────────────────────────────────────────────
48
- * Only the PM's *arrangement* is persisted — never content, never tabs, never a live session.
49
- * Each key carries its own validator. A stored value that fails its validator is DROPPED and the
50
- * rest of the record is still applied: "layout amnesia" in the PRD's failure-mode list means a
51
- * stale record from an older build throwing, or silently wiping a layout the PM is working in.
52
- * An unknown key is simply not read, so an older or newer schema costs nothing.
54
+ * The PM's *arrangement*, and — since the reload defect — WHICH VIEWS WERE OPEN. Never content,
55
+ * never a pad's text, never a live PTY: a tab is a view of a session the server owns, and what is
56
+ * stored here is the identity of the view, not the thing it looks at.
57
+ *
58
+ * Each key carries its own validator, and optionally a `pack` that decides what is WRITTEN. A
59
+ * stored value that fails its validator is DROPPED and the rest of the record is still applied:
60
+ * "layout amnesia" in the PRD's failure-mode list means a stale record from an older build
61
+ * throwing, or silently wiping a layout the PM is working in. An unknown key is simply not read,
62
+ * so an older or newer schema costs nothing.
63
+ *
64
+ * ── Why `tabs` is here, and why it was not ──────────────────────────────────────────────────
65
+ * It read "never tabs" because a tab was thought to be a live thing. It is not: the PTY lives in
66
+ * the server and survives both detach and a server restart (R7), so a window that forgot its tabs
67
+ * was not protecting anything — it was throwing away the only record of which sessions the PM had
68
+ * open. The PM then had to re-find them by hand, one click at a time, with no way to tell which
69
+ * session a re-opened tab had landed on. Restoring the views is what makes the surviving sessions
70
+ * visible again on reload.
71
+ *
72
+ * The stored tab is PACKED, not the live record: a whitelist of scalar fields, capped at 40 tabs.
73
+ * A doc tab's edited buffer, a task's derived prose, and anything a future field might carry stay
74
+ * out of localStorage by construction rather than by remembering to exclude them.
53
75
  */
54
76
  const isBool = (v) => typeof v === 'boolean';
55
77
  const isSlug = (v) => v === null || typeof v === 'string';
@@ -57,6 +79,28 @@ const isFlagMap = (v) =>
57
79
  !!v && typeof v === 'object' && !Array.isArray(v) &&
58
80
  Object.keys(v).every((k) => typeof k === 'string' && typeof v[k] === 'boolean');
59
81
 
82
+ /* The only tab fields that are written. `sessionId` is the load-bearing one: it is the SERVER's own
83
+ * id, so a restored terminal re-attaches to exactly the PTY it was showing rather than re-running
84
+ * the name join — which is what made a reloaded tab land on a session the PM could not identify. */
85
+ const TAB_FIELDS = ['id', 'kind', 'title', 'slug', 'repo', 'sess', 'sessionId', 'state', 'profile',
86
+ 'path', 'role', 'phase', 'status'];
87
+ const MAX_TABS = 40;
88
+
89
+ function packTab(t) {
90
+ if (!t || typeof t !== 'object' || typeof t.id !== 'string' || typeof t.kind !== 'string') return null;
91
+ const out = {};
92
+ for (const k of TAB_FIELDS) {
93
+ const v = t[k];
94
+ if (v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') out[k] = v;
95
+ }
96
+ return out;
97
+ }
98
+
99
+ const isTabList = (v) =>
100
+ Array.isArray(v) && v.length <= MAX_TABS &&
101
+ v.every((t) => t && typeof t === 'object' && !Array.isArray(t)
102
+ && typeof t.id === 'string' && typeof t.kind === 'string');
103
+
60
104
  const PERSIST = {
61
105
  theme: (v) => v === 'dark' || v === 'light',
62
106
  col0: isBool, col1: isBool, col2: isBool,
@@ -65,6 +109,15 @@ const PERSIST = {
65
109
  sel: isSlug,
66
110
  collapsedGroups: isFlagMap,
67
111
  collapsedPhases: isFlagMap,
112
+ tabs: isTabList,
113
+ active: isSlug,
114
+ splitId: isSlug,
115
+ scratchOpen: isBool,
116
+ };
117
+
118
+ /** What is WRITTEN for a key, when it is not the live value verbatim. */
119
+ const PACK = {
120
+ tabs: (v) => (Array.isArray(v) ? v.slice(0, MAX_TABS).map(packTab).filter(Boolean) : []),
68
121
  };
69
122
 
70
123
  /** The exact set of keys written to localStorage, in write order. */
@@ -84,13 +137,19 @@ function restore(target) {
84
137
  try { ok = PERSIST[k](saved[k]); } catch { ok = false; }
85
138
  if (ok) target[k] = saved[k];
86
139
  }
140
+ /* `active` and `splitId` name tabs. A record whose tab list was dropped (or that names a tab the
141
+ * list no longer holds) must not restore a pointer into nothing — panel 4 would render an empty
142
+ * pane and the PM would see a window with no tab selected and no way to guess why. */
143
+ const ids = new Set((target.tabs || []).map((t) => t.id));
144
+ if (!ids.has(target.active)) target.active = target.tabs.length ? target.tabs[target.tabs.length - 1].id : null;
145
+ if (!ids.has(target.splitId)) target.splitId = null;
87
146
  return target;
88
147
  }
89
148
 
90
149
  function persist() {
91
150
  if (FIXTURE) return;
92
151
  const out = {};
93
- for (const k of persistKeys) out[k] = state[k];
152
+ for (const k of persistKeys) out[k] = PACK[k] ? PACK[k](state[k]) : state[k];
94
153
  try { localStorage.setItem(STORE_KEY, JSON.stringify(out)); } catch { /* quota / private mode */ }
95
154
  }
96
155
 
@@ -149,6 +149,7 @@ function connect() {
149
149
  for (const rec of panes.values()) {
150
150
  if (rec.kind !== 'real') continue;
151
151
  rec.attachedId = null;
152
+ rec.sentCols = null; rec.sentRows = null; // the next connection has stated no grid yet
152
153
  note(rec, 'pmos: connection to the workspace server lost — retrying');
153
154
  }
154
155
  clearTimeout(SOCK.timer);
@@ -181,6 +182,17 @@ function handle(m) {
181
182
  rec.noted = null;
182
183
  try { rec.term.reset(); } catch { /* a disposed terminal */ }
183
184
  if (m.buf) { try { rec.term.write(m.buf); } catch { /* ignore */ } }
185
+ /* THE PTY DOES NOT KNOW HOW WIDE THIS BROWSER IS. `attached` carries the SERVER's cols/rows —
186
+ * 80×24 for a session nobody ever resized — and the server never adopts the client's grid on
187
+ * attach. The only thing that ever told it was xterm's `onResize`, which fires when a fit
188
+ * CHANGES the grid; the first fit runs while the socket is still connecting, so `sessionId` was
189
+ * null and the message was dropped, and every later fit measured the same box and changed
190
+ * nothing, so it never fired again. Result: a 200-column window rendering a shell that believes
191
+ * it has 80, wrapping a third of the way across — "the terminal only shows a small portion".
192
+ * The grid is therefore PUSHED here, unconditionally, the moment there is a session to push it
193
+ * to. `sent*` is cleared first so a re-attach after a reconnect re-states it. */
194
+ rec.sentCols = null; rec.sentRows = null;
195
+ syncSize(rec);
184
196
  refitVisible();
185
197
  return;
186
198
  }
@@ -199,6 +211,8 @@ function handle(m) {
199
211
  const rec = panes.get(id);
200
212
  if (!rec) { send({ t: 'detach', id: m.id }); return; } // the tab closed while we waited
201
213
  rec.sessionId = m.id;
214
+ rec.sentCols = null; rec.sentRows = null;
215
+ rememberSession(rec.tab, m.id);
202
216
  attach(rec);
203
217
  return;
204
218
  }
@@ -218,22 +232,38 @@ const recFor = (sessionId) => {
218
232
  };
219
233
 
220
234
  /* ── binding a tab to a real session ───────────────────────────────────────────────────────────
221
- * The tab knows the initiative slug and the session NAME (`term:<slug>:<name>` is its id); the
222
- * server knows session ids. The join is by name within the tab's workspace, which is the same join
223
- * the sidebar has always made — a session's name is what the PM sees and what they named it.
224
- * `sessionId` set on the tab by the data layer wins, when there is one.
235
+ * `sessionId` on the tab is the ONLY exact join, and it is the one that is used whenever it exists:
236
+ * it is the server's own id, the data layer puts it on every tab opened from a listed session, and
237
+ * `rememberSession` writes it back (and so into localStorage) the first time a tab resolves. The
238
+ * name join below is the fallback for a tab that predates all of that.
239
+ *
240
+ * THE NAME JOIN IS SCOPED TO THE TAB'S OWN WORKSPACE, and no longer falls back to "a session with
241
+ * this name anywhere". Session names are not unique across repositories — every `+ session` in
242
+ * every repo produces `agent 1` — so the unscoped fallback quietly attached a tab in one repository
243
+ * to a PTY running in another, and the PM was shown another repo's shell under their own repo's
244
+ * initiative. A name that resolves nowhere in this workspace is now reported as not found, which is
245
+ * the true answer.
225
246
  */
226
247
  function resolveSessionId(tab) {
227
- if (tab.sessionId) return tab.sessionId;
228
248
  const pool = SOCK.sessions.length ? SOCK.sessions
229
249
  : (Array.isArray(state.sessions) ? state.sessions : []);
250
+ if (tab.sessionId) {
251
+ // A restored tab names a session from a previous run of this window — or of this SERVER. Trust
252
+ // it while nothing is known (the socket has not said `hello` yet), but once there is a list,
253
+ // an id that is not in it is a session that no longer exists: fall through rather than
254
+ // `attach`ing to it, which the server answers with silence and the PM reads as a dead pane.
255
+ if (!pool.length || pool.some((s) => s && s.id === tab.sessionId)) return tab.sessionId;
256
+ }
257
+ if (tab.spawn) return null; // a `+ session` tab must START one, never adopt one
230
258
  const name = tab.sess;
231
259
  if (!name) return null;
232
260
  const byId = pool.find((s) => s && s.id === name);
233
261
  if (byId) return byId.id;
234
- const ws = workspaceId();
262
+ const ws = wsForTab(tab);
235
263
  const inWs = pool.find((s) => s && s.name === name && (!ws || s.ws === ws));
236
264
  if (inWs) return inWs.id;
265
+ // No cross-workspace fallback. See the note above: it is how a repository showed another's shell.
266
+ if (ws) return null;
237
267
  const anywhere = pool.find((s) => s && s.name === name);
238
268
  return anywhere ? anywhere.id : null;
239
269
  }
@@ -254,40 +284,95 @@ function workspaceId() {
254
284
  }
255
285
 
256
286
  /**
257
- * Was this session started in THIS window (the `+ session` path)? Only then may a terminal spawn a
258
- * PTY on its own: a tab opened against a session the board listed must attach to that session or
259
- * say it is gone — silently starting a second shell with the same name would be a lie about what
260
- * the PM is looking at.
287
+ * The workspace THIS TAB belongs to — which is not always the selected one. Every open terminal is
288
+ * kept live, including the ones belonging to a repository the PM is not currently looking at (see
289
+ * panel-tabs.js `keepSessionsLive`), so scoping a parked tab's name join by the SELECTED repository
290
+ * would scope it by the wrong repo entirely. `state.productWs` is the data layer's product-name →
291
+ * workspace-id map; a tab whose repository is not in it falls back to the selected workspace, which
292
+ * is right for the tab on screen and is all a fixture render has.
293
+ */
294
+ function wsForTab(tab) {
295
+ const map = state.productWs;
296
+ if (tab && tab.repo && map && typeof map === 'object' && map[tab.repo]) return map[tab.repo];
297
+ return workspaceId();
298
+ }
299
+
300
+ /**
301
+ * Does this tab carry an instruction to START a session? Only then may a terminal spawn a PTY on
302
+ * its own: a tab opened against a session the board listed must attach to that session or say it is
303
+ * gone — silently starting a second shell with the same name would be a lie about what the PM is
304
+ * looking at.
305
+ *
306
+ * The flag is set by `+ session` and travels ON THE TAB. It used to be inferred by looking the
307
+ * tab's session NAME up in `state.extraSess`, which is a different question wearing the same
308
+ * clothes: after a reload `extraSess` is empty and `startN` is back at 1, so a fresh `+ session`
309
+ * produced the name `agent 1`, the name join found the `agent 1` still running from before the
310
+ * reload, and the click ATTACHED to an old session instead of starting a new one. The PM asked for
311
+ * a session and got someone else's.
261
312
  */
262
- function startedHere(tab) {
313
+ function wantsSpawn(tab) {
314
+ if (tab && tab.spawn) return true;
263
315
  const list = (state.extraSess || {})[tab.slug];
264
316
  return Array.isArray(list) && list.some((s) => s && s.name === tab.sess);
265
317
  }
266
318
 
319
+ /**
320
+ * Record the session a tab actually landed on, on the tab record itself — so it is written to
321
+ * localStorage and a reload re-attaches to exactly this PTY rather than re-running the name join.
322
+ * "I don't know which session it has reloaded" is that join being re-run against a list that has
323
+ * moved on; an id does not have that failure mode.
324
+ */
325
+ function rememberSession(tab, sessionId) {
326
+ if (!tab || !sessionId) return;
327
+ // The guard is checked BEFORE the mutation, and on the tab record itself. `rec.tab` is very often
328
+ // the same object that sits in `state.tabs`, so mutating first and then comparing would always
329
+ // find them equal and the repaint that shows the id on the tab would never be scheduled.
330
+ if (tab.sessionId === sessionId && tab.spawn === false) return;
331
+ tab.sessionId = sessionId;
332
+ tab.spawn = false;
333
+ setState((s) => {
334
+ const i = (s.tabs || []).findIndex((t) => t.id === tab.id);
335
+ if (i < 0) return null;
336
+ const next = [...s.tabs];
337
+ next[i] = { ...next[i], sessionId, spawn: false };
338
+ return { tabs: next };
339
+ });
340
+ }
341
+
267
342
  function bind(rec) {
268
343
  if (rec.kind !== 'real' || !rec.term) return;
269
344
  const id = resolveSessionId(rec.tab);
270
345
  if (id) {
271
- if (rec.sessionId !== id) { rec.sessionId = id; rec.attachedId = null; }
346
+ if (rec.sessionId !== id) {
347
+ rec.sessionId = id; rec.attachedId = null; rec.sentCols = null; rec.sentRows = null;
348
+ }
349
+ rememberSession(rec.tab, id);
272
350
  attach(rec);
273
351
  return;
274
352
  }
275
353
  if (!SOCK.open) { note(rec, 'pmos: connecting to the workspace server…'); return; }
276
354
  if (SOCK.spawning.get(rec.tab.id)) return;
277
- if (startedHere(rec.tab)) {
355
+ if (wantsSpawn(rec.tab)) {
278
356
  // agent 'shell' deliberately: PMOS cannot know which agent CLI the PM meant from a tab alone,
279
357
  // and falling back from an agent to a shell after the fact would be the dishonest version.
280
358
  SOCK.spawning.set(rec.tab.id, true);
281
359
  SOCK.spawnQueue.push(rec.tab.id);
282
360
  note(rec, 'pmos: starting a session…', true);
283
- const ok = send({ t: 'new', name: rec.tab.sess, agent: 'shell', ws: workspaceId() });
361
+ const ok = send({ t: 'new', name: rec.tab.sess, agent: 'shell', ws: wsForTab(rec.tab) });
284
362
  if (!ok) {
285
363
  SOCK.spawning.delete(rec.tab.id);
286
364
  SOCK.spawnQueue = SOCK.spawnQueue.filter((x) => x !== rec.tab.id);
287
365
  }
288
366
  return;
289
367
  }
290
- note(rec, 'pmos: no live session named "' + rec.tab.sess + '" — nothing was attached');
368
+ /* Say WHICH session was looked for and why it could not be found. A tab restored from a previous
369
+ * run names an id; one opened from the board names a name. Both are worth printing — "nothing was
370
+ * attached" without the identity is exactly the report that left the PM unable to tell which
371
+ * session a reloaded tab had landed on. */
372
+ note(rec, rec.tab.sessionId
373
+ ? 'pmos: session ' + rec.tab.sessionId + ' ("' + (rec.tab.sess || '?')
374
+ + '") is no longer running — nothing was attached'
375
+ : 'pmos: no live session named "' + rec.tab.sess + '" in this repository — nothing was attached');
291
376
  }
292
377
 
293
378
  function attach(rec) {
@@ -296,6 +381,24 @@ function attach(rec) {
296
381
  if (send({ t: 'attach', id: rec.sessionId })) SOCK.attached.add(rec.sessionId);
297
382
  }
298
383
 
384
+ /**
385
+ * Tell the PTY the grid this browser is actually rendering. Sent whenever the pane is attached and
386
+ * the terminal's cols/rows differ from what this connection last stated — which covers the attach
387
+ * itself, every refit that moves the grid, and a reconnect (where `sent*` is cleared).
388
+ *
389
+ * Idempotent by design: `sent*` makes a re-render cost nothing, and the guard is on what was SENT
390
+ * rather than on what xterm reports, because the server holds the authoritative copy and a resize
391
+ * this connection never sent is a resize the shell never heard.
392
+ */
393
+ function syncSize(rec) {
394
+ if (!rec || rec.kind !== 'real' || !rec.term) return;
395
+ if (!rec.sessionId || rec.attachedId !== rec.sessionId) return;
396
+ const cols = rec.term.cols, rows = rec.term.rows;
397
+ if (!cols || !rows) return;
398
+ if (rec.sentCols === cols && rec.sentRows === rows) return;
399
+ if (send({ t: 'resize', id: rec.sessionId, cols, rows })) { rec.sentCols = cols; rec.sentRows = rows; }
400
+ }
401
+
299
402
  /** Write a status line into the terminal itself — once per distinct message, dimmed. */
300
403
  function note(rec, line, force) {
301
404
  if (!rec.term) return;
@@ -321,6 +424,10 @@ export function refitVisible() {
321
424
  if (!rec.host.isConnected) continue;
322
425
  if (!rec.host.offsetWidth || !rec.host.offsetHeight) continue;
323
426
  try { rec.fit.fit(); } catch { /* a terminal mid-teardown */ }
427
+ // …and state the resulting grid to the PTY. `fit()` only fires xterm's `onResize` when the
428
+ // grid MOVES, so a pane that was measured before it had a session would otherwise never tell
429
+ // the shell how wide it is. See the `attached` handler.
430
+ syncSize(rec);
324
431
  }
325
432
  };
326
433
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
@@ -459,9 +566,7 @@ function buildReal(tab) {
459
566
  kind: 'real', tab, host, term, fit, sessionId: null, attachedId: null, noted: null, ro: null,
460
567
  };
461
568
  term.onData((d) => { if (rec.sessionId) send({ t: 'input', id: rec.sessionId, data: d }); });
462
- term.onResize(({ cols, rows }) => {
463
- if (rec.sessionId) send({ t: 'resize', id: rec.sessionId, cols, rows });
464
- });
569
+ term.onResize(() => syncSize(rec));
465
570
  if (typeof ResizeObserver === 'function') {
466
571
  rec.ro = new ResizeObserver(() => refitVisible());
467
572
  try { rec.ro.observe(host); } catch { /* ignore */ }
@@ -534,6 +639,17 @@ export function sendToTerm(tab, data) {
534
639
  return send({ t: 'input', id: rec.sessionId, data });
535
640
  }
536
641
 
642
+ /**
643
+ * The SERVER's session id this tab is bound to, or null. The scratchpad reads it: a pad belongs to
644
+ * the session, and the session's identity is this id — not the tab id (which survives a session
645
+ * being replaced) and not the session name (which repeats across repositories).
646
+ */
647
+ export function sessionIdOf(tab) {
648
+ const rec = panes.get(tab && (tab.id || tab));
649
+ if (rec && rec.kind === 'real' && rec.sessionId) return rec.sessionId;
650
+ return (tab && tab.sessionId) || null;
651
+ }
652
+
537
653
  /** Focus the terminal of a tab, if it is live. */
538
654
  export function focusTerm(tab) {
539
655
  const rec = panes.get(tab && (tab.id || tab));
@@ -71,6 +71,7 @@ const consoleState = require('./lib/console-state'); // read-only console surf
71
71
  const boardCache = require('./lib/board-cache'); // the workflow board, cached + derived off the event loop (R4)
72
72
  const fmParse = require('./lib/file-mode-parse'); // canonical file-mode parsers, shared with metrics.py (D64 D)
73
73
  const gitInfo = require('./lib/git-info'); // read-mostly branch awareness + select/create at session start
74
+ const startPolicy = require('./lib/start-policy'); // ONE stated rule for where a session starts (repo-first R1/R3)
74
75
  const treeShare = require('./lib/tree-share'); // who shares a working tree, and who is DECLARED to be driving it
75
76
  const tmuxControl = require('./lib/tmux-control'); // tmux -CC protocol: the pane's raw stream, so xterm.js keeps its scrollback
76
77
  const relatedDocs = require('./lib/related-docs'); // which documents a change puts out of step (R5/R6)
@@ -1306,6 +1307,10 @@ function persist(s) {
1306
1307
  try {
1307
1308
  fs.writeFileSync(path.join(SESS_DIR, s.id + '.json'), JSON.stringify(meta));
1308
1309
  fs.writeFileSync(path.join(SESS_DIR, s.id + '.scrollback'), s.buf);
1310
+ // What is on disk now matches `s.buf`. The periodic writer reads this to skip a session whose
1311
+ // buffer has not moved — see the interval below. Cleared only on a write that SUCCEEDED, so a
1312
+ // disk blip leaves the session dirty and it is retried on the next tick.
1313
+ s.bufDirty = false;
1309
1314
  } catch { /* best-effort; never crash a live session on a disk blip */ }
1310
1315
  }
1311
1316
  function unpersist(id) {
@@ -1641,6 +1646,7 @@ function trimBuf(buf, limit) {
1641
1646
  function appendBuf(s, data) {
1642
1647
  s.buf += data;
1643
1648
  if (s.buf.length > SCROLLBACK) s.buf = trimBuf(s.buf, SCROLLBACK);
1649
+ s.bufDirty = true; // the periodic writer skips a session that is not dirty
1644
1650
  // Parser 2's whole input: the moment output last arrived. `quiet` is derived from this, never
1645
1651
  // from reading the bytes — which is why it is the only one of the five that cannot misread.
1646
1652
  s.lastOutputAt = Date.now();
@@ -1808,6 +1814,7 @@ function seedFromPaneHistory(s, name) {
1808
1814
  if (!text) return false;
1809
1815
  s.buf = trimBuf(text + '\r\n', SCROLLBACK); // 5000 lines of history can overrun the ring
1810
1816
  s.parsed = null;
1817
+ s.bufDirty = true; // the ring moved; the file on disk is stale
1811
1818
  return true;
1812
1819
  }
1813
1820
 
@@ -2112,6 +2119,86 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
2112
2119
  return { ok: true, session: s, notice: treeShare.joinNotices(notice, shared) };
2113
2120
  }
2114
2121
 
2122
+ /* ── workspace-repo-first · start work in one click (PRD R1 · R3) ───────────────────────────────
2123
+ * The composer asked six questions to open a terminal, and the one question the PM actually has —
2124
+ * "which initiative am I working on?" — was not among them. It asked for a BRANCH instead, plus an
2125
+ * isolation checkbox whose default came from liveSessionCount(). These three functions replace that
2126
+ * with: pick a repository, optionally pick an initiative, press one control.
2127
+ *
2128
+ * The policy itself is lib/start-policy.js — pure, so the git-touching paths are testable without
2129
+ * a repository. This layer only supplies it with facts and carries out what it decides.
2130
+ *
2131
+ * R3 is why `agent` appears NOWHERE below except as a value passed through to the spawn: the
2132
+ * destination is derived from the repository and the initiative, so `shell` and `claude` cannot
2133
+ * land in different directories for the same selection. Previously both resolved through
2134
+ * `state.wsId` — whichever folder the client happened to have selected — which is exactly how they
2135
+ * came apart.
2136
+ */
2137
+ function treesOfRepo(repoKey) {
2138
+ const out = [];
2139
+ for (const w of workspaces.values()) {
2140
+ const ident = repoIdentity(w.root);
2141
+ if (String(ident.key) !== String(repoKey)) continue;
2142
+ out.push({ id: w.id, root: w.root, name: w.name, repo_key: ident.key, branch: ident.branch });
2143
+ }
2144
+ return out;
2145
+ }
2146
+ // The repo key for a request that named a workspace rather than a repository. The client sends
2147
+ // whichever it has; a stale ws id must not silently retarget another repo, so an unknown id is an
2148
+ // error rather than a fallback to "some repository".
2149
+ function repoKeyFor({ repoKey, ws }) {
2150
+ if (repoKey) return String(repoKey);
2151
+ const w = ws && workspaces.get(ws);
2152
+ return w ? String(repoIdentity(w.root).key) : null;
2153
+ }
2154
+ // What the PM is about to get, BEFORE they press anything (R1: the destination is named first).
2155
+ // Read-only: it reads git and resolves the policy, and starts nothing.
2156
+ function startPreview({ initiative, repoKey, ws }) {
2157
+ const key = repoKeyFor({ repoKey, ws });
2158
+ if (!key) return { ok: false, error: 'no repository selected' };
2159
+ const trees = treesOfRepo(key);
2160
+ const main = startPolicy.mainTreeOf(trees);
2161
+ // branchInfo is shelled per call and only when an initiative is named — the no-initiative plan
2162
+ // needs no branch list, and this runs on every selection change.
2163
+ let branches = null;
2164
+ if (initiative && main) {
2165
+ try { const bi = gitInfo.branchInfo(main.root); if (bi.ok) branches = bi.branches; } catch {}
2166
+ }
2167
+ const plan = startPolicy.resolveStart({ initiative, trees, branches });
2168
+ if (!plan.ok) return plan;
2169
+ // The literal directory, so the sentence in the UI is checkable rather than a promise.
2170
+ let dir = null;
2171
+ if (plan.plan === 'new-worktree' && main) { try { dir = gitInfo.worktreePath(main.root, plan.branch); } catch {} }
2172
+ else { const t = trees.find((x) => x.id === plan.wsId); dir = t ? t.root : null; }
2173
+ return { ok: true, plan: plan.plan, why: plan.why, dir, branch: plan.branch, wsId: plan.wsId };
2174
+ }
2175
+ // One click. Resolves the policy, then hands the SAME seam spawnWithBranch already exposes the
2176
+ // branch/isolate decision it made — so the inherited hard floor (a failed git operation spawns
2177
+ // nothing) is enforced in exactly one place and is not re-implemented here.
2178
+ function startWork({ initiative, repoKey, ws, agent, name, profile }) {
2179
+ const key = repoKeyFor({ repoKey, ws });
2180
+ if (!key) return { ok: false, error: 'no repository selected' };
2181
+ const trees = treesOfRepo(key);
2182
+ const main = startPolicy.mainTreeOf(trees);
2183
+ let branches = null;
2184
+ if (initiative && main) {
2185
+ try { const bi = gitInfo.branchInfo(main.root); if (bi.ok) branches = bi.branches; } catch {}
2186
+ }
2187
+ const plan = startPolicy.resolveStart({ initiative, trees, branches });
2188
+ if (!plan.ok) return plan;
2189
+ const r = spawnWithBranch({
2190
+ name: name || initiative || agent || 'shell',
2191
+ agent: agent || 'shell',
2192
+ ws: plan.wsId,
2193
+ branch: plan.branch,
2194
+ createBranch: plan.createBranch,
2195
+ isolate: plan.isolate,
2196
+ profile,
2197
+ });
2198
+ if (!r.ok) return r;
2199
+ return { ok: true, session: r.session, notice: r.notice || plan.why, plan: plan.plan };
2200
+ }
2201
+
2115
2202
  // ── Worktree housekeeping: the app that creates the folders shows and clears them ──────────────
2116
2203
  // Isolation buys "your other sessions keep running" at the price of a directory per branch. That
2117
2204
  // price is only fair if the PM can see the folders and reclaim them without leaving the workspace
@@ -2255,8 +2342,17 @@ function renameSession(id, name) {
2255
2342
  return true;
2256
2343
  }
2257
2344
 
2258
- // periodic scrollback persistence so a crash loses at most a few seconds of history
2259
- setInterval(() => { for (const s of sessions.values()) if (s.pty) persist(s); }, 5000).unref?.();
2345
+ // Periodic scrollback persistence so a crash loses at most a few seconds of history — but ONLY for
2346
+ // sessions whose buffer actually moved. `persist()` is two SYNCHRONOUS writes, the second of them
2347
+ // the whole ring buffer (up to 200 KB), and this ran unconditionally for every live session every
2348
+ // five seconds. A workspace with six idle panes open was therefore doing over a megabyte of blocking
2349
+ // disk I/O, on a timer, forever — rewriting bytes identical to the ones already on disk, on the same
2350
+ // thread that carries every keystroke to a PTY and every byte back to the browser. That is a stall
2351
+ // the PM feels as the terminal going unresponsive for no reason they can see.
2352
+ //
2353
+ // `bufDirty` is set by appendBuf (the only writer of `s.buf`) and cleared by persist(), so an idle
2354
+ // session now costs one map lookup per tick and a busy one is written exactly as often as before.
2355
+ setInterval(() => { for (const s of sessions.values()) if (s.pty && s.bufDirty) persist(s); }, 5000).unref?.();
2260
2356
 
2261
2357
  // ── File-mode state, per workspace. ───────────────────────────────────────────────────
2262
2358
  // A repo that carries PMOS state OWNS its records. The kit (D54) keeps them under `pmos/state/`
@@ -3504,6 +3600,16 @@ async function handleRequest(req, res) {
3504
3600
  if (!w) return json(res, { ok: false, error: 'no workspace' });
3505
3601
  return json(res, gitInfo.branchInfo(w.root));
3506
3602
  }
3603
+ if (p === '/api/start/preview') {
3604
+ // repo-first R1 — what one click will do, said BEFORE the click. Read-only: it resolves the
3605
+ // policy and names the literal destination directory, and starts nothing. This exists so the
3606
+ // rule is checkable by the PM rather than inferred after a folder appears.
3607
+ return json(res, startPreview({
3608
+ initiative: url.searchParams.get('initiative') || '',
3609
+ repoKey: url.searchParams.get('repo') || '',
3610
+ ws: url.searchParams.get('ws') || '',
3611
+ }));
3612
+ }
3507
3613
  if (p === '/api/git/worktrees') {
3508
3614
  // Every working tree attached to this workspace's repo, each with the reasons it can or cannot
3509
3615
  // be removed. A worktree the workspace made is a folder the PM has to live with, so the app
@@ -4159,7 +4265,7 @@ wss.on('connection', (ws) => {
4159
4265
  ws.on('message', (raw) => {
4160
4266
  let m; try { m = JSON.parse(raw); } catch { return; }
4161
4267
  // Gate the shell-spawning / PTY-feeding messages through the buckets before they do any work.
4162
- if ((m.t === 'new' || m.t === 'respawn') && !rateLimit.take(ws._rlSpawn)) {
4268
+ if ((m.t === 'new' || m.t === 'start' || m.t === 'respawn') && !rateLimit.take(ws._rlSpawn)) {
4163
4269
  ws.send(JSON.stringify({ t: 'error', error: 'rate limited: too many session spawns — slow down' }));
4164
4270
  return;
4165
4271
  }
@@ -4183,6 +4289,15 @@ wss.on('connection', (ws) => {
4183
4289
  // non-repo) — said out loud, never swallowed
4184
4290
  if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
4185
4291
  ws.send(JSON.stringify({ t: 'created', id: r.session.id }));
4292
+ } else if (m.t === 'start') {
4293
+ // repo-first R1 — the one-click path. It carries an INITIATIVE (or none) and a repository,
4294
+ // never a branch and never an isolate flag: those are the policy's to decide, and a client
4295
+ // that could still send them would be a second place the rule lives.
4296
+ const r = startWork({ initiative: m.initiative, repoKey: m.repoKey, ws: m.ws,
4297
+ agent: m.agent, name: m.name, profile: m.profile });
4298
+ if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^profile /.test(r.error || '') ? '' : 'start: ') + r.error })); return; }
4299
+ if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
4300
+ ws.send(JSON.stringify({ t: 'created', id: r.session.id }));
4186
4301
  } else if (m.t === 'respawn') {
4187
4302
  const old = sessions.get(m.id); if (!old || old.pty) return;
4188
4303
  // `respawn` is UNCHANGED on the wire (profiles R9): the profile is read off the record the