create-pmos 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,7 +25,7 @@ const STORE_KEY = 'pmos.workspace.pm.v1';
25
25
  function initial() {
26
26
  return {
27
27
  theme: 'dark', sel: null, q: '', mode: 'product', collapsedGroups: {}, settingsOpen: false,
28
- product: null, newSessProfile: 'claude-work', intgSynced: {},
28
+ product: null, intgSynced: {},
29
29
  addSrc: 'github', addRepo: 'github.com/acme/atlas-mobile', addPath: '~/code/notes-cli',
30
30
  addLog: [], addedProducts: [], extraKnow: {},
31
31
  // the folder chooser's two transient bits: the in-app browser's current listing (null = closed)
@@ -55,6 +55,38 @@ function initial() {
55
55
  * last bind. None is persisted — a half-finished declaration restored on reload would be a
56
56
  * question about a moment that has passed, and `bindResult` is a report about one request. */
57
57
  bindSess: null, bindTo: '', bindResult: null,
58
+ /* ── the session pre-flight (workspace-session-preflight) ──────────────────────────────────
59
+ * `newSess` is `{slug}` while the form is open and null otherwise. The rest is what the PM has
60
+ * typed and clicked in it, plus the two server reads it renders over (`branches` from
61
+ * /api/git/branches, `profiles` from /api/profiles) and the answer to the last attempt.
62
+ *
63
+ * NONE of it is in PERSIST below, and that is a decision rather than an omission: the
64
+ * allow-list is explicit, so these are excluded by construction. A branch choice restored from
65
+ * localStorage is how a PM starts a session on a branch they picked yesterday for a question
66
+ * they have already answered — and `newSessBusy` restored true would be a form wedged shut
67
+ * waiting on a request that ended with the last page.
68
+ *
69
+ * `branchInfo` is separate from `newSessBranches`: it is the server's `{t:'branch'}` broadcast,
70
+ * written by tab-term.js whenever a checkout happens ANYWHERE, and it is what stops an open
71
+ * picker going stale while the PM checks something out in a terminal.
72
+ *
73
+ * ── WHY THE PROFILE SLOT IS NAMED FOR THE FORM AND NOT FOR "A NEW SESSION" ────────────────
74
+ * It was `newSessProfile`, and `+ session` read it — which made it a slot two different
75
+ * controls wrote and read, only one of which ever asked the PM anything. Opening the form wrote
76
+ * the server's remembered preference into it, every chip wrote the PM's choice into it, and
77
+ * cancelling cleared neither; the next one-click `+ session` then filed that choice onto its
78
+ * session, put it on the tab, and (since a tab's profile now travels to the server) sent it as
79
+ * a spawn instruction. A descriptor the server cannot resolve is refused, so one click produced
80
+ * no session and a refusal written inside the terminal it had already opened — the exact shape
81
+ * the pre-flight exists to remove, on the path the PRD calls primary.
82
+ *
83
+ * `preflightProfile` therefore belongs to the form, is reset when it opens AND when it closes,
84
+ * and is read by nothing else. `+ session` states no profile at all, which is the honest
85
+ * answer for a control that never asked. */
86
+ newSess: null, newSessName: '', newSessAgent: 'shell',
87
+ newSessBranch: '', newSessBranchNew: '', newSessBranchMode: false,
88
+ newSessIsolate: null, newSessError: null, newSessBusy: false,
89
+ newSessBranches: null, preflightProfile: null, profiles: null, branchInfo: null,
58
90
  col0: true, col1: true, col2: true, scratchOpen: false, scratchText: '', copied: false,
59
91
  flowsMap: null, schedList: null, agentList: null, memory: null,
60
92
  savedDocs: {}, scratchCopied: false,
@@ -64,6 +96,13 @@ function initial() {
64
96
  * against. `scratchText` remains the text ON SCREEN, so every existing reader (the copy button,
65
97
  * ⇥ send to terminal, the fixture render) is unchanged. `scratchKey` is which pad that is. */
66
98
  scratchPads: {}, scratchKey: null,
99
+ /* The server's answer to "is the runtime state settled?" (/api/workspaces#state_health), null
100
+ * until the data layer answers. `{ok:false}` means the workspace could not move the PM's state
101
+ * out of the checkout because both directories hold state, so any list drawn from a store the
102
+ * per-user directory does not hold is SHORT — panel 1 draws it as a red banner rather than
103
+ * letting an empty skills list read as "you have no skills". Deliberately NOT in PERSIST: it is
104
+ * a fact about this server's boot, and a stale one restored from localStorage would be a lie. */
105
+ stateHealth: null,
67
106
  };
68
107
  }
69
108
 
@@ -124,6 +124,152 @@ const SOCK = {
124
124
  timer: null,
125
125
  };
126
126
 
127
+ /* ── what rides on spawnQueue, and why it is two kinds ─────────────────────────────────────────
128
+ * The queue itself is unchanged in MECHANISM: `created` carries no correlation id, the server
129
+ * answers `new` in receipt order, so first-in-first-out IS the correlation and two simultaneous
130
+ * starts cannot be handed each other's shell. What changed is what a slot holds.
131
+ *
132
+ * {kind:'pane', tabId} — the original behaviour: a pane already exists and is waiting for
133
+ * its session. Still used by bind()'s fallback spawn (see below).
134
+ * {kind:'ask', msg, resolve} — NOBODY is waiting on screen. The answer resolves a promise, and
135
+ * the caller decides whether a tab is opened at all.
136
+ *
137
+ * The second kind is the whole of workspace-session-preflight's R1. Before it, a refusal arrived
138
+ * after the tab, the pane and the xterm had all been built, so `{t:'error'}` could only be written
139
+ * INSIDE a terminal belonging to a session the server had just declined to start.
140
+ *
141
+ * ── WHY A SLOT ALSO CARRIES ITS MESSAGE, AND WHETHER IT WAS SENT ──────────────────────────────
142
+ * An `ask` is queued BEFORE it is on the wire, because on the first use of the pre-flight there is
143
+ * no wire yet: `connect()` opens a WebSocket and the handshake is asynchronous, so the socket that
144
+ * the very first press opens is still in readyState 0 when that press would have sent. The first
145
+ * implementation called `send()` synchronously and reported the false refusal "not connected to the
146
+ * workspace server — nothing was started" — which made the feature dead on first use in a fresh
147
+ * window, and alive on the second press only because the failed press had left a socket behind.
148
+ *
149
+ * So a slot holds the message it still owes the server, and `flushAsks()` puts it on the wire the
150
+ * moment there is one — from `requestSpawn` when the socket is already up, and from `ws.onopen`
151
+ * when it is not. This is the idiom the module already used for panes: bind() answers a cold socket
152
+ * with "connecting to the workspace server…" and is re-run from `onopen`.
153
+ *
154
+ * The FIFO correlation is unchanged in meaning but is now stated exactly: `created`/`error` belong
155
+ * to the oldest request THAT WAS ACTUALLY SENT (takeSent). A queued-but-unsent slot cannot absorb
156
+ * the answer to somebody else's message, because the server has never heard of it. What actually
157
+ * enforces the correlation is that queue order and WIRE order are the same thing — which is why
158
+ * `ws.onopen` flushes the asks before it re-binds the panes, and why takeSent is a floor rather
159
+ * than the mechanism. See its own note.
160
+ */
161
+ const SPAWN_TIMEOUT_MS = 15000;
162
+
163
+ /** Resolve an `ask` slot exactly once, and stop its timer. */
164
+ function settleAsk(req, out) {
165
+ if (!req || req.settled) return;
166
+ req.settled = true;
167
+ if (req.timer) { clearTimeout(req.timer); req.timer = null; }
168
+ try { req.resolve(out); } catch { /* a caller that threw is not this module's to repair */ }
169
+ }
170
+
171
+ /** Fail every outstanding `ask`. A dropped promise is the same swallow this module is removing. */
172
+ function failAllAsks(error) {
173
+ for (const req of SOCK.spawnQueue) if (req && req.kind === 'ask') settleAsk(req, { ok: false, error });
174
+ }
175
+
176
+ /**
177
+ * Put every queued `ask` that is not yet on the wire onto it, in queue order — and stop at the
178
+ * first one the socket will not take, so the queue's order stays the order the server received.
179
+ * Called from `requestSpawn` (the socket may already be up) and from `ws.onopen` (it has just come
180
+ * up). Sending in queue order is what keeps FIFO the correlation `created` relies on.
181
+ */
182
+ function flushAsks() {
183
+ for (const req of SOCK.spawnQueue) {
184
+ if (!req || req.kind !== 'ask' || req.sent || req.settled) continue;
185
+ if (!send(req.msg)) return;
186
+ req.sent = true;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * The oldest request the server has actually been sent. `consume` removes it (an answer that ends
192
+ * the request) or leaves it (a `notice`, which is followed by a `created` for the same request).
193
+ *
194
+ * It is a search rather than a `shift` because the head of the queue may now be an `ask` that is
195
+ * waiting for the socket to open: the server cannot be answering a message it was never sent, and
196
+ * handing that slot somebody else's `created` would attach a pre-flight to the wrong PTY.
197
+ *
198
+ * SAID PLAINLY, BECAUSE THE SUITE CANNOT SHOW IT: no call site reaches that state today. `bind()`
199
+ * only queues while the socket is open, `flushAsks` runs before the re-bind, and both are removed
200
+ * again when a send is refused — so replacing this with a plain `shift()` passes every assertion in
201
+ * pm-term-test.js (checked, not assumed). What keeps the correlation honest in practice is the
202
+ * ORDER in `ws.onopen`, and that IS asserted — the race test one page down goes red when the panes
203
+ * are re-bound first. This is a floor under an invariant, not behaviour anybody can observe.
204
+ */
205
+ function takeSent(consume) {
206
+ const i = SOCK.spawnQueue.findIndex((r) => r && r.sent);
207
+ if (i < 0) return null;
208
+ const req = SOCK.spawnQueue[i];
209
+ if (consume) SOCK.spawnQueue.splice(i, 1);
210
+ return req;
211
+ }
212
+
213
+ /**
214
+ * Ask the server to START a session, and answer what it said. NOTHING is opened here — no tab, no
215
+ * pane, no terminal. That is the caller's to do, and only on `{ok:true}`.
216
+ *
217
+ * Resolves `{ok:true, id, notice}` on `created`, or `{ok:false, error}` on any of the four ways a
218
+ * request can end without one:
219
+ * · the server refused it (`{t:'error'}` — an unreadable profile, a failed worktree, a failed
220
+ * checkout, all of which spawnWithBranch decides BEFORE spawnSession is reached);
221
+ * · the socket closed with the request still outstanding;
222
+ * · the socket never came up at all;
223
+ * · nothing came back within SPAWN_TIMEOUT_MS.
224
+ * It always settles. A promise that hangs is a form that hangs, which is the same failure in a
225
+ * different costume.
226
+ *
227
+ * A COLD SOCKET IS NOT A REFUSAL. `connect()` returns before the WebSocket handshake completes, so
228
+ * on the first press in a window with no terminal open there is nothing to send on yet. The message
229
+ * is therefore QUEUED and flushed from `ws.onopen`; only the timeout and a socket that actually
230
+ * closed can end it without an answer. Reporting "not connected" synchronously is what made this
231
+ * control fail every first press in a fresh window.
232
+ */
233
+ export function requestSpawn(opts) {
234
+ const o = opts || {};
235
+ if (FIXTURE) {
236
+ return Promise.resolve({ ok: false, error: 'fixture mode starts no sessions' });
237
+ }
238
+ connect();
239
+ return new Promise((resolve) => {
240
+ const req = {
241
+ kind: 'ask', resolve, notice: null, settled: false, timer: null, sent: false,
242
+ msg: {
243
+ t: 'new',
244
+ name: o.name,
245
+ agent: o.agent || 'shell',
246
+ ws: o.ws,
247
+ /* Sent even when the PM chose nothing, and that is deliberate on two of them. `isolate` has
248
+ * a server-side default (`server.js` liveSessionCount) whose own comment says the client
249
+ * should state what the PM actually saw; `profile: null` is a DELIBERATE "no profile",
250
+ * which the server distinguishes from a client that forgot the field. */
251
+ branch: o.branch || null,
252
+ createBranch: !!o.createBranch,
253
+ isolate: o.isolate === undefined ? null : o.isolate,
254
+ profile: o.profile === undefined ? null : o.profile,
255
+ },
256
+ };
257
+ SOCK.spawnQueue.push(req);
258
+ /* Armed BEFORE the flush, and armed whether or not the message goes out now: a request waiting
259
+ * for a socket that never opens must end in a sentence, not in a form that spins for ever. */
260
+ req.timer = setTimeout(() => {
261
+ SOCK.spawnQueue = SOCK.spawnQueue.filter((x) => x !== req);
262
+ settleAsk(req, {
263
+ ok: false,
264
+ error: req.sent
265
+ ? 'the workspace server did not answer — nothing was started'
266
+ : 'could not reach the workspace server — nothing was started',
267
+ });
268
+ }, SPAWN_TIMEOUT_MS);
269
+ flushAsks();
270
+ });
271
+ }
272
+
127
273
  /* The session heartbeat arrives about every 2s. It is deliberately NOT pushed through setState:
128
274
  * a state change repaints all four panels, and repainting the window twice a second to redraw
129
275
  * nothing would destroy text selection, scroll position and any in-flight edit. Nothing in this
@@ -140,13 +286,24 @@ function connect() {
140
286
  SOCK.ws = ws;
141
287
  ws.onopen = () => {
142
288
  SOCK.open = true; SOCK.tries = 0;
289
+ /* The asks FIRST, and the order is the point: a pre-flight that pressed `start session` before
290
+ * this socket existed has been waiting here, and binding the panes first would put a pane's
291
+ * fallback `new` ahead of it on the wire while the queue still said otherwise. Flushing first
292
+ * keeps queue order and wire order the same thing, which is what makes FIFO a correlation. */
293
+ flushAsks();
143
294
  for (const rec of panes.values()) if (rec.kind === 'real') bind(rec);
144
295
  };
145
296
  ws.onclose = () => {
146
297
  SOCK.open = false; SOCK.ws = null; SOCK.attached.clear();
147
298
  /* An in-flight `new` is abandoned rather than retried: the server may well have started the
148
299
  * session before the socket dropped, and the reconnect's `hello` will find it by name. Retrying
149
- * blind is how a PM ends up with two shells and one tab. */
300
+ * blind is how a PM ends up with two shells and one tab.
301
+ *
302
+ * An `ask` is not merely abandoned, it is ANSWERED. Dropping the promise would leave the
303
+ * pre-flight spinning on a question that can no longer be answered — the same swallow this
304
+ * module is removing, one layer up. The wording is careful: the session may in fact exist, so
305
+ * it says what PMOS knows (this window did not start one) and not more. */
306
+ failAllAsks('the connection to the workspace server was lost — this window started nothing');
150
307
  SOCK.spawning.clear(); SOCK.spawnQueue = [];
151
308
  for (const rec of panes.values()) {
152
309
  if (rec.kind !== 'real') continue;
@@ -204,11 +361,21 @@ function handle(m) {
204
361
  return;
205
362
  }
206
363
  if (m.t === 'created') {
207
- // `created` carries no correlation id, so the OLDEST outstanding request takes it. The server
364
+ // `created` carries no correlation id, so the oldest request THAT WAS SENT takes it. The server
208
365
  // answers `new` in the order it received them, which makes the queue the correlation — matching
209
366
  // by iteration order over the pane map would hand two simultaneous starts each other's shell.
210
- const id = SOCK.spawnQueue.shift();
211
- if (!id) return;
367
+ // "That was sent" is the whole guard: a queued ask still waiting for the socket to open has not
368
+ // been asked for anything, so it cannot be the thing being answered.
369
+ const req = takeSent(true);
370
+ if (!req) return;
371
+ if (req.kind === 'ask') {
372
+ // Nobody is on screen waiting for this. Hand the caller the id and whatever non-fatal truth
373
+ // arrived just before it; the caller opens the tab, and the tab carries the id so bind()
374
+ // ATTACHES rather than starting a second session for the same click.
375
+ settleAsk(req, { ok: true, id: m.id, notice: req.notice || null });
376
+ return;
377
+ }
378
+ const id = req.tabId;
212
379
  SOCK.spawning.delete(id);
213
380
  const rec = panes.get(id);
214
381
  if (!rec) { send({ t: 'detach', id: m.id }); return; } // the tab closed while we waited
@@ -219,13 +386,39 @@ function handle(m) {
219
386
  return;
220
387
  }
221
388
  if (m.t === 'error' || m.t === 'notice') {
389
+ /* An `error` CONSUMES its slot — the request is over. A `notice` only PEEKS, because the server
390
+ * sends `notice` and then `created` for the same request and the slot has to survive to take
391
+ * the `created`. That asymmetry predates this change and is preserved exactly. */
392
+ const req = takeSent(!!m.error);
393
+ if (!req) return;
394
+ if (req.kind === 'ask') {
395
+ // THE POINT OF THE WHOLE INITIATIVE. A refusal resolves the promise and opens nothing: no
396
+ // tab, no pane, no xterm, no extraSess row. It used to be written as a dimmed line inside a
397
+ // terminal the click had already built, for a session the server had just declined to start.
398
+ if (m.error) settleAsk(req, { ok: false, error: m.error });
399
+ else req.notice = m.notice; // held, and shown by whoever ends up owning the pane
400
+ return;
401
+ }
222
402
  const line = m.error ? 'pmos: ' + m.error : 'pmos: ' + m.notice;
223
- const id = m.error ? SOCK.spawnQueue.shift() : SOCK.spawnQueue[0];
224
- if (m.error && id) SOCK.spawning.delete(id);
225
- const rec = id ? panes.get(id) : null;
403
+ if (m.error) SOCK.spawning.delete(req.tabId);
404
+ const rec = panes.get(req.tabId);
226
405
  if (rec) note(rec, line, true);
227
406
  return;
228
407
  }
408
+ if (m.t === 'branch') {
409
+ /* The picker must not go stale. The server broadcasts this after a worktree is added, after a
410
+ * checkout and after a worktree is removed — so a branch the PM checked out in a terminal, or
411
+ * one this very spawn created, changes what the open pre-flight should be offering. The
412
+ * retired single-file client acted on exactly this (app.js:294); the four-panel port dropped
413
+ * every one of the three broadcasts on the floor.
414
+ *
415
+ * Written to state unconditionally rather than only when a form is open: `branchInfo` is a
416
+ * cache of the server's last word about a workspace, and a reader that is not open ignores it.
417
+ * The `ws` is carried so a reader can tell whether the news is about the repository it is
418
+ * asking about. */
419
+ setState({ branchInfo: { ...m, at: Date.now() } });
420
+ return;
421
+ }
229
422
  }
230
423
 
231
424
  const recFor = (sessionId) => {
@@ -355,10 +548,44 @@ function bind(rec) {
355
548
  if (!SOCK.open) { note(rec, 'pmos: connecting to the workspace server…'); return; }
356
549
  if (SOCK.spawning.get(rec.tab.id)) return;
357
550
  if (wantsSpawn(rec.tab)) {
358
- // agent 'shell' deliberately: PMOS cannot know which agent CLI the PM meant from a tab alone,
359
- // and falling back from an agent to a shell after the fact would be the dishonest version.
360
- SOCK.spawning.set(rec.tab.id, true);
361
- SOCK.spawnQueue.push(rec.tab.id);
551
+ /* THE FALLBACK SPAWN — no longer the PM's path, and no longer pretending to be.
552
+ *
553
+ * Since workspace-session-preflight, a session the PM started through a control that ASKS
554
+ * arrives here already carrying `sessionId`, so `resolveSessionId` returns above and this is
555
+ * never reached for it. What still reaches it is a session record with `spawn:true` and no id:
556
+ * a test seed (pm-term-test.js, ui-test.js both write `extraSess` directly), and a tab whose
557
+ * server-side session vanished between the click and the render.
558
+ *
559
+ * It is kept rather than removed, and that is an honest weakness worth stating: the defect is
560
+ * closed because every PM path goes through a control that asks, NOT by construction. A future
561
+ * call site that writes `extraSess` directly re-opens it. See the PRD's R5.
562
+ *
563
+ * WHAT IT MAY SEND IS EXACTLY WHAT A TAB CARRIES — `agent` and `profile`, and nothing else.
564
+ * An earlier draft of this line also sent `branch`, `createBranch` and `isolate` off the tab
565
+ * record, under a comment claiming a seed could name them. Nothing sets any of the three
566
+ * (`openTerm` in panel-initiatives.js is the only builder of a term tab) and none of them is in
567
+ * `TAB_FIELDS` (state.js), so all three were `undefined` on every tab that has ever existed:
568
+ * three nulls dressed as a feature, and a claim in a comment that no code path could satisfy.
569
+ *
570
+ * They are also the three that must NOT be here even if a tab did carry one. This path exists
571
+ * to re-open a view whose session went missing — a reload, a server restart — and a restored
572
+ * tab that re-ran a checkout each time would move the PM's working tree behind their back,
573
+ * repeatedly, for a decision they made once. A branch is a question, and the control that asks
574
+ * it is the pre-flight; its answer reaches the server through `requestSpawn`, never through a
575
+ * tab record.
576
+ *
577
+ * `profile` stays because it is genuinely a tab's own: `openTerm` sets it from the session the
578
+ * PM clicked and `TAB_FIELDS` persists it, so a vanished session is restarted under the
579
+ * environment it claimed rather than under a different one. `agent` is honoured when a caller
580
+ * puts one on the tab and defaults to `shell` otherwise, which is the only honest default for
581
+ * a tab that cannot say what the PM meant. */
582
+ const t = rec.tab;
583
+ SOCK.spawning.set(t.id, true);
584
+ /* `sent: true` from birth: this slot is only ever pushed when the socket is up (the guard a few
585
+ * lines above), and it is removed again if `send` refuses it. An `ask` is the kind that can sit
586
+ * in the queue unsent, waiting for `onopen`. */
587
+ const req = { kind: 'pane', tabId: t.id, sent: true };
588
+ SOCK.spawnQueue.push(req);
362
589
  note(rec, 'pmos: starting a session…', true);
363
590
  /* `initiative` is the ONE field this message was missing (workspace-session-attribution R2).
364
591
  * `+ session` is clicked on an initiative row, `openTerm` puts that row's id on the tab as
@@ -370,12 +597,13 @@ function bind(rec) {
370
597
  * A tab with no slug sends nothing extra and the session is unattributed. That is the honest
371
598
  * outcome, not a failure: the server never refuses a spawn over a label. */
372
599
  const ok = send({
373
- t: 'new', name: rec.tab.sess, agent: 'shell', ws: wsForTab(rec.tab),
374
- initiative: rec.tab.slug || null,
600
+ t: 'new', name: t.sess, agent: t.agent || 'shell', ws: wsForTab(t),
601
+ initiative: t.slug || null,
602
+ profile: t.profile === undefined ? null : t.profile,
375
603
  });
376
604
  if (!ok) {
377
- SOCK.spawning.delete(rec.tab.id);
378
- SOCK.spawnQueue = SOCK.spawnQueue.filter((x) => x !== rec.tab.id);
605
+ SOCK.spawning.delete(t.id);
606
+ SOCK.spawnQueue = SOCK.spawnQueue.filter((x) => x !== req);
379
607
  }
380
608
  return;
381
609
  }
@@ -697,6 +925,28 @@ export function killSession(id) {
697
925
  return send({ t: 'kill', id });
698
926
  }
699
927
 
928
+ /**
929
+ * Write a status line into a tab's terminal, by tab id — for a caller that holds a fact about a
930
+ * session but not the pane showing it.
931
+ *
932
+ * The pre-flight is the caller. `spawnWithBranch` answers an accepted start with a `notice` — a
933
+ * worktree's path, "not a git repository, so no branch was applied" — and that notice arrives
934
+ * BEFORE the pane it belongs to exists, because the pane is now built from the acceptance. Without
935
+ * this the server's deliberate non-fatal truth would be dropped precisely because the request
936
+ * succeeded, which is the same swallow as the one this initiative removes, in the happy path.
937
+ *
938
+ * The pane may not be mounted on the very next frame, so the write is retried a few times and then
939
+ * given up on quietly — a status line is worth trying for, not worth a timer that never stops.
940
+ */
941
+ export function noteOnTab(tabId, line, tries) {
942
+ const rec = panes.get(tabId);
943
+ if (rec && rec.term) { note(rec, line, true); return true; }
944
+ const left = tries === undefined ? 20 : tries;
945
+ if (left <= 0) return false;
946
+ setTimeout(() => noteOnTab(tabId, line, left - 1), 100);
947
+ return false;
948
+ }
949
+
700
950
  /**
701
951
  * The SERVER's session id this tab is bound to, or null. The scratchpad reads it: a pad belongs to
702
952
  * the session, and the session's identity is this id — not the tab id (which survives a session
@@ -729,6 +979,17 @@ export function termProbe() {
729
979
  return out;
730
980
  }
731
981
 
732
- if (typeof window !== 'undefined') window.pmTerm = { refitVisible, termProbe, panes };
982
+ /**
983
+ * The message entry point, exposed for the browser suite ONLY.
984
+ *
985
+ * `handle` is what `ws.onmessage` calls after `JSON.parse`, so feeding it a frame exercises the
986
+ * exact path a real broadcast takes — nothing is stubbed and no branch is special-cased for a
987
+ * test. It exists because the `{t:'branch'}` claim cannot honestly be asserted any other way: the
988
+ * only way to make the server broadcast one is to check a branch out, and the server under test in
989
+ * pm-term-test.js is serving the repository the suite is running in.
990
+ */
991
+ export function __handleForTest(m) { return handle(m); }
992
+
993
+ if (typeof window !== 'undefined') window.pmTerm = { refitVisible, termProbe, panes, requestSpawn };
733
994
 
734
995
  export default mountTerm;