create-pmos 0.26.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/README.md +17 -7
- package/workspace/bin/pmos.js +5 -1
- package/workspace/doctor.js +5 -1
- package/workspace/lib/skill-store.js +180 -6
- package/workspace/lib/state-root.js +587 -0
- package/workspace/package.json +2 -2
- package/workspace/public/pm/data.js +63 -0
- package/workspace/public/pm/panel-initiatives.js +8 -1
- package/workspace/public/pm/panel-newsess.js +507 -0
- package/workspace/public/pm/panel-repos.js +31 -0
- package/workspace/public/pm/panel-tabs.js +34 -2
- package/workspace/public/pm/state.js +40 -1
- package/workspace/public/pm/tab-term.js +277 -16
- package/workspace/server.js +174 -20
- package/workspace/test-run.js +84 -0
|
@@ -32,6 +32,7 @@ import * as FX from './fixture.js';
|
|
|
32
32
|
import { openTab, openTerm as openTermTab, openDoc as openDocTab } from './panel-initiatives.js';
|
|
33
33
|
import { mountDoc, disposeDoc, docText } from './tab-doc.js';
|
|
34
34
|
import { mountTask } from './tab-task.js';
|
|
35
|
+
import { newSessionBlock, openNewSession } from './panel-newsess.js';
|
|
35
36
|
|
|
36
37
|
/* tab-term.js is loaded LAZILY and on purpose: it is another module's file, a static import of a
|
|
37
38
|
* file that is not on disk yet takes the whole panel down with it, and an empty terminal pane is a
|
|
@@ -239,7 +240,18 @@ export function newSession(slug, opts) {
|
|
|
239
240
|
/* `spawn` is carried on the session and thence onto the tab: it is an INSTRUCTION to start a PTY,
|
|
240
241
|
* and it is the only thing that authorises one. Without it the terminal resolves by name, which
|
|
241
242
|
* is a question about what already exists, not a request for something new. */
|
|
242
|
-
|
|
243
|
+
/* NO PROFILE, and stated rather than left to a slot. This control asks the PM nothing — that is
|
|
244
|
+
* what makes it one click — so the only honest thing it can say about a profile is that none was
|
|
245
|
+
* chosen, which the server reads as "no overlay" rather than as a client that forgot the field.
|
|
246
|
+
*
|
|
247
|
+
* It used to read `state.newSessProfile`, the slot the `⋯ options` pre-flight also wrote. Opening
|
|
248
|
+
* that form wrote the server's remembered preference into it and every chip wrote the PM's choice
|
|
249
|
+
* into it, and cancelling cleared neither — so a question the PM had abandoned came back as an
|
|
250
|
+
* instruction on the next one-click start, travelled to the server on the tab, and was refused
|
|
251
|
+
* whenever the descriptor could not be resolved. The click then opened a terminal and wrote the
|
|
252
|
+
* refusal inside it, for a session that never existed. A profile is a per-session question, and
|
|
253
|
+
* the control that asks it is the only one that may answer it. */
|
|
254
|
+
const sess = { name, st: 'working', label: 'working', profile: null, spawn: true };
|
|
243
255
|
setState((s) => ({
|
|
244
256
|
startN: n + 1,
|
|
245
257
|
startPrompt: null,
|
|
@@ -292,6 +304,21 @@ const newSessionBtn = el('button', {
|
|
|
292
304
|
onClick: () => newSession(), style: CHIP_STYLE, hover: CHIP_HOVER,
|
|
293
305
|
}, '+ session');
|
|
294
306
|
|
|
307
|
+
/* `⋯ options` — the demoted composer, restored (workspace-session-preflight).
|
|
308
|
+
*
|
|
309
|
+
* `workspace-repo-first`'s accepted PRD says the six-question composer "remains reachable as *more
|
|
310
|
+
* options* … it is demoted, not deleted". The four-panel rebuild deleted it, and start-policy.test
|
|
311
|
+
* has been asserting in a comment that `spawnWithBranch` is "still reachable as more options" —
|
|
312
|
+
* reachable from the server, by no client. This chip is the client half.
|
|
313
|
+
*
|
|
314
|
+
* `+ session` above is untouched: same handler, same text, same CHIP_STYLE, same co-tenancy
|
|
315
|
+
* chooser. One click stays the primary path, which is what that PRD decided. */
|
|
316
|
+
const newSessionOptsBtn = el('button', {
|
|
317
|
+
onClick: () => openNewSession(selSlug()),
|
|
318
|
+
style: CHIP_STYLE, hover: CHIP_HOVER,
|
|
319
|
+
title: 'choose the branch, the agent and the profile before the session starts',
|
|
320
|
+
}, '⋯ options');
|
|
321
|
+
|
|
295
322
|
const splitLabel = iv('⫽ split');
|
|
296
323
|
const splitBtn = el('button', {
|
|
297
324
|
onClick: () => toggleSplit(), style: SPLIT_STYLE, hover: CHIP_HOVER,
|
|
@@ -769,6 +796,7 @@ export function renderTabs(host) {
|
|
|
769
796
|
setChildren(tabBar, [
|
|
770
797
|
...visTabs.map((t) => tabNode(t, act, splitTab)),
|
|
771
798
|
newSessionBtn,
|
|
799
|
+
newSessionOptsBtn,
|
|
772
800
|
canSplit ? splitBtn : null,
|
|
773
801
|
]);
|
|
774
802
|
|
|
@@ -806,7 +834,11 @@ export function renderTabs(host) {
|
|
|
806
834
|
// was. Replacing the panes with it would re-parent a terminal, which is the quiet regression
|
|
807
835
|
// the persistent pane hosts exist to prevent.
|
|
808
836
|
startPromptBlock(),
|
|
809
|
-
|
|
837
|
+
// The pre-flight sits in the same slot and for the same reason: it is about the click just made
|
|
838
|
+
// in the bar above, and putting it here leaves every open pane — and every live PTY — exactly
|
|
839
|
+
// where it was. Replacing the panes with it would re-parent a terminal.
|
|
840
|
+
newSessionBlock(),
|
|
841
|
+
visTabs.length === 0 && !state.startPrompt && !state.newSess ? emptyState : null,
|
|
810
842
|
panesRow,
|
|
811
843
|
state.scratchOpen ? scratchBlock : null,
|
|
812
844
|
bottomBar,
|
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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:
|
|
374
|
-
initiative:
|
|
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(
|
|
378
|
-
SOCK.spawnQueue = SOCK.spawnQueue.filter((x) => x !==
|
|
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
|
-
|
|
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;
|