create-pmos 0.17.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +25 -0
- package/workspace/lib/file-mode-parse.js +12 -1
- package/workspace/lib/start-policy.js +118 -0
- package/workspace/lib/tree-share.js +129 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +2 -2
- package/workspace/public/pm/data.js +87 -3
- package/workspace/public/pm/panel-initiatives.js +7 -0
- package/workspace/public/pm/panel-tabs.js +147 -6
- package/workspace/public/pm/state.js +65 -6
- package/workspace/public/pm/tab-term.js +134 -18
- package/workspace/server.js +267 -8
|
@@ -146,7 +146,32 @@ export function closeTab(id) {
|
|
|
146
146
|
/* The data layer's optional session-start hook. In fixture mode nothing is ever started; in real
|
|
147
147
|
* mode the PTY is data.js's to create, and panel 4's part is only to show the tab for it. */
|
|
148
148
|
let dataMod = null;
|
|
149
|
-
|
|
149
|
+
/* The repaint matters: `syncPad` below cannot name a pad before this module exists, so the render
|
|
150
|
+
* that lands it is the render that first loads the PM's staged text. */
|
|
151
|
+
import('./data.js').then((m) => { dataMod = m; setState({}); }).catch(() => { dataMod = null; });
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The next free `agent <n>`, counted against EVERY name the window can see — the server's live
|
|
155
|
+
* sessions, the ones started in this window, and the tabs already open — not just this page's
|
|
156
|
+
* counter.
|
|
157
|
+
*
|
|
158
|
+
* `startN` resets to 1 on every reload, and the name is what the terminal used to join on and what
|
|
159
|
+
* the tab id is built from. So after a reload the first `+ session` proposed `agent 1`, which was
|
|
160
|
+
* already running from before the reload: the click attached to that old session instead of
|
|
161
|
+
* starting a new one, and a second click collided with its own tab id. Counting past what exists
|
|
162
|
+
* is what makes `+ session` mean "a new one" again.
|
|
163
|
+
*/
|
|
164
|
+
function nextSessionName() {
|
|
165
|
+
let max = state.startN - 1;
|
|
166
|
+
const bump = (name) => {
|
|
167
|
+
const m = /^agent (\d+)$/.exec(String(name || ''));
|
|
168
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
169
|
+
};
|
|
170
|
+
for (const s of (state.sessions || [])) bump(s && s.name);
|
|
171
|
+
for (const k in (state.extraSess || {})) for (const s of (state.extraSess[k] || [])) bump(s && s.name);
|
|
172
|
+
for (const t of (state.tabs || [])) if (t && t.kind === 'term') bump(t.sess);
|
|
173
|
+
return { n: max + 1, name: 'agent ' + (max + 1) };
|
|
174
|
+
}
|
|
150
175
|
|
|
151
176
|
/**
|
|
152
177
|
* `+ session` (line 2087): file a new session against the selected initiative and open its tab.
|
|
@@ -156,8 +181,11 @@ import('./data.js').then((m) => { dataMod = m; }).catch(() => { dataMod = null;
|
|
|
156
181
|
export function newSession(slug) {
|
|
157
182
|
const target = slug || selSlug();
|
|
158
183
|
if (!target) return;
|
|
159
|
-
const n =
|
|
160
|
-
|
|
184
|
+
const { n, name } = nextSessionName();
|
|
185
|
+
/* `spawn` is carried on the session and thence onto the tab: it is an INSTRUCTION to start a PTY,
|
|
186
|
+
* and it is the only thing that authorises one. Without it the terminal resolves by name, which
|
|
187
|
+
* is a question about what already exists, not a request for something new. */
|
|
188
|
+
const sess = { name, st: 'working', label: 'working', profile: state.newSessProfile, spawn: true };
|
|
161
189
|
setState((s) => ({
|
|
162
190
|
startN: n + 1,
|
|
163
191
|
extraSess: { ...s.extraSess, [target]: [...(s.extraSess[target] || []), sess] },
|
|
@@ -204,13 +232,118 @@ const panesRow = el('div', { style: 'flex:1;display:flex;min-height:0' });
|
|
|
204
232
|
* One textarea, created once. Its text is written back as a CHILD TEXT NODE as well as a value,
|
|
205
233
|
* because that is what the prototype's runtime does and the comparator reads text nodes — but only
|
|
206
234
|
* when the two differ, so a repaint mid-sentence never moves the PM's caret.
|
|
235
|
+
*
|
|
236
|
+
* ── ONE PAD PER SESSION ─────────────────────────────────────────────────────────────────────
|
|
237
|
+
* The pad stages the next thing to type into a PARTICULAR agent, so it belongs to that agent's
|
|
238
|
+
* session. The four-panel port shipped it as one in-memory string for the whole page, which meant
|
|
239
|
+
* every terminal — in every repository, in every window — showed and overwrote the same buffer.
|
|
240
|
+
*
|
|
241
|
+
* The store is `state.scratchPads`, keyed by:
|
|
242
|
+
* `sess:<server session id>` — a terminal tab bound to a live PTY. The id is the SERVER's, so two
|
|
243
|
+
* sessions both called `agent 1` in two repositories are two pads.
|
|
244
|
+
* `ws:<workspace id>` — everything else (a doc, a task, nothing open): the repository pad.
|
|
245
|
+
* That is exactly the key space `/api/scratchpad` already stores under — the endpoint has been
|
|
246
|
+
* per-session all along, and it is the client half that went missing. `state.scratchText` remains
|
|
247
|
+
* the text ON SCREEN, so the copy button, `⇥ send to terminal` and the fixture render are unchanged.
|
|
248
|
+
*
|
|
249
|
+
* FIXTURE MODE IS UNTOUCHED: no key, no fetch, one pad, the prototype's own placeholder. The offline
|
|
250
|
+
* demo path must make no network call, and the comparator must see the design's string.
|
|
207
251
|
*/
|
|
252
|
+
const SCRATCH_PLACEHOLDER = FIXTURE
|
|
253
|
+
? 'scratchpad — survives tab switches, dies with the page'
|
|
254
|
+
: 'scratchpad — one pad per session, saved on this machine';
|
|
255
|
+
|
|
208
256
|
const scratchTa = el('textarea', {
|
|
209
|
-
placeholder:
|
|
257
|
+
placeholder: SCRATCH_PLACEHOLDER,
|
|
210
258
|
style: 'height:96px;background:oklch(0.11 0.004 228.8);color:#c4ccce;padding:10px 14px;font-family:var(--mono);font-size:12px',
|
|
211
|
-
onInput: (e) =>
|
|
259
|
+
onInput: (e) => onScratchInput(e.target.value),
|
|
212
260
|
});
|
|
213
261
|
|
|
262
|
+
/* ── pad identity, loading and autosave ─────────────────────────────────────────────────────── */
|
|
263
|
+
|
|
264
|
+
const padLoading = new Set(); // keys with a read in flight — one read per key, ever
|
|
265
|
+
let padTimer = null;
|
|
266
|
+
let padPending = null; // {key, text} not yet written
|
|
267
|
+
|
|
268
|
+
/** The pad the ACTIVE tab stages against, or null when it cannot be named yet. */
|
|
269
|
+
function padKey() {
|
|
270
|
+
if (FIXTURE) return null;
|
|
271
|
+
const act = activeTab();
|
|
272
|
+
if (act && act.kind === 'term') {
|
|
273
|
+
const id = (termMod && typeof termMod.sessionIdOf === 'function')
|
|
274
|
+
? termMod.sessionIdOf(act) : (act.sessionId || null);
|
|
275
|
+
// A terminal whose session has not resolved yet has no pad of its own YET. Null holds the
|
|
276
|
+
// drawer on whatever it was showing rather than flashing an empty pad and then SAVING it over
|
|
277
|
+
// the real one the moment the session lands.
|
|
278
|
+
return id ? 'sess:' + id : null;
|
|
279
|
+
}
|
|
280
|
+
return state.wsId ? 'ws:' + state.wsId : null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Write the pending pad now. Called before leaving a pad, and when the page goes away. */
|
|
284
|
+
function flushPad() {
|
|
285
|
+
if (padTimer) { clearTimeout(padTimer); padTimer = null; }
|
|
286
|
+
if (!padPending) return;
|
|
287
|
+
const { key, text } = padPending;
|
|
288
|
+
padPending = null;
|
|
289
|
+
if (dataMod && typeof dataMod.saveScratchpad === 'function') {
|
|
290
|
+
try { dataMod.saveScratchpad(key, text); } catch { /* the text stays in state either way */ }
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function schedulePadSave(key, text) {
|
|
295
|
+
padPending = { key, text };
|
|
296
|
+
if (padTimer) clearTimeout(padTimer);
|
|
297
|
+
padTimer = setTimeout(flushPad, 500); // one write per pause, not one per keystroke
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function onScratchInput(text) {
|
|
301
|
+
if (FIXTURE) { setState({ scratchText: text }); return; }
|
|
302
|
+
const key = state.scratchKey;
|
|
303
|
+
setState((s) => ({
|
|
304
|
+
scratchText: text,
|
|
305
|
+
scratchPads: key ? { ...s.scratchPads, [key]: text } : s.scratchPads,
|
|
306
|
+
}));
|
|
307
|
+
if (key) schedulePadSave(key, text);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Bring the drawer onto the pad the active tab belongs to, reading it from the server the first
|
|
312
|
+
* time that pad is seen. Called at the top of every render; a no-op unless the pad CHANGED.
|
|
313
|
+
*/
|
|
314
|
+
function syncPad() {
|
|
315
|
+
if (FIXTURE || !dataMod) return; // no data layer yet — retried on the next render
|
|
316
|
+
const key = padKey();
|
|
317
|
+
if (!key || key === state.scratchKey) return;
|
|
318
|
+
flushPad(); // the pad being left is written before it is left
|
|
319
|
+
if (Object.prototype.hasOwnProperty.call(state.scratchPads, key)) {
|
|
320
|
+
setState({ scratchKey: key, scratchText: state.scratchPads[key] });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
setState({ scratchKey: key, scratchText: '' });
|
|
324
|
+
if (padLoading.has(key)) return;
|
|
325
|
+
padLoading.add(key);
|
|
326
|
+
const landed = (text) => {
|
|
327
|
+
padLoading.delete(key);
|
|
328
|
+
setState((s) => ({
|
|
329
|
+
scratchPads: { ...s.scratchPads, [key]: text },
|
|
330
|
+
// …and onto the screen only if this is still the pad on screen. A PM who switched tabs while
|
|
331
|
+
// the read was in flight must not have the arriving text land in the pad they moved to.
|
|
332
|
+
...(s.scratchKey === key ? { scratchText: text } : {}),
|
|
333
|
+
}));
|
|
334
|
+
};
|
|
335
|
+
try { dataMod.loadScratchpad(key).then(landed, () => landed('')); }
|
|
336
|
+
catch { padLoading.delete(key); }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/* A pad edited in the last half-second is still worth keeping when the tab is closed or the machine
|
|
340
|
+
* sleeps. `pagehide` fires where `beforeunload` is unreliable (bfcache, mobile), and both are cheap. */
|
|
341
|
+
if (typeof window !== 'undefined' && !FIXTURE) {
|
|
342
|
+
window.addEventListener('pagehide', flushPad);
|
|
343
|
+
window.addEventListener('beforeunload', flushPad);
|
|
344
|
+
document.addEventListener('visibilitychange', () => { if (document.hidden) flushPad(); });
|
|
345
|
+
}
|
|
346
|
+
|
|
214
347
|
const scratchCopyLabel = iv('⧉ copy');
|
|
215
348
|
const scratchCopyBtn = el('button', {
|
|
216
349
|
onClick: () => {
|
|
@@ -396,7 +529,13 @@ function tabNode(t, act, splitTab) {
|
|
|
396
529
|
const bc = selected ? FX.TAB_COLORS.activeBorder : FX.TAB_COLORS.idleBorder;
|
|
397
530
|
const color = act && act.id === t.id ? FX.TAB_COLORS.activeText : FX.TAB_COLORS.idleText;
|
|
398
531
|
const dotColor = FX.TAB_COLORS.dot[t.kind] || FX.TAB_COLORS.dot.doc;
|
|
399
|
-
|
|
532
|
+
/* A terminal's sub-line names the SESSION IT IS ON, not the word "session". After a reload the PM
|
|
533
|
+
* could not tell which session a re-opened tab had landed on, and the answer was on screen
|
|
534
|
+
* nowhere; the server's id is the one identifier that does not repeat across repositories. The
|
|
535
|
+
* fixture render keeps the design's literal — the comparator reads this text node. */
|
|
536
|
+
const meta = t.kind === 'term'
|
|
537
|
+
? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
|
|
538
|
+
: (t.kind === 'task' ? 'task' : t.role);
|
|
400
539
|
|
|
401
540
|
return el('div', {
|
|
402
541
|
style: 'flex:none;display:flex;align-items:center;gap:8px;padding:0 10px;background:' + bg
|
|
@@ -433,6 +572,8 @@ function tabNode(t, act, splitTab) {
|
|
|
433
572
|
* bar. The panes row renders even when nothing is open, exactly as the design does.
|
|
434
573
|
*/
|
|
435
574
|
export function renderTabs(host) {
|
|
575
|
+
// Which pad the drawer is on is decided from the active tab, before anything is painted from it.
|
|
576
|
+
syncPad();
|
|
436
577
|
const visTabs = visibleTabs();
|
|
437
578
|
const act = visTabs.find((t) => t.id === state.active) || null;
|
|
438
579
|
const splitTab = state.splitId
|
|
@@ -41,15 +41,37 @@ function initial() {
|
|
|
41
41
|
col0: true, col1: true, col2: true, scratchOpen: false, scratchText: '', copied: false,
|
|
42
42
|
flowsMap: null, schedList: null, agentList: null, memory: null,
|
|
43
43
|
savedDocs: {}, scratchCopied: false,
|
|
44
|
+
/* The scratchpad is ONE PAD PER SESSION, not one per page (see panel-tabs.js). `scratchPads`
|
|
45
|
+
* holds every pad this window has loaded, keyed by the pad key the panel derives — `sess:<id>`
|
|
46
|
+
* for a terminal bound to a PTY, `ws:<id>` for the repository pad a non-terminal tab stages
|
|
47
|
+
* against. `scratchText` remains the text ON SCREEN, so every existing reader (the copy button,
|
|
48
|
+
* ⇥ send to terminal, the fixture render) is unchanged. `scratchKey` is which pad that is. */
|
|
49
|
+
scratchPads: {}, scratchKey: null,
|
|
44
50
|
};
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
/* ── persistence ──────────────────────────────────────────────────────────────────────────────
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
54
|
+
* The PM's *arrangement*, and — since the reload defect — WHICH VIEWS WERE OPEN. Never content,
|
|
55
|
+
* never a pad's text, never a live PTY: a tab is a view of a session the server owns, and what is
|
|
56
|
+
* stored here is the identity of the view, not the thing it looks at.
|
|
57
|
+
*
|
|
58
|
+
* Each key carries its own validator, and optionally a `pack` that decides what is WRITTEN. A
|
|
59
|
+
* stored value that fails its validator is DROPPED and the rest of the record is still applied:
|
|
60
|
+
* "layout amnesia" in the PRD's failure-mode list means a stale record from an older build
|
|
61
|
+
* throwing, or silently wiping a layout the PM is working in. An unknown key is simply not read,
|
|
62
|
+
* so an older or newer schema costs nothing.
|
|
63
|
+
*
|
|
64
|
+
* ── Why `tabs` is here, and why it was not ──────────────────────────────────────────────────
|
|
65
|
+
* It read "never tabs" because a tab was thought to be a live thing. It is not: the PTY lives in
|
|
66
|
+
* the server and survives both detach and a server restart (R7), so a window that forgot its tabs
|
|
67
|
+
* was not protecting anything — it was throwing away the only record of which sessions the PM had
|
|
68
|
+
* open. The PM then had to re-find them by hand, one click at a time, with no way to tell which
|
|
69
|
+
* session a re-opened tab had landed on. Restoring the views is what makes the surviving sessions
|
|
70
|
+
* visible again on reload.
|
|
71
|
+
*
|
|
72
|
+
* The stored tab is PACKED, not the live record: a whitelist of scalar fields, capped at 40 tabs.
|
|
73
|
+
* A doc tab's edited buffer, a task's derived prose, and anything a future field might carry stay
|
|
74
|
+
* out of localStorage by construction rather than by remembering to exclude them.
|
|
53
75
|
*/
|
|
54
76
|
const isBool = (v) => typeof v === 'boolean';
|
|
55
77
|
const isSlug = (v) => v === null || typeof v === 'string';
|
|
@@ -57,6 +79,28 @@ const isFlagMap = (v) =>
|
|
|
57
79
|
!!v && typeof v === 'object' && !Array.isArray(v) &&
|
|
58
80
|
Object.keys(v).every((k) => typeof k === 'string' && typeof v[k] === 'boolean');
|
|
59
81
|
|
|
82
|
+
/* The only tab fields that are written. `sessionId` is the load-bearing one: it is the SERVER's own
|
|
83
|
+
* id, so a restored terminal re-attaches to exactly the PTY it was showing rather than re-running
|
|
84
|
+
* the name join — which is what made a reloaded tab land on a session the PM could not identify. */
|
|
85
|
+
const TAB_FIELDS = ['id', 'kind', 'title', 'slug', 'repo', 'sess', 'sessionId', 'state', 'profile',
|
|
86
|
+
'path', 'role', 'phase', 'status'];
|
|
87
|
+
const MAX_TABS = 40;
|
|
88
|
+
|
|
89
|
+
function packTab(t) {
|
|
90
|
+
if (!t || typeof t !== 'object' || typeof t.id !== 'string' || typeof t.kind !== 'string') return null;
|
|
91
|
+
const out = {};
|
|
92
|
+
for (const k of TAB_FIELDS) {
|
|
93
|
+
const v = t[k];
|
|
94
|
+
if (v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') out[k] = v;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const isTabList = (v) =>
|
|
100
|
+
Array.isArray(v) && v.length <= MAX_TABS &&
|
|
101
|
+
v.every((t) => t && typeof t === 'object' && !Array.isArray(t)
|
|
102
|
+
&& typeof t.id === 'string' && typeof t.kind === 'string');
|
|
103
|
+
|
|
60
104
|
const PERSIST = {
|
|
61
105
|
theme: (v) => v === 'dark' || v === 'light',
|
|
62
106
|
col0: isBool, col1: isBool, col2: isBool,
|
|
@@ -65,6 +109,15 @@ const PERSIST = {
|
|
|
65
109
|
sel: isSlug,
|
|
66
110
|
collapsedGroups: isFlagMap,
|
|
67
111
|
collapsedPhases: isFlagMap,
|
|
112
|
+
tabs: isTabList,
|
|
113
|
+
active: isSlug,
|
|
114
|
+
splitId: isSlug,
|
|
115
|
+
scratchOpen: isBool,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** What is WRITTEN for a key, when it is not the live value verbatim. */
|
|
119
|
+
const PACK = {
|
|
120
|
+
tabs: (v) => (Array.isArray(v) ? v.slice(0, MAX_TABS).map(packTab).filter(Boolean) : []),
|
|
68
121
|
};
|
|
69
122
|
|
|
70
123
|
/** The exact set of keys written to localStorage, in write order. */
|
|
@@ -84,13 +137,19 @@ function restore(target) {
|
|
|
84
137
|
try { ok = PERSIST[k](saved[k]); } catch { ok = false; }
|
|
85
138
|
if (ok) target[k] = saved[k];
|
|
86
139
|
}
|
|
140
|
+
/* `active` and `splitId` name tabs. A record whose tab list was dropped (or that names a tab the
|
|
141
|
+
* list no longer holds) must not restore a pointer into nothing — panel 4 would render an empty
|
|
142
|
+
* pane and the PM would see a window with no tab selected and no way to guess why. */
|
|
143
|
+
const ids = new Set((target.tabs || []).map((t) => t.id));
|
|
144
|
+
if (!ids.has(target.active)) target.active = target.tabs.length ? target.tabs[target.tabs.length - 1].id : null;
|
|
145
|
+
if (!ids.has(target.splitId)) target.splitId = null;
|
|
87
146
|
return target;
|
|
88
147
|
}
|
|
89
148
|
|
|
90
149
|
function persist() {
|
|
91
150
|
if (FIXTURE) return;
|
|
92
151
|
const out = {};
|
|
93
|
-
for (const k of persistKeys) out[k] = state[k];
|
|
152
|
+
for (const k of persistKeys) out[k] = PACK[k] ? PACK[k](state[k]) : state[k];
|
|
94
153
|
try { localStorage.setItem(STORE_KEY, JSON.stringify(out)); } catch { /* quota / private mode */ }
|
|
95
154
|
}
|
|
96
155
|
|
|
@@ -149,6 +149,7 @@ function connect() {
|
|
|
149
149
|
for (const rec of panes.values()) {
|
|
150
150
|
if (rec.kind !== 'real') continue;
|
|
151
151
|
rec.attachedId = null;
|
|
152
|
+
rec.sentCols = null; rec.sentRows = null; // the next connection has stated no grid yet
|
|
152
153
|
note(rec, 'pmos: connection to the workspace server lost — retrying');
|
|
153
154
|
}
|
|
154
155
|
clearTimeout(SOCK.timer);
|
|
@@ -181,6 +182,17 @@ function handle(m) {
|
|
|
181
182
|
rec.noted = null;
|
|
182
183
|
try { rec.term.reset(); } catch { /* a disposed terminal */ }
|
|
183
184
|
if (m.buf) { try { rec.term.write(m.buf); } catch { /* ignore */ } }
|
|
185
|
+
/* THE PTY DOES NOT KNOW HOW WIDE THIS BROWSER IS. `attached` carries the SERVER's cols/rows —
|
|
186
|
+
* 80×24 for a session nobody ever resized — and the server never adopts the client's grid on
|
|
187
|
+
* attach. The only thing that ever told it was xterm's `onResize`, which fires when a fit
|
|
188
|
+
* CHANGES the grid; the first fit runs while the socket is still connecting, so `sessionId` was
|
|
189
|
+
* null and the message was dropped, and every later fit measured the same box and changed
|
|
190
|
+
* nothing, so it never fired again. Result: a 200-column window rendering a shell that believes
|
|
191
|
+
* it has 80, wrapping a third of the way across — "the terminal only shows a small portion".
|
|
192
|
+
* The grid is therefore PUSHED here, unconditionally, the moment there is a session to push it
|
|
193
|
+
* to. `sent*` is cleared first so a re-attach after a reconnect re-states it. */
|
|
194
|
+
rec.sentCols = null; rec.sentRows = null;
|
|
195
|
+
syncSize(rec);
|
|
184
196
|
refitVisible();
|
|
185
197
|
return;
|
|
186
198
|
}
|
|
@@ -199,6 +211,8 @@ function handle(m) {
|
|
|
199
211
|
const rec = panes.get(id);
|
|
200
212
|
if (!rec) { send({ t: 'detach', id: m.id }); return; } // the tab closed while we waited
|
|
201
213
|
rec.sessionId = m.id;
|
|
214
|
+
rec.sentCols = null; rec.sentRows = null;
|
|
215
|
+
rememberSession(rec.tab, m.id);
|
|
202
216
|
attach(rec);
|
|
203
217
|
return;
|
|
204
218
|
}
|
|
@@ -218,22 +232,38 @@ const recFor = (sessionId) => {
|
|
|
218
232
|
};
|
|
219
233
|
|
|
220
234
|
/* ── binding a tab to a real session ───────────────────────────────────────────────────────────
|
|
221
|
-
*
|
|
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);
|
|
@@ -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,17 @@ export function sendToTerm(tab, data) {
|
|
|
534
639
|
return send({ t: 'input', id: rec.sessionId, data });
|
|
535
640
|
}
|
|
536
641
|
|
|
642
|
+
/**
|
|
643
|
+
* The SERVER's session id this tab is bound to, or null. The scratchpad reads it: a pad belongs to
|
|
644
|
+
* the session, and the session's identity is this id — not the tab id (which survives a session
|
|
645
|
+
* being replaced) and not the session name (which repeats across repositories).
|
|
646
|
+
*/
|
|
647
|
+
export function sessionIdOf(tab) {
|
|
648
|
+
const rec = panes.get(tab && (tab.id || tab));
|
|
649
|
+
if (rec && rec.kind === 'real' && rec.sessionId) return rec.sessionId;
|
|
650
|
+
return (tab && tab.sessionId) || null;
|
|
651
|
+
}
|
|
652
|
+
|
|
537
653
|
/** Focus the terminal of a tab, if it is live. */
|
|
538
654
|
export function focusTerm(tab) {
|
|
539
655
|
const rec = panes.get(tab && (tab.id || tab));
|