create-pmos 0.17.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.
@@ -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));