create-pmos 0.25.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.
@@ -5,25 +5,39 @@
5
5
  // records: *which of these am I currently working on*, and *which one has an active session*. Both
6
6
  // were computable from state the browser already held, and nothing put them together.
7
7
  //
8
- // ── THE JOIN, AND WHY IT IS ONLY EVER TWO HOPS ───────────────────────────────────────────────
9
- // A session carries NO initiative id. It never has. The only thing tying a PTY to an initiative is
10
- // the branch convention — a working tree on `initiative/<id>` — and that is exactly the join
11
- // lib/four-panel.js `sessionsFor()` already makes on the server, per initiative, inside
12
- // /api/initiative:
8
+ // ── THE JOIN ─────────────────────────────────────────────────────────────────────────────────
9
+ // This module shipped joining on ONE signal, because one was all a session carried:
13
10
  //
14
11
  // session ──s.ws──▶ workspace ──w.branch──▶ "initiative/<id>" ──▶ the initiative
15
12
  //
16
- // This module is that same join, made once over ALL sessions instead of once per initiative, so the
17
- // board can colour 178 rows without 178 requests. It is deliberately NOT a second opinion: it takes
18
- // the branch strings the server derived (`git rev-parse --abbrev-ref`, published on /api/workspaces)
19
- // and the session rows the server published, and it runs no git and guesses at nothing.
13
+ // That is the join lib/four-panel.js `sessionsFor()` makes per initiative on the server, made once
14
+ // over ALL sessions instead, so the board can colour 178 rows without 178 requests.
15
+ //
16
+ // It was too narrow, and the proof was this repository's own history: the initiative that shipped
17
+ // that board was built on a `claude/*` branch, so the feature could not see the session that wrote
18
+ // it. The convention holds in one of four ordinary situations — the PM works on `main`, the harness
19
+ // opens sessions on `claude/*`, plenty of repos have no git at all, and only a worktree made by
20
+ // `+ session`'s branch control is actually named after its initiative.
21
+ //
22
+ // So a session now RECORDS what it is for (workspace-session-attribution), and attribution runs in
23
+ // precedence order — recorded, then branch, then unattributed — with the answering path carried so
24
+ // an inference is never mistaken for a record. See `attributeSessions`.
25
+ //
26
+ // What is unchanged: this module runs no git and guesses at nothing. It reads the branch strings the
27
+ // server derived (`git rev-parse --abbrev-ref`, published on /api/workspaces) and the session rows
28
+ // the server published, and nothing else.
20
29
  //
21
30
  // ── WHAT IT REFUSES TO DO ────────────────────────────────────────────────────────────────────
22
- // Every input that does not resolve contributes NOTHING. A session whose workspace is unknown, a
23
- // workspace with no branch, a branch that is not `initiative/…`, a branch that is the literal
24
- // `initiative/` with no id — each is dropped in silence rather than bucketed somewhere plausible.
25
- // A marker on the wrong initiative is worse than no marker: the PM would stop trusting the column,
26
- // and an untrusted signal costs more than an absent one.
31
+ // Every input that does not resolve contributes NOTHING TO AN INITIATIVE. A session whose workspace
32
+ // is unknown, a workspace with no branch, a branch that is not `initiative/…`, a branch that is the
33
+ // literal `initiative/` with no id — none of them lands on a row. A marker on the wrong initiative
34
+ // is worse than no marker: the PM would stop trusting the column, and an untrusted signal costs more
35
+ // than an absent one.
36
+ //
37
+ // It is NOT dropped, though — that was the other half of the same defect. An unresolvable live
38
+ // session is returned as `unattributed`, because a running agent that appears nowhere is invisible
39
+ // on the one surface whose job is to say what is running. Refusing to guess and refusing to mention
40
+ // are different things, and only the first is honesty.
27
41
  //
28
42
  // `exited` is not live. A session that has ended is a corpse, not a claim on anyone's attention, and
29
43
  // it is filtered before the loudest-state fold rather than ranked at the bottom of it.
@@ -92,18 +106,63 @@
92
106
  * never containing an initiative the join could not actually reach.
93
107
  */
94
108
  function sessionsByInitiative(sessions, branchOf) {
95
- const out = Object.create(null);
96
- if (typeof branchOf !== 'function') return out;
109
+ return attributeSessions(sessions, branchOf).byInitiative;
110
+ }
111
+
112
+ /**
113
+ * ATTRIBUTION, in precedence order, with the answering path carried.
114
+ *
115
+ * The first cut of this module joined on the branch alone, because that was the only signal a
116
+ * session carried. It is no longer: `sessionList()` publishes `initiative` + `attributed_by` for
117
+ * a session PMOS was told about at spawn, or that the PM bound afterwards. So the order is:
118
+ *
119
+ * 1. `s.initiative` RECORDED — PMOS was told, or the PM said so. First-hand.
120
+ * 2. `initiative/<id>` BRANCH — an inference from a naming convention. Kept, because every
121
+ * session running before the field existed has only this; labelled,
122
+ * because an inference must never be mistaken for a record.
123
+ * 3. neither UNATTRIBUTED — reported as such, never bucketed or guessed.
124
+ *
125
+ * Why the branch survives at all, since a recorded field is strictly better: dropping it would
126
+ * un-attribute every session on a real `initiative/<id>` worktree the moment this shipped, which
127
+ * is a regression the PM would experience as the feature losing information it used to have. It
128
+ * is a fallback with a name, not a second opinion competing with the record — `via` says which
129
+ * answered, and the two never merge.
130
+ *
131
+ * Returns `{ byInitiative: {id: [session…]}, via: {sessionId: 'recorded'|'branch'}, unattributed:
132
+ * [session…] }`. Insertion-ordered, no empty arrays, and nothing the join could not actually reach.
133
+ */
134
+ function attributeSessions(sessions, branchOf) {
135
+ const byInitiative = Object.create(null);
136
+ const via = Object.create(null);
137
+ const unattributed = [];
138
+ const lookup = typeof branchOf === 'function' ? branchOf : null;
97
139
  for (const s of Array.isArray(sessions) ? sessions : []) {
98
- if (!isLive(s) || !s.ws) continue;
99
- let id = null;
100
- // A branch lookup that THROWS must not take the board down with it. The map is the data
101
- // layer's and this module does not own its failure modes.
102
- try { id = initiativeOfBranch(branchOf(s.ws)); } catch { id = null; }
103
- if (!id) continue;
104
- (out[id] || (out[id] = [])).push(s);
140
+ if (!isLive(s)) continue;
141
+ // 1 — the record. Independent of `ws`: a session in a folder with no git, or one whose
142
+ // workspace the client has not loaded, is still attributable if PMOS was told what it is for.
143
+ let id = validId(s.initiative);
144
+ let how = id ? 'recorded' : null;
145
+ // 2 — the branch, only where the record is silent. A lookup that THROWS must not take the
146
+ // board down with it: the map is the data layer's and this module does not own its failures.
147
+ if (!id && s.ws && lookup) {
148
+ try { id = initiativeOfBranch(lookup(s.ws)); } catch { id = null; }
149
+ if (id) how = 'branch';
150
+ }
151
+ if (!id) { unattributed.push(s); continue; }
152
+ (byInitiative[id] || (byInitiative[id] = [])).push(s);
153
+ if (s.id) via[s.id] = how;
105
154
  }
106
- return out;
155
+ return { byInitiative, via, unattributed };
156
+ }
157
+
158
+ /**
159
+ * Is this a usable initiative id? Deliberately permissive about SHAPE and strict about type —
160
+ * the server has already validated anything it recorded (`fourPanel.validId`), and re-deriving
161
+ * that regex here would put a second opinion in the tree about what an id is. What this rejects
162
+ * is the shapes that cannot be a key at all.
163
+ */
164
+ function validId(v) {
165
+ return typeof v === 'string' && v !== '' && v.indexOf('/') === -1 ? v : null;
107
166
  }
108
167
 
109
168
  /**
@@ -164,16 +223,26 @@
164
223
  * Each entry is `{slug, reason, state, sessions}`. `reason` is one of REASONS and is never
165
224
  * collapsed away — the surface says which of the three it is.
166
225
  */
167
- function workingSet({ live, trees, tabs, onBoard } = {}) {
226
+ function workingSet({ live, trees, tabs, onBoard, via } = {}) {
168
227
  const L = live && typeof live === 'object' ? live : {};
169
228
  const T = trees instanceof Set ? trees : new Set();
170
229
  const B = tabs instanceof Set ? tabs : new Set();
171
230
  const keep = typeof onBoard === 'function' ? onBoard : () => false;
231
+ const how = via && typeof via === 'object' ? via : {};
172
232
 
173
233
  const seen = new Map();
174
234
  const add = (slug, reason, sessions) => {
175
235
  if (!slug || seen.has(slug) || !keep(slug)) return;
176
- seen.set(slug, { slug, reason, state: reason === 'session' ? stateOf(sessions) : null, sessions: sessions || [] });
236
+ // WHICH PATH ANSWERED, carried onto the row so the reason line can say it. An initiative whose
237
+ // sessions were all resolved from a branch name is a weaker claim than one PMOS was told
238
+ // about, and the PM is entitled to know which they are looking at.
239
+ const rows = sessions || [];
240
+ const paths = rows.map((s) => (s && s.id ? how[s.id] : null)).filter(Boolean);
241
+ seen.set(slug, {
242
+ slug, reason, state: reason === 'session' ? stateOf(sessions) : null, sessions: rows,
243
+ via: paths.length && paths.every((v) => v === 'branch') ? 'branch'
244
+ : paths.includes('recorded') ? 'recorded' : null,
245
+ });
177
246
  };
178
247
  for (const slug of Object.keys(L)) add(slug, 'session', L[slug]);
179
248
  for (const slug of T) add(slug, 'tree', null);
@@ -211,14 +280,41 @@
211
280
  /** One line naming why an initiative is in the working set. Stated, never implied. */
212
281
  function reasonTitle(entry) {
213
282
  if (!entry) return '';
214
- if (entry.reason === 'session') return sessionTitle(entry.slug, entry.sessions);
283
+ if (entry.reason === 'session') {
284
+ const t = sessionTitle(entry.slug, entry.sessions);
285
+ // A branch-only attribution says so. It is a reading of a folder's branch name, not something
286
+ // PMOS was told, and the difference is the whole point of the recorded field.
287
+ return entry.via === 'branch'
288
+ ? t + '\nattributed by the ' + PREFIX + entry.slug + ' branch — no session recorded this initiative'
289
+ : t;
290
+ }
215
291
  if (entry.reason === 'tree') return 'a registered working tree is on ' + PREFIX + entry.slug + ' — nothing is running in it';
216
292
  return 'you have a tab open on this initiative in this window';
217
293
  }
218
294
 
295
+ /**
296
+ * The tooltip for the unattributed row: every live session PMOS cannot place, named individually.
297
+ *
298
+ * This row exists because the alternative is worse. A session the join cannot reach has nowhere to
299
+ * go on an initiative-keyed board, and dropping it would mean a running agent is invisible on the
300
+ * surface whose job is to say what is running. It claims NO initiative — the wording has to leave
301
+ * the PM in no doubt that PMOS does not know, rather than implying a default.
302
+ */
303
+ function unattributedTitle(sessions) {
304
+ const rows = (sessions || []).filter(isLive);
305
+ if (!rows.length) return '';
306
+ const n = rows.length;
307
+ return [
308
+ n + ' live session' + (n === 1 ? '' : 's') + ' PMOS cannot place on an initiative',
309
+ ...rows.map((s) => '· ' + (s.name || s.id || 'session') + ' — ' + (s.label || s.shown || 'unknown')),
310
+ 'no initiative was recorded, and no working tree is on an ' + PREFIX + '… branch',
311
+ ].join('\n');
312
+ }
313
+
219
314
  return {
220
315
  PREFIX, REASONS, REASON_RANK,
221
- initiativeOfBranch, isLive, sessionsByInitiative, stateOf,
316
+ initiativeOfBranch, isLive, sessionsByInitiative, attributeSessions, stateOf,
222
317
  initiativesWithTree, initiativesWithTab, workingSet, sessionTitle, reasonTitle,
318
+ unattributedTitle,
223
319
  };
224
320
  });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.25.0",
9
+ "version": "0.26.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -10,7 +10,7 @@
10
10
  "scripts": {
11
11
  "start": "node server.js",
12
12
  "test": "node test.js && npm run test:modules",
13
- "test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node no-network.test.js && node fixture-parity.test.mjs && node start-policy.test.js",
13
+ "test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node no-network.test.js && node fixture-parity.test.mjs && node start-policy.test.js && node state-isolation.test.js",
14
14
  "test:ui": "node ui-test.js",
15
15
  "test:parity": "node design-parity.js --states",
16
16
  "test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
@@ -795,6 +795,11 @@ function schedulePoll() {
795
795
  export async function loadRepos() {
796
796
  if (FIXTURE) return [];
797
797
  const out = await getJson('/api/workspaces');
798
+ // The server's one answer to "is the runtime state settled?", published straight onto state so
799
+ // panel 1 can say so. Under an unmigrated conflict the lists below are short by everything the
800
+ // per-user state directory does not hold, and a list that is short without saying so is the
801
+ // silent failure this key exists to end. `{ok:true}` publishes too — the banner reads `ok`.
802
+ const health = out && out.state_health ? out.state_health : null;
798
803
  const rows = foldWorkspaces(out && out.workspaces);
799
804
  productOrder = rows.map((r) => r.name);
800
805
  for (const r of rows) products.set(r.name, Object.assign(products.get(r.name) || {}, r));
@@ -808,6 +813,7 @@ export async function loadRepos() {
808
813
  // actually on screen. A restored selection from the PM's last session always wins.
809
814
  const sel = state.sel || (p ? firstSlugOf(p.name) : null);
810
815
  setState({
816
+ stateHealth: health,
811
817
  wsId: p ? p.ws : null,
812
818
  // NOT initialised to a value here. What is resolvable is the server's derivation from the PM's
813
819
  // agent profiles, it arrives with the first `/api/initiative` payload, and seeding a `[]` in the
@@ -942,6 +948,63 @@ export async function listFolder(dir) {
942
948
  return getJson('/api/fs/list', dir ? { path: dir } : null);
943
949
  }
944
950
 
951
+ /* ── what the session pre-flight asks the server ───────────────────────────────────────────────
952
+ * Four read-only endpoints that have been served all along with no client reading them. They are
953
+ * the questions the retired composer asked before a session started, and losing the client half is
954
+ * what left `+ session` spawning on defaults nobody chose.
955
+ *
956
+ * Every one declines in FIXTURE mode, and that is load-bearing rather than tidy: the fixture render
957
+ * must make no network call at all — it is what lets no-network.test.js and the design comparator
958
+ * trust a fixture run. The form degrades to "no repository was read" and the fixture's own profile
959
+ * list, which is a true statement about fixture mode rather than a blank.
960
+ */
961
+
962
+ /**
963
+ * Every branch of a workspace's repository, plus which one is current and which are already
964
+ * checked out in another working tree. Returns the server's `gitInfo.branchInfo` verbatim, so a
965
+ * non-repo answers `{ok:false, code, error}` and NEVER a fabricated branch — the difference
966
+ * between "this folder has no git" and "git failed" is the picker's to show, so it must survive
967
+ * the trip.
968
+ */
969
+ export async function loadBranches(ws) {
970
+ if (FIXTURE) return { ok: false, error: 'fixture mode reads no repository' };
971
+ if (!ws) return { ok: false, error: 'no repository selected' };
972
+ return getJson('/api/git/branches', { ws });
973
+ }
974
+
975
+ /**
976
+ * The Claude profile descriptors, as `{ok, absent, dir, profiles, broken, prefs, error}`.
977
+ *
978
+ * All six fields matter to the caller and none may be flattened here: `absent` ("the directory does
979
+ * not exist") is a different sentence from a successful read that found none; `broken` names a
980
+ * descriptor that must be shown and NOT offered, while every good one beside it is still offered;
981
+ * and `prefs` is what preselects a chip, replacing a hardcoded 'claude-work' that named a file this
982
+ * client had never opened.
983
+ *
984
+ * `env` values are absent by construction on the server side (profileWire) — D08's discipline
985
+ * covers anything the browser receives, and an overlay is where a credential lives.
986
+ */
987
+ export async function loadProfiles() {
988
+ if (FIXTURE) return { ok: false, error: 'fixture mode reads no profiles' };
989
+ return getJson('/api/profiles');
990
+ }
991
+
992
+ /** Remember the profile this agent should preselect next time. The server refuses a preference for
993
+ * a descriptor it cannot read, so a stored id always resolves to a chip that is on screen. */
994
+ export async function preferProfile(agent, profile) {
995
+ if (FIXTURE) return { ok: false, error: 'fixture mode remembers nothing' };
996
+ return postJson('/api/profiles/prefer', { agent, profile });
997
+ }
998
+
999
+ /** Every working tree of this workspace's repo. The pre-flight reads it for ONE purpose: a branch
1000
+ * checked out elsewhere is a branch git will refuse, and the PM should read that in the picker
1001
+ * rather than as an error after the click. */
1002
+ export async function loadWorktrees(ws) {
1003
+ if (FIXTURE) return { ok: false, error: 'fixture mode reads no repository' };
1004
+ if (!ws) return { ok: false, error: 'no repository selected' };
1005
+ return getJson('/api/git/worktrees', { ws });
1006
+ }
1007
+
945
1008
  /**
946
1009
  * panel-tabs.js — `startSession(slug, sess)`. Deliberately NOT a spawn.
947
1010
  *
@@ -957,6 +1020,29 @@ export function startSession(slug, sess) {
957
1020
  setTimeout(pollSessions, 800);
958
1021
  }
959
1022
 
1023
+ /**
1024
+ * Bind a RUNNING session to an initiative — the PM's own word about work already in flight
1025
+ * (workspace-session-attribution R3).
1026
+ *
1027
+ * Every session that existed before the recorded field shipped has no attribution, and so does every
1028
+ * session started outside PMOS. Without this the field would be a promise about future sessions and
1029
+ * no help at all with the ones on screen.
1030
+ *
1031
+ * What comes back is the server's answer, not an assumption: `previous` names the initiative this
1032
+ * replaced, so a re-bind the PM did not mean to make is visible rather than silent. Pass
1033
+ * `initiative: null` to clear a binding — the honest way back from a wrong one.
1034
+ *
1035
+ * The poll is scheduled rather than the state patched locally: `/api/sessions` is the one reader of
1036
+ * this fact, and writing a second opinion into `state.sessions` here is how the board and the server
1037
+ * would come to disagree about what a session is for.
1038
+ */
1039
+ export async function bindSessionInitiative(id, initiative) {
1040
+ if (FIXTURE) return { ok: false, error: 'fixture mode has no server' };
1041
+ const out = await postJson('/api/session/initiative', { id, initiative: initiative || null });
1042
+ if (out && out.ok) setTimeout(pollSessions, 200);
1043
+ return out || { ok: false, error: 'the workspace server did not answer' };
1044
+ }
1045
+
960
1046
  /* ── the scratchpad ─────────────────────────────────────────────────────────────────────────────
961
1047
  * The pad is PER SESSION, and the server has always stored it that way: `/api/scratchpad` is keyed
962
1048
  * by the server-issued session id (workspace-scoped, one file per session, deleted with it). What
@@ -52,7 +52,7 @@
52
52
  import { el, setChildren } from './dom.js';
53
53
  import { state, setState, FIXTURE } from './state.js';
54
54
  import * as FX from './fixture.js';
55
- import { openDoc, openTab, fileNewModel } from './panel-initiatives.js';
55
+ import { openDoc, openTab, openTerm, fileNewModel } from './panel-initiatives.js';
56
56
  import { addProductBody } from './panel-repos.js';
57
57
 
58
58
  /* ── the prototype's small helpers ─────────────────────────────────────────────────────────── */
@@ -212,6 +212,78 @@ const stickyHeader = el('div', {
212
212
  }, '⟨'),
213
213
  );
214
214
 
215
+ /* ── 1b · SESSIONS — the agents running on THIS initiative ──────────────────────────────────────
216
+ *
217
+ * The PM's report: *"when I click the initiative on the initiative panel (which is column number 3),
218
+ * I can see the active sessions list, then when i click, either go to the tab (if tab is open), or
219
+ * open new tab and directly resume to the session."*
220
+ *
221
+ * Their first sentence was not that the list is unclickable — there was no list. `data.js` has been
222
+ * building `rec.sessions` (each row carrying the server's own id and rendered label) and `rec.noSess`
223
+ * since the four-panel port, and `initBody()` rendered header → phases → links → artifacts → footer.
224
+ * Neither field appeared anywhere in this file. The join ran on the server, survived the wire, was
225
+ * normalised by the data layer, and was dropped by the renderer.
226
+ *
227
+ * ── WHY THIS DOES NOT RENDER `rec.sessions` ──────────────────────────────────────────────────
228
+ * Because `FX.DATA` carries seed `sessions` arrays, and drawing them would light up seven fixture
229
+ * rows and break the design comparator's text axis on the first render. The board solved this the
230
+ * same way and for the same reason: read the LIVE join, which fixture mode structurally cannot fill
231
+ * (`pollSessions` returns early on FIXTURE). There is no server in fixture mode, so there are no
232
+ * live sessions to claim.
233
+ *
234
+ * ── THE CLICK ────────────────────────────────────────────────────────────────────────────────
235
+ * `openTerm` with the session's SERVER id and no `spawn`. Those two facts are the whole behaviour:
236
+ * `openTab` focuses a tab whose id already exists rather than duplicating it, and the absent `spawn`
237
+ * is what withholds authorisation to create a PTY — so a click either lands on the open tab or opens
238
+ * one attached to the running process. It cannot start a second agent.
239
+ */
240
+
241
+ /** The live sessions on one initiative, from the shared join — never from the record's seed rows. */
242
+ function liveSessionsFor(slug) {
243
+ if (FIXTURE || !slug) return [];
244
+ const ws = typeof window !== 'undefined' ? window.PMOSWorkingSet : null;
245
+ const map = state.wsBranches && typeof state.wsBranches === 'object' ? state.wsBranches : {};
246
+ if (!ws || typeof ws.attributeSessions !== 'function') return [];
247
+ const attr = ws.attributeSessions(state.sessions, (id) => map[id] || null);
248
+ return attr.byInitiative[slug] || [];
249
+ }
250
+
251
+ function sessionsBlock(slug) {
252
+ const rows = liveSessionsFor(slug);
253
+ if (!rows.length) return null; // nothing running: no heading over nothing
254
+ const ss = typeof window !== 'undefined' ? window.PMOSSessionState : null;
255
+ const openIds = new Set((state.tabs || []).filter((t) => t && t.kind === 'term' && t.sessionId).map((t) => t.sessionId));
256
+
257
+ return el('div', { style: 'padding:9px 14px 10px;border-bottom:1px solid var(--border)' },
258
+ el('div', { style: 'font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground);margin-bottom:6px' },
259
+ 'sessions'),
260
+ rows.map((s) => {
261
+ const st = ss && ss.GLYPH[s.shown] ? s.shown : null;
262
+ const here = openIds.has(s.id);
263
+ return el('button', {
264
+ onClick: () => openTerm(slug, { name: s.name, st: s.shown, label: s.label, id: s.id, profile: s.profile_id }),
265
+ style: 'display:flex;align-items:center;gap:8px;width:100%;padding:5px 6px;min-height:30px',
266
+ hover: 'background:var(--accent)',
267
+ // What the click will do, said BEFORE it — the difference between landing on a tab already
268
+ // open and opening one that attaches. Neither ever starts an agent, and that is worth saying
269
+ // on the control rather than only in a comment nobody reading the surface can see.
270
+ title: (s.name || s.id) + ' — ' + (s.label || s.shown || '')
271
+ + (s.profile_id ? '\nprofile ' + s.profile_id : '')
272
+ + (here ? '\nits tab is open — this goes to it' : '\nopens a tab attached to this running session; nothing is started'),
273
+ },
274
+ el('span', { style: 'flex:none;font-size:11px;color:' + (st && ss ? ss.COLOR[st] : 'var(--muted-foreground)') },
275
+ st && ss ? ss.GLYPH[st] : '·'),
276
+ el('span', { style: 'font-family:var(--mono);font-size:11.5px;color:var(--foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' },
277
+ iv(s.name || s.id)),
278
+ el('span', { style: 'margin-left:auto;flex:none;font-size:10px;color:var(--muted-foreground)' },
279
+ iv(s.label || s.shown || '')),
280
+ el('span', { style: 'flex:none;font-family:var(--mono);font-size:9.5px;color:var(--muted-foreground)' },
281
+ iv(here ? 'open' : 'resume')),
282
+ );
283
+ }),
284
+ );
285
+ }
286
+
215
287
  /* ── 1 · HEADER — slug, tag, pills (lines 338–346) ──────────────────────────────────────────── */
216
288
 
217
289
  function headerBlock(name, tag, pills) {
@@ -908,6 +980,11 @@ function initBody(slug) {
908
980
  out.push(...noLadderBlock(FX.wash(ansC), ansC, (rec.answer && rec.answer.text) || '', rec.noLadder));
909
981
  }
910
982
 
983
+ // What is RUNNING on this initiative, above the ladder: the loop position is the slower fact, and
984
+ // an agent waiting on the PM is the one they came to the panel for.
985
+ const sess = sessionsBlock(slug);
986
+ if (sess) out.push(sess);
987
+
911
988
  const phases = phaseBlock(slug, rec);
912
989
  if (phases) out.push(phases);
913
990
 
@@ -961,6 +1038,97 @@ function anchorButton(an) {
961
1038
  );
962
1039
  }
963
1040
 
1041
+ /* ── BIND — the PM's own word about a session already running ──────────────────────────────────
1042
+ *
1043
+ * Every session that existed before a session could record its initiative has no attribution, and so
1044
+ * does every session started outside PMOS. This is the only way one of them ever joins an initiative,
1045
+ * which is why "show them read-only" was not enough.
1046
+ *
1047
+ * The whole surface turns on one distinction, stated in the copy rather than left to a code comment:
1048
+ * what is recorded here is a DECLARATION. PMOS was not told at spawn and did not read it off
1049
+ * anything — the PM is saying so. `initiative-links.jsonl` already draws that line with
1050
+ * `origin`/`state`, and a binding that presented itself as a reading would be the same watermelon
1051
+ * this repo keeps finding.
1052
+ */
1053
+ function bindBody() {
1054
+ const out = [];
1055
+ const rows = (state.sessions || []).filter((s) => s && s.shown !== 'exited');
1056
+ const picked = state.bindSess && rows.find((s) => s.id === state.bindSess);
1057
+ const board = state.board && state.board[state.product] ? state.board[state.product] : [];
1058
+ const slugs = [];
1059
+ for (const g of board) for (const sl of g.slugs || []) if (!slugs.includes(sl)) slugs.push(sl);
1060
+ const typed = String(state.bindTo || '');
1061
+ const matches = typed ? slugs.filter((sl) => sl.includes(typed.toLowerCase())).slice(0, 8) : [];
1062
+
1063
+ out.push(el('div', { style: 'padding:13px 14px 11px;border-bottom:1px solid var(--border)' },
1064
+ el('div', { style: 'font-size:14px;font-weight:600;margin-bottom:4px' }, 'bind a session'),
1065
+ el('div', { style: 'font-size:11.5px;line-height:1.5;color:var(--muted-foreground)' },
1066
+ 'PMOS was never told what these sessions are for, and nothing here can read it. '
1067
+ + 'What you pick is recorded as YOUR declaration — not as something PMOS observed.')));
1068
+
1069
+ out.push(el('div', { style: 'padding:10px 14px 2px;' + NEW_LABEL }, 'session'));
1070
+ if (!rows.length) {
1071
+ out.push(el('div', { style: 'padding:2px 14px 10px;font-size:11.5px;color:var(--muted-foreground);font-style:italic' },
1072
+ 'no live sessions — a successful read of nothing'));
1073
+ return out;
1074
+ }
1075
+ out.push(el('div', { style: 'display:flex;flex-direction:column;gap:2px;padding:2px 14px 6px' },
1076
+ rows.map((s) => el('button', {
1077
+ onClick: () => setState({ bindSess: s.id }),
1078
+ style: 'display:flex;gap:8px;align-items:center;min-height:32px;padding:0 9px;background:'
1079
+ + (picked && picked.id === s.id ? 'var(--secondary)' : 'transparent')
1080
+ + ';border:1px solid ' + (picked && picked.id === s.id ? 'var(--input)' : 'var(--border)'),
1081
+ hover: 'border-color:var(--ring)',
1082
+ },
1083
+ el('span', { style: 'font-family:var(--mono);font-size:11px;color:var(--foreground)' }, iv(s.name || s.id)),
1084
+ el('span', { style: 'font-size:11px;color:var(--muted-foreground)' }, iv(s.label || s.shown || '')),
1085
+ // What it is bound to NOW, and by what. A session already attributed can be re-bound, and the
1086
+ // PM is entitled to see what they would be replacing before they replace it (R8).
1087
+ s.initiative
1088
+ ? el('span', { style: 'margin-left:auto;font-family:var(--mono);font-size:9.5px;color:var(--muted-foreground)' },
1089
+ iv('→ ' + s.initiative + (s.attributed_by === 'declared' ? ' · declared' : ' · recorded')))
1090
+ : null,
1091
+ ))));
1092
+
1093
+ out.push(el('div', { style: 'padding:6px 14px 2px;' + NEW_LABEL }, 'initiative'));
1094
+ out.push(el('div', { style: 'padding:2px 14px 4px' },
1095
+ newField('bindTo', typed, () => setState({}), 'type part of an initiative id',
1096
+ 'width:100%;font-family:var(--mono);font-size:11px;padding:5px 7px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)')));
1097
+ if (matches.length) {
1098
+ out.push(el('div', { style: 'display:flex;flex-direction:column;gap:2px;padding:2px 14px 6px' },
1099
+ matches.map((sl) => el('button', {
1100
+ onClick: () => {
1101
+ if (!picked) return;
1102
+ const data = sibling('data', './data.js');
1103
+ if (!data || typeof data.bindSessionInitiative !== 'function') return;
1104
+ data.bindSessionInitiative(picked.id, sl).then((r) => {
1105
+ setState({
1106
+ bindResult: r && r.ok
1107
+ ? '“' + (picked.name || picked.id) + '” is now on ' + sl
1108
+ + (r.previous ? ' — replacing ' + r.previous : '')
1109
+ : 'could not bind — ' + ((r && r.error) || 'the server did not answer'),
1110
+ });
1111
+ });
1112
+ },
1113
+ style: 'display:flex;gap:8px;align-items:center;min-height:30px;padding:0 9px;background:transparent;border:1px solid var(--border)',
1114
+ hover: 'border-color:var(--ring)',
1115
+ }, el('span', { style: 'font-family:var(--mono);font-size:11px' }, iv(sl))))));
1116
+ } else if (typed) {
1117
+ out.push(el('div', { style: 'padding:2px 14px 8px;font-size:11.5px;color:var(--muted-foreground);font-style:italic' },
1118
+ 'nothing on this board matches — a successful read of nothing'));
1119
+ }
1120
+
1121
+ if (!picked) {
1122
+ out.push(el('div', { style: 'padding:4px 14px 10px;font-size:11.5px;color:var(--muted-foreground)' },
1123
+ 'pick a session first.'));
1124
+ }
1125
+ if (state.bindResult) {
1126
+ out.push(el('div', { style: 'margin:4px 14px 10px;padding:8px 11px;border:1px solid var(--border);font-size:11.5px;line-height:1.5' },
1127
+ iv(state.bindResult)));
1128
+ }
1129
+ return out;
1130
+ }
1131
+
964
1132
  function fileNewBody() {
965
1133
  const nw = fileNewModel();
966
1134
  const out = [];
@@ -1073,11 +1241,15 @@ function modeDetailBody(kind) {
1073
1241
 
1074
1242
  export function renderDetail(host) {
1075
1243
  const kind = (state.detail && state.detail.kind) || 'init';
1076
- headerTitleText.textContent = FX.detailTitle(kind);
1244
+ // `bind` is not one of the prototype's detail kinds, and `fixture.js` is its seed data — adding a
1245
+ // key there to title one screen would be drift into a file the parity suite exists to keep
1246
+ // transcribed. The override lives here instead, where the screen does.
1247
+ headerTitleText.textContent = kind === 'bind' ? 'bind a session' : FX.detailTitle(kind);
1077
1248
 
1078
1249
  let body;
1079
1250
  if (kind === 'init') body = initBody(selectedSlug());
1080
1251
  else if (kind === 'addprod') body = addProductBody();
1252
+ else if (kind === 'bind') body = bindBody();
1081
1253
  else if (kind === 'new') body = fileNewBody();
1082
1254
  else if (kind === 'none') body = [noneBody()];
1083
1255
  else body = modeDetailBody(kind);