create-pmos 0.18.0 → 0.19.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/README.md +57 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/scripts/metrics.py +9 -2
- package/workspace/README.md +21 -1
- package/workspace/lib/file-mode-parse.js +12 -1
- package/workspace/lib/start-policy.js +118 -0
- package/workspace/lib/tree-share.js +171 -13
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +2 -2
- package/workspace/public/index.html +6 -0
- package/workspace/public/pm/app.js +44 -7
- package/workspace/public/pm/data.js +117 -5
- package/workspace/public/pm/panel-initiatives.js +7 -0
- package/workspace/public/pm/panel-tabs.js +339 -10
- package/workspace/public/pm/state.js +71 -6
- package/workspace/public/pm/tab-doc.js +5 -5
- package/workspace/public/pm/tab-task.js +1 -1
- package/workspace/public/pm/tab-term.js +151 -19
- package/workspace/server.js +186 -9
|
@@ -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
|
-
*
|
|
222
|
-
* server
|
|
223
|
-
*
|
|
224
|
-
*
|
|
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 =
|
|
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
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
* the
|
|
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
|
|
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) {
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
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);
|
|
@@ -339,7 +446,7 @@ if (typeof window !== 'undefined') {
|
|
|
339
446
|
|
|
340
447
|
/* ── fixture pane ──────────────────────────────────────────────────────────────────────────── */
|
|
341
448
|
|
|
342
|
-
const SCROLL_STYLE = 'flex:1;overflow-y:auto;padding:14px 16px;font-family:var(--mono);font-size:12.5px;line-height:1.65;color:#c4ccce';
|
|
449
|
+
const SCROLL_STYLE = 'flex:1;min-height:0;overflow-y:auto;padding:14px 16px;font-family:var(--mono);font-size:12.5px;line-height:1.65;color:#c4ccce';
|
|
343
450
|
const LINE_STYLE = (c) => 'color:' + c + ';white-space:pre-wrap;overflow-wrap:anywhere';
|
|
344
451
|
const PROMPT_ROW_STYLE = 'display:flex;gap:8px;align-items:baseline';
|
|
345
452
|
const PROMPT_STYLE = 'color:#47cf79;flex:none';
|
|
@@ -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((
|
|
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,33 @@ export function sendToTerm(tab, data) {
|
|
|
534
639
|
return send({ t: 'input', id: rec.sessionId, data });
|
|
535
640
|
}
|
|
536
641
|
|
|
642
|
+
/**
|
|
643
|
+
* End a running session, by id — the `+ session` chooser's one destructive action.
|
|
644
|
+
*
|
|
645
|
+
* The wire message already existed (`t:'kill'`); what did not exist anywhere in the four-panel
|
|
646
|
+
* surface was a way to reach it, which is why "is it safe to end this?" had no answer the PM could
|
|
647
|
+
* act on. This is a bare send: the CONFIRMATION lives in the chooser, next to the reading of what
|
|
648
|
+
* ending it would cost, because that is the only place the PM is looking at both at once.
|
|
649
|
+
*
|
|
650
|
+
* Returns false when the socket is not up, so a caller can say "not sent" rather than imply a kill
|
|
651
|
+
* that never left the page.
|
|
652
|
+
*/
|
|
653
|
+
export function killSession(id) {
|
|
654
|
+
if (FIXTURE || !id) return false;
|
|
655
|
+
return send({ t: 'kill', id });
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* The SERVER's session id this tab is bound to, or null. The scratchpad reads it: a pad belongs to
|
|
660
|
+
* the session, and the session's identity is this id — not the tab id (which survives a session
|
|
661
|
+
* being replaced) and not the session name (which repeats across repositories).
|
|
662
|
+
*/
|
|
663
|
+
export function sessionIdOf(tab) {
|
|
664
|
+
const rec = panes.get(tab && (tab.id || tab));
|
|
665
|
+
if (rec && rec.kind === 'real' && rec.sessionId) return rec.sessionId;
|
|
666
|
+
return (tab && tab.sessionId) || null;
|
|
667
|
+
}
|
|
668
|
+
|
|
537
669
|
/** Focus the terminal of a tab, if it is live. */
|
|
538
670
|
export function focusTerm(tab) {
|
|
539
671
|
const rec = panes.get(tab && (tab.id || tab));
|
package/workspace/server.js
CHANGED
|
@@ -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)
|
|
@@ -368,6 +369,36 @@ function treeForWs(wsIdent) {
|
|
|
368
369
|
function treeTenants(tree, excludeId) {
|
|
369
370
|
return treeShare.coTenants([...sessions.values()], treeOfWsMap(), tree, excludeId);
|
|
370
371
|
}
|
|
372
|
+
/**
|
|
373
|
+
* A registry row, described for a human deciding whether to JOIN it rather than start another
|
|
374
|
+
* (workspace-session-choice). "2 sessions already running" names a fact the PM cannot act on; what
|
|
375
|
+
* they need is which one, what it is doing, and what ending it would cost.
|
|
376
|
+
*
|
|
377
|
+
* Every field is one the sidebar already publishes — the joined presented state, the quiet clock,
|
|
378
|
+
* the sidebar's own word for it, and the agent's last words. Nothing new is derived here and no
|
|
379
|
+
* word is re-invented: `sessionState` owns the vocabulary and `treeShare.endRisk` owns the
|
|
380
|
+
* consequence, and this function only joins them onto one row.
|
|
381
|
+
*/
|
|
382
|
+
function describePeer(s, now) {
|
|
383
|
+
const d = harness.deriveState(s.state, s.lastOutputAt, now || Date.now());
|
|
384
|
+
const hx = transcriptHarness(s);
|
|
385
|
+
const shown = sessionState.presented(d.state, hx && hx.turn);
|
|
386
|
+
const oc = hx && hx.openCall;
|
|
387
|
+
const call = oc && oc.verb
|
|
388
|
+
? { verb: oc.verb, ageMs: oc.sinceTs ? Math.max(0, (now || Date.now()) - Date.parse(oc.sinceTs)) : null }
|
|
389
|
+
: null;
|
|
390
|
+
return {
|
|
391
|
+
id: s.id, ws: s.ws, name: s.name, agent: s.agent, state: s.state, shown,
|
|
392
|
+
quietMs: d.quietMs,
|
|
393
|
+
label: sessionState.label(shown, { quietMs: d.quietMs, call, fmtDur: treeShare.ago }),
|
|
394
|
+
doing: (harnessOf(s) || {}).lastWords || null,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/** The same set as `treeTenants`, each row described. The order is the registry's, unchanged. */
|
|
398
|
+
function treePeersDescribed(tree, excludeId) {
|
|
399
|
+
const now = Date.now();
|
|
400
|
+
return treeTenants(tree, excludeId).map((s) => describePeer(s, now));
|
|
401
|
+
}
|
|
371
402
|
function treeWriter(tree) {
|
|
372
403
|
return treeShare.writerOf(treeClaims, treeOfWsMap(), [...sessions.values()], tree);
|
|
373
404
|
}
|
|
@@ -381,12 +412,16 @@ function announceTree(s) {
|
|
|
381
412
|
const tree = treeForWs(s.ws);
|
|
382
413
|
s.tree = tree || null;
|
|
383
414
|
if (!tree) return [];
|
|
384
|
-
|
|
415
|
+
// DESCRIBED rows, not bare registry objects: the pane line now carries what each co-tenant is
|
|
416
|
+
// doing and what ending it would cost, and it must read from the same join the sidebar does.
|
|
417
|
+
const mates = treePeersDescribed(tree, s.id);
|
|
385
418
|
const holder = treeWriter(tree);
|
|
386
419
|
if (!holder) treeClaims.set(tree, s.id); // vacant → this session declares itself
|
|
387
420
|
if (!mates.length) return []; // alone in the tree: nothing to say
|
|
388
421
|
const held = holder ? (sessions.get(holder) || {}).name : null;
|
|
389
|
-
const line = treeShare.paneAdvisory(mates, {
|
|
422
|
+
const line = treeShare.paneAdvisory(mates, {
|
|
423
|
+
role: holder ? 'reader' : 'writer', writerName: held, writerId: holder || s.id, tree,
|
|
424
|
+
});
|
|
390
425
|
if (line) appendBuf(s, line);
|
|
391
426
|
return mates;
|
|
392
427
|
}
|
|
@@ -395,7 +430,9 @@ function treeNoticeFor(s, mates) {
|
|
|
395
430
|
if (!mates || !mates.length) return null;
|
|
396
431
|
const holder = treeWriter(s.tree);
|
|
397
432
|
const held = holder && holder !== s.id ? (sessions.get(holder) || {}).name : null;
|
|
398
|
-
return treeShare.coTenancyNotice(mates, {
|
|
433
|
+
return treeShare.coTenancyNotice(mates, {
|
|
434
|
+
role: holder === s.id ? 'writer' : 'reader', writerName: held, writerId: holder, tree: s.tree,
|
|
435
|
+
});
|
|
399
436
|
}
|
|
400
437
|
function wsList() {
|
|
401
438
|
return [...workspaces.values()].map((w) => {
|
|
@@ -409,6 +446,12 @@ function wsList() {
|
|
|
409
446
|
// the client renders the first quietly and the second as a fault (R1/R2).
|
|
410
447
|
repo_key: rid.key, repo_name: rid.name, branch: rid.branch,
|
|
411
448
|
branch_error: rid.branchError, branch_code: rid.branchCode,
|
|
449
|
+
// The COLLISION UNIT (workspace-session-concurrency), published so a client can answer "who
|
|
450
|
+
// else is in the tree I am about to start in?" BEFORE it starts anything. Without it the
|
|
451
|
+
// browser could only find co-tenants via a session that already exists there, which is
|
|
452
|
+
// exactly one session too late for a chooser. Null for a folder with no git — no index to
|
|
453
|
+
// collide over, so no group.
|
|
454
|
+
tree: rid.tree || null,
|
|
412
455
|
// Where the tree came from, and the ONE label the repository list can honestly put on a row.
|
|
413
456
|
// `remote` is the raw origin URL (null when there is none) so a caller can derive a richer
|
|
414
457
|
// answer without guessing; `kind` is the surface's own two-value vocabulary — a repository
|
|
@@ -1306,6 +1349,10 @@ function persist(s) {
|
|
|
1306
1349
|
try {
|
|
1307
1350
|
fs.writeFileSync(path.join(SESS_DIR, s.id + '.json'), JSON.stringify(meta));
|
|
1308
1351
|
fs.writeFileSync(path.join(SESS_DIR, s.id + '.scrollback'), s.buf);
|
|
1352
|
+
// What is on disk now matches `s.buf`. The periodic writer reads this to skip a session whose
|
|
1353
|
+
// buffer has not moved — see the interval below. Cleared only on a write that SUCCEEDED, so a
|
|
1354
|
+
// disk blip leaves the session dirty and it is retried on the next tick.
|
|
1355
|
+
s.bufDirty = false;
|
|
1309
1356
|
} catch { /* best-effort; never crash a live session on a disk blip */ }
|
|
1310
1357
|
}
|
|
1311
1358
|
function unpersist(id) {
|
|
@@ -1554,6 +1601,17 @@ function sessionList() {
|
|
|
1554
1601
|
// `failed` does not exist (no observable for it). The client re-derives this from the SAME
|
|
1555
1602
|
// module — see /lib/session-state.js below — so the two can never hold different opinions.
|
|
1556
1603
|
shown: sessionState.presented(d.state, hx && hx.turn), quietMs: d.quietMs,
|
|
1604
|
+
// The rendered word for that state — computed HERE rather than in each client, because it is
|
|
1605
|
+
// the same join (`sessionState.label`) with the same clock, and a client re-deriving it was
|
|
1606
|
+
// already producing a second, slightly different sentence for one session.
|
|
1607
|
+
label: sessionState.label(sessionState.presented(d.state, hx && hx.turn), {
|
|
1608
|
+
quietMs: d.quietMs,
|
|
1609
|
+
call: hx && hx.openCall && hx.openCall.verb
|
|
1610
|
+
? { verb: hx.openCall.verb,
|
|
1611
|
+
ageMs: hx.openCall.sinceTs ? Math.max(0, now - Date.parse(hx.openCall.sinceTs)) : null }
|
|
1612
|
+
: null,
|
|
1613
|
+
fmtDur: treeShare.ago,
|
|
1614
|
+
}),
|
|
1557
1615
|
lastOutputAt: s.lastOutputAt || null,
|
|
1558
1616
|
lastWords: h.lastWords,
|
|
1559
1617
|
// the harness proper — structured record, not rendered bytes. Resolved ONCE above and
|
|
@@ -1641,6 +1699,7 @@ function trimBuf(buf, limit) {
|
|
|
1641
1699
|
function appendBuf(s, data) {
|
|
1642
1700
|
s.buf += data;
|
|
1643
1701
|
if (s.buf.length > SCROLLBACK) s.buf = trimBuf(s.buf, SCROLLBACK);
|
|
1702
|
+
s.bufDirty = true; // the periodic writer skips a session that is not dirty
|
|
1644
1703
|
// Parser 2's whole input: the moment output last arrived. `quiet` is derived from this, never
|
|
1645
1704
|
// from reading the bytes — which is why it is the only one of the five that cannot misread.
|
|
1646
1705
|
s.lastOutputAt = Date.now();
|
|
@@ -1808,6 +1867,7 @@ function seedFromPaneHistory(s, name) {
|
|
|
1808
1867
|
if (!text) return false;
|
|
1809
1868
|
s.buf = trimBuf(text + '\r\n', SCROLLBACK); // 5000 lines of history can overrun the ring
|
|
1810
1869
|
s.parsed = null;
|
|
1870
|
+
s.bufDirty = true; // the ring moved; the file on disk is stale
|
|
1811
1871
|
return true;
|
|
1812
1872
|
}
|
|
1813
1873
|
|
|
@@ -2108,10 +2168,90 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
|
|
|
2108
2168
|
// Both facts travel: `notice` is a single string on the wire and a branch notice and a co-tenancy
|
|
2109
2169
|
// notice can both be true of one spawn. joinNotices keeps them both rather than letting the
|
|
2110
2170
|
// second evict the first exactly when the PM most needs both.
|
|
2111
|
-
const shared = treeNoticeFor(s,
|
|
2171
|
+
const shared = treeNoticeFor(s, treePeersDescribed(s.tree, s.id));
|
|
2112
2172
|
return { ok: true, session: s, notice: treeShare.joinNotices(notice, shared) };
|
|
2113
2173
|
}
|
|
2114
2174
|
|
|
2175
|
+
/* ── workspace-repo-first · start work in one click (PRD R1 · R3) ───────────────────────────────
|
|
2176
|
+
* The composer asked six questions to open a terminal, and the one question the PM actually has —
|
|
2177
|
+
* "which initiative am I working on?" — was not among them. It asked for a BRANCH instead, plus an
|
|
2178
|
+
* isolation checkbox whose default came from liveSessionCount(). These three functions replace that
|
|
2179
|
+
* with: pick a repository, optionally pick an initiative, press one control.
|
|
2180
|
+
*
|
|
2181
|
+
* The policy itself is lib/start-policy.js — pure, so the git-touching paths are testable without
|
|
2182
|
+
* a repository. This layer only supplies it with facts and carries out what it decides.
|
|
2183
|
+
*
|
|
2184
|
+
* R3 is why `agent` appears NOWHERE below except as a value passed through to the spawn: the
|
|
2185
|
+
* destination is derived from the repository and the initiative, so `shell` and `claude` cannot
|
|
2186
|
+
* land in different directories for the same selection. Previously both resolved through
|
|
2187
|
+
* `state.wsId` — whichever folder the client happened to have selected — which is exactly how they
|
|
2188
|
+
* came apart.
|
|
2189
|
+
*/
|
|
2190
|
+
function treesOfRepo(repoKey) {
|
|
2191
|
+
const out = [];
|
|
2192
|
+
for (const w of workspaces.values()) {
|
|
2193
|
+
const ident = repoIdentity(w.root);
|
|
2194
|
+
if (String(ident.key) !== String(repoKey)) continue;
|
|
2195
|
+
out.push({ id: w.id, root: w.root, name: w.name, repo_key: ident.key, branch: ident.branch });
|
|
2196
|
+
}
|
|
2197
|
+
return out;
|
|
2198
|
+
}
|
|
2199
|
+
// The repo key for a request that named a workspace rather than a repository. The client sends
|
|
2200
|
+
// whichever it has; a stale ws id must not silently retarget another repo, so an unknown id is an
|
|
2201
|
+
// error rather than a fallback to "some repository".
|
|
2202
|
+
function repoKeyFor({ repoKey, ws }) {
|
|
2203
|
+
if (repoKey) return String(repoKey);
|
|
2204
|
+
const w = ws && workspaces.get(ws);
|
|
2205
|
+
return w ? String(repoIdentity(w.root).key) : null;
|
|
2206
|
+
}
|
|
2207
|
+
// What the PM is about to get, BEFORE they press anything (R1: the destination is named first).
|
|
2208
|
+
// Read-only: it reads git and resolves the policy, and starts nothing.
|
|
2209
|
+
function startPreview({ initiative, repoKey, ws }) {
|
|
2210
|
+
const key = repoKeyFor({ repoKey, ws });
|
|
2211
|
+
if (!key) return { ok: false, error: 'no repository selected' };
|
|
2212
|
+
const trees = treesOfRepo(key);
|
|
2213
|
+
const main = startPolicy.mainTreeOf(trees);
|
|
2214
|
+
// branchInfo is shelled per call and only when an initiative is named — the no-initiative plan
|
|
2215
|
+
// needs no branch list, and this runs on every selection change.
|
|
2216
|
+
let branches = null;
|
|
2217
|
+
if (initiative && main) {
|
|
2218
|
+
try { const bi = gitInfo.branchInfo(main.root); if (bi.ok) branches = bi.branches; } catch {}
|
|
2219
|
+
}
|
|
2220
|
+
const plan = startPolicy.resolveStart({ initiative, trees, branches });
|
|
2221
|
+
if (!plan.ok) return plan;
|
|
2222
|
+
// The literal directory, so the sentence in the UI is checkable rather than a promise.
|
|
2223
|
+
let dir = null;
|
|
2224
|
+
if (plan.plan === 'new-worktree' && main) { try { dir = gitInfo.worktreePath(main.root, plan.branch); } catch {} }
|
|
2225
|
+
else { const t = trees.find((x) => x.id === plan.wsId); dir = t ? t.root : null; }
|
|
2226
|
+
return { ok: true, plan: plan.plan, why: plan.why, dir, branch: plan.branch, wsId: plan.wsId };
|
|
2227
|
+
}
|
|
2228
|
+
// One click. Resolves the policy, then hands the SAME seam spawnWithBranch already exposes the
|
|
2229
|
+
// branch/isolate decision it made — so the inherited hard floor (a failed git operation spawns
|
|
2230
|
+
// nothing) is enforced in exactly one place and is not re-implemented here.
|
|
2231
|
+
function startWork({ initiative, repoKey, ws, agent, name, profile }) {
|
|
2232
|
+
const key = repoKeyFor({ repoKey, ws });
|
|
2233
|
+
if (!key) return { ok: false, error: 'no repository selected' };
|
|
2234
|
+
const trees = treesOfRepo(key);
|
|
2235
|
+
const main = startPolicy.mainTreeOf(trees);
|
|
2236
|
+
let branches = null;
|
|
2237
|
+
if (initiative && main) {
|
|
2238
|
+
try { const bi = gitInfo.branchInfo(main.root); if (bi.ok) branches = bi.branches; } catch {}
|
|
2239
|
+
}
|
|
2240
|
+
const plan = startPolicy.resolveStart({ initiative, trees, branches });
|
|
2241
|
+
if (!plan.ok) return plan;
|
|
2242
|
+
const r = spawnWithBranch({
|
|
2243
|
+
name: name || initiative || agent || 'shell',
|
|
2244
|
+
agent: agent || 'shell',
|
|
2245
|
+
ws: plan.wsId,
|
|
2246
|
+
branch: plan.branch,
|
|
2247
|
+
createBranch: plan.createBranch,
|
|
2248
|
+
isolate: plan.isolate,
|
|
2249
|
+
profile,
|
|
2250
|
+
});
|
|
2251
|
+
if (!r.ok) return r;
|
|
2252
|
+
return { ok: true, session: r.session, notice: r.notice || plan.why, plan: plan.plan };
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2115
2255
|
// ── Worktree housekeeping: the app that creates the folders shows and clears them ──────────────
|
|
2116
2256
|
// Isolation buys "your other sessions keep running" at the price of a directory per branch. That
|
|
2117
2257
|
// price is only fair if the PM can see the folders and reclaim them without leaving the workspace
|
|
@@ -2255,8 +2395,17 @@ function renameSession(id, name) {
|
|
|
2255
2395
|
return true;
|
|
2256
2396
|
}
|
|
2257
2397
|
|
|
2258
|
-
//
|
|
2259
|
-
|
|
2398
|
+
// Periodic scrollback persistence so a crash loses at most a few seconds of history — but ONLY for
|
|
2399
|
+
// sessions whose buffer actually moved. `persist()` is two SYNCHRONOUS writes, the second of them
|
|
2400
|
+
// the whole ring buffer (up to 200 KB), and this ran unconditionally for every live session every
|
|
2401
|
+
// five seconds. A workspace with six idle panes open was therefore doing over a megabyte of blocking
|
|
2402
|
+
// disk I/O, on a timer, forever — rewriting bytes identical to the ones already on disk, on the same
|
|
2403
|
+
// thread that carries every keystroke to a PTY and every byte back to the browser. That is a stall
|
|
2404
|
+
// the PM feels as the terminal going unresponsive for no reason they can see.
|
|
2405
|
+
//
|
|
2406
|
+
// `bufDirty` is set by appendBuf (the only writer of `s.buf`) and cleared by persist(), so an idle
|
|
2407
|
+
// session now costs one map lookup per tick and a busy one is written exactly as often as before.
|
|
2408
|
+
setInterval(() => { for (const s of sessions.values()) if (s.pty && s.bufDirty) persist(s); }, 5000).unref?.();
|
|
2260
2409
|
|
|
2261
2410
|
// ── File-mode state, per workspace. ───────────────────────────────────────────────────
|
|
2262
2411
|
// A repo that carries PMOS state OWNS its records. The kit (D54) keeps them under `pmos/state/`
|
|
@@ -2995,6 +3144,7 @@ async function handleRequest(req, res) {
|
|
|
2995
3144
|
if (p === '/lib/layout.js') return serveFile(res, path.join(__dirname, 'lib', 'layout.js'));
|
|
2996
3145
|
if (p === '/lib/mobile-nav.js') return serveFile(res, path.join(__dirname, 'lib', 'mobile-nav.js'));
|
|
2997
3146
|
if (p === '/lib/session-state.js') return serveFile(res, path.join(__dirname, 'lib', 'session-state.js'));
|
|
3147
|
+
if (p === '/lib/tree-share.js') return serveFile(res, path.join(__dirname, 'lib', 'tree-share.js'));
|
|
2998
3148
|
// The editor computes its live diff with the SAME module the server saves through, so what the PM
|
|
2999
3149
|
// sees while typing and what gets written cannot disagree. NB: /lib/ is an explicit allowlist, not
|
|
3000
3150
|
// a directory route — a <script src> for a file missing from this list 404s silently, and the
|
|
@@ -3504,6 +3654,16 @@ async function handleRequest(req, res) {
|
|
|
3504
3654
|
if (!w) return json(res, { ok: false, error: 'no workspace' });
|
|
3505
3655
|
return json(res, gitInfo.branchInfo(w.root));
|
|
3506
3656
|
}
|
|
3657
|
+
if (p === '/api/start/preview') {
|
|
3658
|
+
// repo-first R1 — what one click will do, said BEFORE the click. Read-only: it resolves the
|
|
3659
|
+
// policy and names the literal destination directory, and starts nothing. This exists so the
|
|
3660
|
+
// rule is checkable by the PM rather than inferred after a folder appears.
|
|
3661
|
+
return json(res, startPreview({
|
|
3662
|
+
initiative: url.searchParams.get('initiative') || '',
|
|
3663
|
+
repoKey: url.searchParams.get('repo') || '',
|
|
3664
|
+
ws: url.searchParams.get('ws') || '',
|
|
3665
|
+
}));
|
|
3666
|
+
}
|
|
3507
3667
|
if (p === '/api/git/worktrees') {
|
|
3508
3668
|
// Every working tree attached to this workspace's repo, each with the reasons it can or cannot
|
|
3509
3669
|
// be removed. A worktree the workspace made is a folder the PM has to live with, so the app
|
|
@@ -3524,9 +3684,13 @@ async function handleRequest(req, res) {
|
|
|
3524
3684
|
note: 'this workspace is not inside a git working tree — there is no shared index to collide over' });
|
|
3525
3685
|
}
|
|
3526
3686
|
const writer = treeWriter(tree);
|
|
3527
|
-
|
|
3528
|
-
|
|
3687
|
+
// Described, not merely listed (workspace-session-choice): a caller deciding between joining a
|
|
3688
|
+
// running session and starting another needs the state, the word for it, what the agent is
|
|
3689
|
+
// doing and what ending it would cost — the same four facts the chooser and the pane line show.
|
|
3690
|
+
const rows = treePeersDescribed(tree, null).map((s) => ({
|
|
3691
|
+
...s,
|
|
3529
3692
|
role: treeShare.roleOf(s.id, tree, writer),
|
|
3693
|
+
risk: treeShare.endRisk(s.shown, s.quietMs),
|
|
3530
3694
|
}));
|
|
3531
3695
|
return json(res, { ok: true, tree, writer, sessions: rows, advisory: true,
|
|
3532
3696
|
note: writer
|
|
@@ -4159,7 +4323,7 @@ wss.on('connection', (ws) => {
|
|
|
4159
4323
|
ws.on('message', (raw) => {
|
|
4160
4324
|
let m; try { m = JSON.parse(raw); } catch { return; }
|
|
4161
4325
|
// 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)) {
|
|
4326
|
+
if ((m.t === 'new' || m.t === 'start' || m.t === 'respawn') && !rateLimit.take(ws._rlSpawn)) {
|
|
4163
4327
|
ws.send(JSON.stringify({ t: 'error', error: 'rate limited: too many session spawns — slow down' }));
|
|
4164
4328
|
return;
|
|
4165
4329
|
}
|
|
@@ -4183,6 +4347,15 @@ wss.on('connection', (ws) => {
|
|
|
4183
4347
|
// non-repo) — said out loud, never swallowed
|
|
4184
4348
|
if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
|
|
4185
4349
|
ws.send(JSON.stringify({ t: 'created', id: r.session.id }));
|
|
4350
|
+
} else if (m.t === 'start') {
|
|
4351
|
+
// repo-first R1 — the one-click path. It carries an INITIATIVE (or none) and a repository,
|
|
4352
|
+
// never a branch and never an isolate flag: those are the policy's to decide, and a client
|
|
4353
|
+
// that could still send them would be a second place the rule lives.
|
|
4354
|
+
const r = startWork({ initiative: m.initiative, repoKey: m.repoKey, ws: m.ws,
|
|
4355
|
+
agent: m.agent, name: m.name, profile: m.profile });
|
|
4356
|
+
if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^profile /.test(r.error || '') ? '' : 'start: ') + r.error })); return; }
|
|
4357
|
+
if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
|
|
4358
|
+
ws.send(JSON.stringify({ t: 'created', id: r.session.id }));
|
|
4186
4359
|
} else if (m.t === 'respawn') {
|
|
4187
4360
|
const old = sessions.get(m.id); if (!old || old.pty) return;
|
|
4188
4361
|
// `respawn` is UNCHANGED on the wire (profiles R9): the profile is read off the record the
|
|
@@ -4291,6 +4464,10 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
|
|
|
4291
4464
|
// (one repo registered twice is one tree; two worktrees of one repo are two) without driving
|
|
4292
4465
|
// every case through a spawn.
|
|
4293
4466
|
treeForWs, treeTenants, treeWriter, treeClaims,
|
|
4467
|
+
// …and the DESCRIBED form, exported for the same reason: "what is that session doing and what
|
|
4468
|
+
// would ending it cost?" is the half of co-tenancy the PM actually acts on, so the suite asserts
|
|
4469
|
+
// it directly rather than reading it back out of a rendered sentence.
|
|
4470
|
+
describePeer, treePeersDescribed,
|
|
4294
4471
|
// exported so the suite can assert the D08 invariant end-to-end: the harness parsers consume
|
|
4295
4472
|
// scrubbed text, so a secret printed into scrollback must not survive into a rendered value.
|
|
4296
4473
|
scrub, harnessOf,
|