create-pmos 0.25.0 → 0.26.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 +27 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/workspace/README.md +16 -11
- package/workspace/lib/working-set.js +124 -28
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/data.js +23 -0
- package/workspace/public/pm/panel-detail.js +174 -2
- package/workspace/public/pm/panel-initiatives.js +124 -16
- package/workspace/public/pm/state.js +5 -0
- package/workspace/public/pm/tab-term.js +13 -1
- package/workspace/server.js +104 -4
package/README.md
CHANGED
|
@@ -333,6 +333,33 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
|
|
|
333
333
|
`okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
|
|
334
334
|
exactly what you're running.
|
|
335
335
|
|
|
336
|
+
## What's new in 0.26.0
|
|
337
|
+
|
|
338
|
+
**Your sessions now know which initiative they belong to — whatever branch you are on — and each
|
|
339
|
+
initiative shows the agents running on it.** Run `npx create-pmos@latest workspace`; the version bump
|
|
340
|
+
means an existing install auto-upgrades.
|
|
341
|
+
|
|
342
|
+
- **`NOW · working on` stopped missing sessions.** It could only recognise a session by the *branch*
|
|
343
|
+
its folder was on — `initiative/<id>` — which is true of almost no real checkout. Work on `main`,
|
|
344
|
+
or on a branch your agent harness named, and the board could not see the agent you had running.
|
|
345
|
+
A session now **records** which initiative it is for when you start it from one, so it is
|
|
346
|
+
attributed whatever branch you are on, and that survives restarting the workspace.
|
|
347
|
+
- **Nothing is guessed, and nothing is hidden.** Where no initiative was recorded, the
|
|
348
|
+
`initiative/<id>` branch still answers — and the row tells you when the attribution is only a
|
|
349
|
+
branch reading rather than something PMOS was told. A session it genuinely cannot place is listed
|
|
350
|
+
under **not on an initiative** instead of vanishing: an agent that is running should never be
|
|
351
|
+
invisible on the screen whose job is to tell you what is running.
|
|
352
|
+
- **Bind a session that is already running.** `+ bind` records what a session is for — including
|
|
353
|
+
every session that was running before this shipped, and any you started outside PMOS. It is stored
|
|
354
|
+
as **your declaration**, kept distinct from what PMOS was told first-hand, and re-binding tells you
|
|
355
|
+
what it replaced. Binding never touches the process: same session, same folder, still running.
|
|
356
|
+
- **The initiative panel lists its live sessions.** Select an initiative and you see the agents on
|
|
357
|
+
it, each with the same word the sidebar uses — `needs you` · `working` · `quiet 12m` ·
|
|
358
|
+
`maybe blocked` — and its profile.
|
|
359
|
+
- **Click one to get to it.** If its tab is already open, you go to that tab. If not, a tab opens
|
|
360
|
+
**attached** to the running session, scrollback and all. It never starts a second agent — the row
|
|
361
|
+
says which of the two will happen before you click.
|
|
362
|
+
|
|
336
363
|
## What's new in 0.24.0
|
|
337
364
|
|
|
338
365
|
**The initiative board says which work is yours, and which of it has an agent running.** Run
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit \u2014 run the PMOS product-management workflow (initiative \u2192 lane-fit spec \u2192 contract \u2192 build \u2192 three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
package/workspace/README.md
CHANGED
|
@@ -68,17 +68,22 @@ bar and a 26px status bar, on the oklch token layer, in both themes:
|
|
|
68
68
|
box that forces the groups open and reports matches elsewhere, and an add form. Unanchored
|
|
69
69
|
initiatives are filed honestly and shown **rejected** under D59, never hidden.
|
|
70
70
|
A **`NOW · working on`** group is pinned above the objectives when — and only when — there is
|
|
71
|
-
something to say: every initiative with a live session
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
71
|
+
something to say: every initiative with a live session, a registered working tree on its
|
|
72
|
+
`initiative/<id>` branch, or a tab open in this window, ordered so whatever is **waiting on you**
|
|
73
|
+
is the first row on the board. The three signals are ranked, never merged, and each row's tooltip
|
|
74
|
+
names which one put it there. On a row with a live session the leading `#` becomes that session's
|
|
75
|
+
own glyph — `◆ needs you`, `● working`, `◇ maybe blocked`, `◼ quiet` — in the vocabulary
|
|
76
|
+
`lib/session-state.js` uses everywhere else, with every session named individually on hover. The
|
|
77
|
+
glyphs differ in **shape**, so the signal survives greyscale.
|
|
78
|
+
**A session records which initiative it is for**, so `+ session` on an initiative is attributed
|
|
79
|
+
to it whatever branch you are on — and that survives a server restart. Where nothing was recorded
|
|
80
|
+
the `initiative/<id>` branch still answers, and the row says when the attribution is only a branch
|
|
81
|
+
reading. A session PMOS cannot place is never guessed at **and never hidden**: it is listed under
|
|
82
|
+
*not on an initiative*, and `+ bind` records what it is for as your own declaration.
|
|
83
|
+
3. **Initiative detail** — the record's header and pills (type / anchor / flow / branch), the
|
|
84
|
+
**sessions** running on it (each with the same word the sidebar uses — click one to go to its tab
|
|
85
|
+
if it is open, or to open a tab **attached** to the running session; nothing here ever starts an
|
|
86
|
+
agent), the derived footer, **task phases** (collapsible; a phase's status is derived from its tasks, and the tasks
|
|
82
87
|
are derived from the workflow ladder unless the record authors them), a **links** section for
|
|
83
88
|
references that are not repo files, and **artifacts** with an external destination and the sync
|
|
84
89
|
panel. A record that fails to read renders **red with the parse error** — never a blank panel.
|
|
@@ -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
|
|
9
|
-
//
|
|
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
|
-
//
|
|
17
|
-
// board can colour 178 rows without 178 requests.
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
|
23
|
-
// workspace with no branch, a branch that is not `initiative/…`, a branch that is the
|
|
24
|
-
// `initiative/` with no id —
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
|
-
|
|
96
|
-
|
|
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)
|
|
99
|
-
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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
|
-
|
|
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')
|
|
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.
|
|
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.
|
|
9
|
+
"version": "0.26.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -957,6 +957,29 @@ export function startSession(slug, sess) {
|
|
|
957
957
|
setTimeout(pollSessions, 800);
|
|
958
958
|
}
|
|
959
959
|
|
|
960
|
+
/**
|
|
961
|
+
* Bind a RUNNING session to an initiative — the PM's own word about work already in flight
|
|
962
|
+
* (workspace-session-attribution R3).
|
|
963
|
+
*
|
|
964
|
+
* Every session that existed before the recorded field shipped has no attribution, and so does every
|
|
965
|
+
* session started outside PMOS. Without this the field would be a promise about future sessions and
|
|
966
|
+
* no help at all with the ones on screen.
|
|
967
|
+
*
|
|
968
|
+
* What comes back is the server's answer, not an assumption: `previous` names the initiative this
|
|
969
|
+
* replaced, so a re-bind the PM did not mean to make is visible rather than silent. Pass
|
|
970
|
+
* `initiative: null` to clear a binding — the honest way back from a wrong one.
|
|
971
|
+
*
|
|
972
|
+
* The poll is scheduled rather than the state patched locally: `/api/sessions` is the one reader of
|
|
973
|
+
* this fact, and writing a second opinion into `state.sessions` here is how the board and the server
|
|
974
|
+
* would come to disagree about what a session is for.
|
|
975
|
+
*/
|
|
976
|
+
export async function bindSessionInitiative(id, initiative) {
|
|
977
|
+
if (FIXTURE) return { ok: false, error: 'fixture mode has no server' };
|
|
978
|
+
const out = await postJson('/api/session/initiative', { id, initiative: initiative || null });
|
|
979
|
+
if (out && out.ok) setTimeout(pollSessions, 200);
|
|
980
|
+
return out || { ok: false, error: 'the workspace server did not answer' };
|
|
981
|
+
}
|
|
982
|
+
|
|
960
983
|
/* ── the scratchpad ─────────────────────────────────────────────────────────────────────────────
|
|
961
984
|
* The pad is PER SESSION, and the server has always stored it that way: `/api/scratchpad` is keyed
|
|
962
985
|
* 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
|
-
|
|
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);
|
|
@@ -45,8 +45,21 @@
|
|
|
45
45
|
* Three rules hold it honest. Rows are DUPLICATED, never moved — an initiative keeps its place under
|
|
46
46
|
* its own anchor, because the anchor is the truth (D59) and a shortcut that re-homes work is a second
|
|
47
47
|
* filing system. The group is ABSENT, not empty, when nothing is live. And the join is
|
|
48
|
-
* lib/working-set.js's
|
|
49
|
-
*
|
|
48
|
+
* lib/working-set.js's — so a session the join cannot reach never lands on a row it does not belong
|
|
49
|
+
* to.
|
|
50
|
+
*
|
|
51
|
+
* ── What that first cut got wrong (workspace-session-attribution) ────────────────────────────
|
|
52
|
+
* It joined on the `initiative/<id>` branch convention ALONE, which holds in one of four ordinary
|
|
53
|
+
* situations — and the proof was this file's own history: the initiative that shipped this group was
|
|
54
|
+
* built on a `claude/*` branch, so the group could not see the session that wrote it. A session now
|
|
55
|
+
* RECORDS what it is for, and `attributeSessions` resolves recorded → branch → unattributed, saying
|
|
56
|
+
* which path answered.
|
|
57
|
+
*
|
|
58
|
+
* The other half was worse than a narrow join. A session the board could not place was DROPPED, so a
|
|
59
|
+
* running agent was invisible on the surface whose job is to say what is running. Refusing to guess
|
|
60
|
+
* and refusing to mention are different things, and only the first is honesty — hence `orphanRows`,
|
|
61
|
+
* which lists them under the group, claims no initiative for them, and offers the bind that turns
|
|
62
|
+
* the PM's knowledge into a recorded declaration.
|
|
50
63
|
*
|
|
51
64
|
* Fixture mode is structurally unaffected, not conditionally suppressed: `pollSessions` returns early
|
|
52
65
|
* on FIXTURE and `foldWorkspaces` never runs, so `state.sessions` is empty and there are no branches.
|
|
@@ -148,11 +161,26 @@ const branches = () => (state.wsBranches && typeof state.wsBranches === 'object'
|
|
|
148
161
|
* `return` two modules away.
|
|
149
162
|
*/
|
|
150
163
|
function liveSessions() {
|
|
151
|
-
|
|
164
|
+
return attribution().byInitiative;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The full attribution — `{byInitiative, via, unattributed}` — computed ONCE per render and shared
|
|
169
|
+
* by the row markers, the working-set group, the unattributed row and the search. Deriving it three
|
|
170
|
+
* times would be three chances to disagree about one fact.
|
|
171
|
+
*
|
|
172
|
+
* The join now prefers what a session RECORDED over what its branch is called; `via` says which
|
|
173
|
+
* answered, and `unattributed` is every live session neither path could place. That last list is
|
|
174
|
+
* why this returns the whole shape rather than just the map: a running agent PMOS cannot attribute
|
|
175
|
+
* still has to be visible somewhere, and dropping it was half the defect this fixes.
|
|
176
|
+
*/
|
|
177
|
+
function attribution() {
|
|
178
|
+
const empty = { byInitiative: {}, via: {}, unattributed: [] };
|
|
179
|
+
if (FIXTURE) return empty;
|
|
152
180
|
const ws = WS();
|
|
153
|
-
if (!ws) return
|
|
181
|
+
if (!ws || typeof ws.attributeSessions !== 'function') return empty;
|
|
154
182
|
const map = branches();
|
|
155
|
-
return ws.
|
|
183
|
+
return ws.attributeSessions(state.sessions, (id) => map[id] || null);
|
|
156
184
|
}
|
|
157
185
|
|
|
158
186
|
/**
|
|
@@ -161,13 +189,14 @@ function liveSessions() {
|
|
|
161
189
|
* `onBoard` is what stops a tab or a working tree belonging to ANOTHER repository putting a row on
|
|
162
190
|
* this repository's board — `slugProduct()` is the same map panel 4 scopes its tabs by.
|
|
163
191
|
*/
|
|
164
|
-
function workingSet(
|
|
192
|
+
function workingSet(attr, product) {
|
|
165
193
|
if (FIXTURE) return [];
|
|
166
194
|
const ws = WS();
|
|
167
195
|
if (!ws) return [];
|
|
168
196
|
const owner = slugProduct();
|
|
169
197
|
return ws.workingSet({
|
|
170
|
-
live,
|
|
198
|
+
live: attr.byInitiative,
|
|
199
|
+
via: attr.via,
|
|
171
200
|
trees: ws.initiativesWithTree(Object.values(branches())),
|
|
172
201
|
tabs: ws.initiativesWithTab(state.tabs),
|
|
173
202
|
onBoard: (slug) => owner[slug] === product,
|
|
@@ -224,9 +253,18 @@ export function openTab(tab) {
|
|
|
224
253
|
}
|
|
225
254
|
|
|
226
255
|
export function openTerm(slug, sess) {
|
|
256
|
+
/* THE TAB ID, and why a session with no initiative gets a different one.
|
|
257
|
+
*
|
|
258
|
+
* `term:<slug>:<name>` is the id every initiative-opened terminal has always had, and it is what
|
|
259
|
+
* makes `openTab` focus an already-open tab instead of duplicating it. A session PMOS cannot place
|
|
260
|
+
* on an initiative has no slug — and interpolating one anyway produced `term:null:agent 1`, where
|
|
261
|
+
* every unattributed session with the same name would collide on one tab. Keying those by the
|
|
262
|
+
* SERVER's own session id instead is both collision-free and more honest: the tab is a view of
|
|
263
|
+
* that process, not of an initiative it does not belong to. */
|
|
264
|
+
const key = slug ? 'term:' + slug + ':' + sess.name : 'term:sess:' + ((sess && sess.id) || sess.name);
|
|
227
265
|
openTab({
|
|
228
|
-
id:
|
|
229
|
-
repo: slugProduct()[slug], sess: sess.name, state: sess.st, profile: sess.profile || 'claude-work',
|
|
266
|
+
id: key, kind: 'term', title: sess.name, slug: slug || null,
|
|
267
|
+
repo: slug ? slugProduct()[slug] : null, sess: sess.name, state: sess.st, profile: sess.profile || 'claude-work',
|
|
230
268
|
/* The SERVER's session id, when the row that was clicked carries one. This is the exact join:
|
|
231
269
|
* with it, the terminal attaches to precisely the PTY the PM pointed at, and a reload attaches
|
|
232
270
|
* to the same one again. Without it the tab falls back to matching by NAME, and names repeat
|
|
@@ -333,11 +371,67 @@ function groupNode(g, product) {
|
|
|
333
371
|
body.push(el('div', { style: 'padding:5px 12px 6px 27px;font-size:11px;color:var(--muted-foreground);font-style:italic' },
|
|
334
372
|
'nothing filed yet — a successful read of nothing'));
|
|
335
373
|
}
|
|
374
|
+
// Rows this group carries that are NOT initiatives — today, the live sessions PMOS cannot place.
|
|
375
|
+
// Appended by the caller so `groupNode` stays the one renderer and does not learn a second model.
|
|
376
|
+
if (g.after) body.push(g.after);
|
|
336
377
|
}
|
|
337
378
|
|
|
338
379
|
return el('div', { style: 'margin-top:4px' }, groupHeader, body);
|
|
339
380
|
}
|
|
340
381
|
|
|
382
|
+
/* ── the live sessions PMOS cannot place ──────────────────────────────────────────────────────
|
|
383
|
+
*
|
|
384
|
+
* One row per unattributable live session, under the working-set group, in the same row shape the
|
|
385
|
+
* initiatives use so the eye reads one list. It deliberately does NOT look like an initiative row:
|
|
386
|
+
* the id slot carries the session's own name and its glyph is the session state, because this row
|
|
387
|
+
* stands for a process, not for a record.
|
|
388
|
+
*
|
|
389
|
+
* Clicking it opens that session's terminal — the same `openTerm` join every other session click
|
|
390
|
+
* uses, by the server's id, with no `spawn`. `+ bind` is the second control: it turns the PM's
|
|
391
|
+
* knowledge of what the session is for into a recorded declaration, which is the only way a session
|
|
392
|
+
* started before this field existed can ever join an initiative.
|
|
393
|
+
*/
|
|
394
|
+
function orphanRows(rows) {
|
|
395
|
+
const ss = SS();
|
|
396
|
+
const ws = WS();
|
|
397
|
+
const head = el('div', {
|
|
398
|
+
style: 'display:flex;align-items:baseline;gap:6px;padding:6px 12px 3px 27px',
|
|
399
|
+
title: ws ? ws.unattributedTitle(rows) : null,
|
|
400
|
+
},
|
|
401
|
+
el('span', { style: 'flex:none;font-family:var(--mono);font-size:10px;color:var(--warning)' }, iv('⌁')),
|
|
402
|
+
el('span', { style: 'font-size:10.5px;color:var(--muted-foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' },
|
|
403
|
+
iv('not on an initiative — ' + rows.length + ' live session' + (rows.length === 1 ? '' : 's'))),
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
const body = rows.map((s) => {
|
|
407
|
+
const st = ss && ss.GLYPH[s.shown] ? s.shown : null;
|
|
408
|
+
return el('button', {
|
|
409
|
+
onClick: () => openTerm(null, { name: s.name, st: s.shown, label: s.label, id: s.id, profile: s.profile_id }),
|
|
410
|
+
style: 'display:flex;align-items:center;gap:7px;width:100%;padding:5px 12px 5px 27px;min-height:32px;border-left:2px solid transparent;background:transparent',
|
|
411
|
+
hover: 'background:var(--accent)',
|
|
412
|
+
title: (s.name || s.id) + ' — ' + (s.label || s.shown || '')
|
|
413
|
+
+ '\nPMOS was never told what this session is for. + bind records it.',
|
|
414
|
+
},
|
|
415
|
+
el('span', { style: 'flex:none;font-size:12px;color:' + (st && ss ? ss.COLOR[st] : 'var(--muted-foreground)') },
|
|
416
|
+
st && ss ? ss.GLYPH[st] : '·'),
|
|
417
|
+
el('span', { style: 'font-family:var(--mono);font-size:12.5px;color:var(--foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' },
|
|
418
|
+
iv(s.name || s.id)),
|
|
419
|
+
el('span', { style: 'margin-left:auto;flex:none;font-size:9px;color:var(--muted-foreground)' }, iv(s.label || s.shown || '')),
|
|
420
|
+
);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const bind = el('button', {
|
|
424
|
+
onClick: () => setState({ bindSess: rows.length === 1 ? rows[0].id : null, bindResult: null, detail: { kind: 'bind' }, col2: true }),
|
|
425
|
+
style: 'display:flex;align-items:center;gap:7px;width:100%;padding:6px 12px 6px 27px;color:var(--muted-foreground)',
|
|
426
|
+
hover: 'color:var(--foreground);background:var(--accent)',
|
|
427
|
+
},
|
|
428
|
+
el('span', { style: 'flex:none;width:9px;text-align:center;font-size:14px' }, '+'),
|
|
429
|
+
el('span', { style: 'font-size:11px' }, 'bind a session to an initiative'),
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
return el('div', null, head, body, bind);
|
|
433
|
+
}
|
|
434
|
+
|
|
341
435
|
/* ── "matches elsewhere" — artifacts and sessions that the group list cannot show ──────────── */
|
|
342
436
|
|
|
343
437
|
/**
|
|
@@ -522,9 +616,10 @@ export function renderInitiatives(host) {
|
|
|
522
616
|
const DATA = records();
|
|
523
617
|
const selSlug = selectedSlug();
|
|
524
618
|
const detKind = (state.detail && state.detail.kind) || '';
|
|
525
|
-
// ONE
|
|
526
|
-
// it
|
|
527
|
-
const
|
|
619
|
+
// ONE attribution per render, shared by the row markers, the working-set group, the unattributed
|
|
620
|
+
// row and the search. Computing it four times would be four chances to disagree about one fact.
|
|
621
|
+
const attr = attribution();
|
|
622
|
+
const live = attr.byInitiative;
|
|
528
623
|
|
|
529
624
|
/** One row's view model. `why` is null everywhere except in the working-set group. */
|
|
530
625
|
const rowOf = (slug, why) => {
|
|
@@ -561,13 +656,26 @@ export function renderInitiatives(host) {
|
|
|
561
656
|
*
|
|
562
657
|
* Absent when empty. Not a "0", not a placeholder: a heading over nothing is a heading the PM
|
|
563
658
|
* learns to skip. */
|
|
564
|
-
const now = workingSet(
|
|
565
|
-
|
|
659
|
+
const now = workingSet(attr, product).filter((e) => !q || e.slug.includes(q));
|
|
660
|
+
/* EVERY LIVE SESSION PMOS CANNOT PLACE (workspace-session-attribution R5).
|
|
661
|
+
*
|
|
662
|
+
* The board shipped joining on the `initiative/<id>` branch alone, so a session on `main`, on a
|
|
663
|
+
* `claude/*` branch, or in a repo with no git simply vanished from it. Refusing to guess which
|
|
664
|
+
* initiative it belongs to is right; refusing to MENTION it is not, and only the first is honesty.
|
|
665
|
+
*
|
|
666
|
+
* It is rendered as a row inside the same group rather than as a group of its own, because it is
|
|
667
|
+
* not an initiative and must not sit in a list keyed by one. `+ bind` opens the chooser that
|
|
668
|
+
* turns it into the PM's own declaration. */
|
|
669
|
+
const orphans = q ? [] : (attr.unattributed || []);
|
|
670
|
+
if (now.length || orphans.length) {
|
|
671
|
+
const rows = now.map((e) => rowOf(e.slug, WS() ? WS().reasonTitle(e) : null));
|
|
566
672
|
kids.push(groupNode({
|
|
567
|
-
gid: 'NOW', dyn: true, idc: FX.C.brand, gname: 'working on',
|
|
673
|
+
gid: 'NOW', dyn: true, idc: FX.C.brand, gname: 'working on',
|
|
674
|
+
gct: String(now.length + orphans.length),
|
|
568
675
|
hasKr: true, krid: '⑂', krname: 'live sessions · checked-out trees · open tabs',
|
|
569
|
-
inits:
|
|
676
|
+
inits: rows,
|
|
570
677
|
isEmpty: false,
|
|
678
|
+
after: orphans.length ? orphanRows(orphans) : null,
|
|
571
679
|
}, product));
|
|
572
680
|
}
|
|
573
681
|
for (const g of groups) kids.push(groupNode(g, product));
|
|
@@ -50,6 +50,11 @@ function initial() {
|
|
|
50
50
|
* second click ends it, and anything else disarms. Neither is persisted: a chooser that
|
|
51
51
|
* reappeared on reload would be a question about a moment that has passed. */
|
|
52
52
|
startPrompt: null, endConfirm: null,
|
|
53
|
+
/* The bind screen's three transient bits (workspace-session-attribution R3): which live session
|
|
54
|
+
* is selected, what the PM has typed to find an initiative, and the server's own answer to the
|
|
55
|
+
* last bind. None is persisted — a half-finished declaration restored on reload would be a
|
|
56
|
+
* question about a moment that has passed, and `bindResult` is a report about one request. */
|
|
57
|
+
bindSess: null, bindTo: '', bindResult: null,
|
|
53
58
|
col0: true, col1: true, col2: true, scratchOpen: false, scratchText: '', copied: false,
|
|
54
59
|
flowsMap: null, schedList: null, agentList: null, memory: null,
|
|
55
60
|
savedDocs: {}, scratchCopied: false,
|
|
@@ -360,7 +360,19 @@ function bind(rec) {
|
|
|
360
360
|
SOCK.spawning.set(rec.tab.id, true);
|
|
361
361
|
SOCK.spawnQueue.push(rec.tab.id);
|
|
362
362
|
note(rec, 'pmos: starting a session…', true);
|
|
363
|
-
|
|
363
|
+
/* `initiative` is the ONE field this message was missing (workspace-session-attribution R2).
|
|
364
|
+
* `+ session` is clicked on an initiative row, `openTerm` puts that row's id on the tab as
|
|
365
|
+
* `slug`, and `state.js` persists it — so the answer to "what is this session for?" has been
|
|
366
|
+
* sitting on `rec.tab` at the moment of every spawn, while the message named three other fields.
|
|
367
|
+
* Without it the server could only infer the initiative from the git branch, which is why a
|
|
368
|
+
* session on `main` or on `claude/*` was invisible to the board.
|
|
369
|
+
*
|
|
370
|
+
* A tab with no slug sends nothing extra and the session is unattributed. That is the honest
|
|
371
|
+
* outcome, not a failure: the server never refuses a spawn over a label. */
|
|
372
|
+
const ok = send({
|
|
373
|
+
t: 'new', name: rec.tab.sess, agent: 'shell', ws: wsForTab(rec.tab),
|
|
374
|
+
initiative: rec.tab.slug || null,
|
|
375
|
+
});
|
|
364
376
|
if (!ok) {
|
|
365
377
|
SOCK.spawning.delete(rec.tab.id);
|
|
366
378
|
SOCK.spawnQueue = SOCK.spawnQueue.filter((x) => x !== rec.tab.id);
|
package/workspace/server.js
CHANGED
|
@@ -1476,6 +1476,14 @@ function persist(s) {
|
|
|
1476
1476
|
// fact: a record persisted before profiles existed restores as "not recorded", and inventing a
|
|
1477
1477
|
// key for the past would be a lie about it.
|
|
1478
1478
|
if ('profile' in s) meta.profile = s.profile == null ? null : s.profile;
|
|
1479
|
+
// WHAT THIS SESSION IS FOR, and where that came from. Same `in`-guard as `profile` above and for
|
|
1480
|
+
// the same reason: every session running before this field existed has no attribution, and
|
|
1481
|
+
// writing a key for it would turn "we were never told" into "it belongs to nothing". Both keys
|
|
1482
|
+
// move together — an attribution with no provenance could not be told apart from a reading.
|
|
1483
|
+
if ('initiative' in s) {
|
|
1484
|
+
meta.initiative = s.initiative == null ? null : s.initiative;
|
|
1485
|
+
meta.attributedBy = s.attributedBy || null;
|
|
1486
|
+
}
|
|
1479
1487
|
try {
|
|
1480
1488
|
fs.writeFileSync(path.join(SESS_DIR, s.id + '.json'), JSON.stringify(meta));
|
|
1481
1489
|
fs.writeFileSync(path.join(SESS_DIR, s.id + '.scrollback'), s.buf);
|
|
@@ -1773,6 +1781,16 @@ function sessionList() {
|
|
|
1773
1781
|
? treeShare.roleOf(s.id, treeOfWs.get(s.ws) || null, writerByTree.get(treeOfWs.get(s.ws)))
|
|
1774
1782
|
: null,
|
|
1775
1783
|
tree_peers: treeShare.coTenants(rowsAll, treeOfWs, treeOfWs.get(s.ws) || null, s.id).length,
|
|
1784
|
+
// ── what this session is for (workspace-session-attribution R1) ──
|
|
1785
|
+
// THREE STATES, kept apart on the wire because the surface has to tell them apart:
|
|
1786
|
+
// initiative: 'x', attributed_by: 'spawn' PMOS was told at spawn — first-hand
|
|
1787
|
+
// initiative: 'x', attributed_by: 'declared' the PM bound a running session — their word
|
|
1788
|
+
// initiative: null, attributed_by: null never recorded — NOT "belongs to nothing"
|
|
1789
|
+
// The branch reading is deliberately NOT folded in here. It is the client's fallback
|
|
1790
|
+
// (lib/working-set.js), computed from `/api/workspaces`, and merging it into this field would
|
|
1791
|
+
// make an inference indistinguishable from a record — the exact confusion this field removes.
|
|
1792
|
+
initiative: 'initiative' in s ? (s.initiative == null ? null : s.initiative) : null,
|
|
1793
|
+
attributed_by: 'initiative' in s ? (s.attributedBy || null) : null,
|
|
1776
1794
|
profile_id: 'profile' in s ? (s.profile == null ? null : s.profile) : null,
|
|
1777
1795
|
profile_line: profilesLib.profileLine(
|
|
1778
1796
|
!('profile' in s) ? undefined
|
|
@@ -2133,6 +2151,24 @@ function nextSessionId() {
|
|
|
2133
2151
|
return id;
|
|
2134
2152
|
}
|
|
2135
2153
|
|
|
2154
|
+
/**
|
|
2155
|
+
* An initiative id, or null — the ONE gate every attribution path goes through.
|
|
2156
|
+
*
|
|
2157
|
+
* `fourPanel.validId` is the repo's existing answer to "is this a record name?" and it is reused
|
|
2158
|
+
* rather than re-typed: it already guards the `/api/initiative` path interpolation and the two
|
|
2159
|
+
* declaration stores, so a second regex here would be a second opinion about what an id is, free to
|
|
2160
|
+
* drift from the one that decides whether a file can be read.
|
|
2161
|
+
*
|
|
2162
|
+
* Returning null rather than throwing is deliberate. An unusable value must leave the session
|
|
2163
|
+
* UNATTRIBUTED — which is a real, honest state with a surface of its own — never half-attributed to
|
|
2164
|
+
* something that failed to parse, and never a spawn refused over a label.
|
|
2165
|
+
*/
|
|
2166
|
+
function initiativeIdOrNull(v) {
|
|
2167
|
+
if (v == null) return null;
|
|
2168
|
+
const s = String(v).trim();
|
|
2169
|
+
return s && fourPanel.validId(s) ? s : null;
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2136
2172
|
// Resolve a requested profile id BEFORE anything is spawned (profiles R3/R9). The rule the whole
|
|
2137
2173
|
// feature turns on: a session must never claim a profile whose overlay was not applied. So the
|
|
2138
2174
|
// three outcomes are kept apart — nothing asked for, asked for and readable, asked for and gone —
|
|
@@ -2150,7 +2186,7 @@ function resolveProfileForSpawn(id) {
|
|
|
2150
2186
|
: `profile "${id}" is not among the descriptors in ${read.dir} — PMOS will not substitute another` };
|
|
2151
2187
|
}
|
|
2152
2188
|
|
|
2153
|
-
function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
|
|
2189
|
+
function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative }) {
|
|
2154
2190
|
const id = nextSessionId();
|
|
2155
2191
|
const w = getWs(ws);
|
|
2156
2192
|
const root = w ? w.root : SELF_ROOT;
|
|
@@ -2172,6 +2208,24 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
|
|
|
2172
2208
|
const base = { id, ws: w ? w.id : wsId(SELF_ROOT), root, name: (name && String(name).trim()) || agent, agent, origin: tag,
|
|
2173
2209
|
profile: pres.id, profileRefusal: pres.ok ? null : pres.error };
|
|
2174
2210
|
|
|
2211
|
+
/* WHAT THIS SESSION IS FOR (workspace-session-attribution R1).
|
|
2212
|
+
*
|
|
2213
|
+
* A PMOS session has never recorded which initiative it is working on — not here, not on the wire,
|
|
2214
|
+
* not on disk — so every initiative-keyed surface had to INFER it from the `initiative/<id>` branch
|
|
2215
|
+
* convention, which holds in one of four ordinary situations. The board that shipped on that
|
|
2216
|
+
* inference could not see the session that wrote it, because that session was on a `claude/*`
|
|
2217
|
+
* branch.
|
|
2218
|
+
*
|
|
2219
|
+
* The key is set ONLY when the caller supplied one, exactly as `profile` is, and for the same
|
|
2220
|
+
* reason: its ABSENCE has to stay a fact. A session started before this field existed, or started
|
|
2221
|
+
* outside PMOS, restores as not-recorded — and "not recorded" must never be confused with
|
|
2222
|
+
* "recorded as none", which is what a `null` default would have produced. `attributedBy` says
|
|
2223
|
+
* where the value came from, because a value the PM declared and a value PMOS was told at spawn
|
|
2224
|
+
* are different claims and the surface has to be able to tell them apart.
|
|
2225
|
+
*/
|
|
2226
|
+
const init = initiativeIdOrNull(initiative);
|
|
2227
|
+
if (init) { base.initiative = init; base.attributedBy = 'spawn'; }
|
|
2228
|
+
|
|
2175
2229
|
// A missing agent CLI is detected BEFORE any spawn (on Linux the exec-in-fork failure is
|
|
2176
2230
|
// otherwise silent) — the same honest exited pane on both the tmux and the direct path.
|
|
2177
2231
|
if (!isShell && !commandExists(agent)) {
|
|
@@ -2247,7 +2301,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
|
|
|
2247
2301
|
// A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
|
|
2248
2302
|
// hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
|
|
2249
2303
|
// git-then-maybe-spawn contract is unit-testable without a WS client.
|
|
2250
|
-
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile }) {
|
|
2304
|
+
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative }) {
|
|
2251
2305
|
const w = getWs(ws);
|
|
2252
2306
|
if (!w) return { ok: false, error: 'no workspace' };
|
|
2253
2307
|
// The profile is checked BEFORE any git operation. A composer that picked a profile PMOS cannot
|
|
@@ -2289,7 +2343,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
|
|
|
2289
2343
|
broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
|
|
2290
2344
|
}
|
|
2291
2345
|
}
|
|
2292
|
-
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id });
|
|
2346
|
+
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative });
|
|
2293
2347
|
// The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
|
|
2294
2348
|
// block above. The isolate default already computes that other sessions would be disturbed — and
|
|
2295
2349
|
// then the whole git block is skipped when no branch was picked, which is the common path and the
|
|
@@ -2377,6 +2431,10 @@ function startWork({ initiative, repoKey, ws, agent, name, profile }) {
|
|
|
2377
2431
|
createBranch: plan.createBranch,
|
|
2378
2432
|
isolate: plan.isolate,
|
|
2379
2433
|
profile,
|
|
2434
|
+
// THE FIX THIS FUNCTION HAS BEEN ONE ARGUMENT AWAY FROM SINCE IT WAS WRITTEN. `initiative`
|
|
2435
|
+
// arrived here, decided the branch and supplied a fallback display name, and was then dropped
|
|
2436
|
+
// — so the one start path DESIGNED to know what it was starting work on never told the session.
|
|
2437
|
+
initiative,
|
|
2380
2438
|
});
|
|
2381
2439
|
if (!r.ok) return r;
|
|
2382
2440
|
return { ok: true, session: r.session, notice: r.notice || plan.why, plan: plan.plan };
|
|
@@ -3994,6 +4052,45 @@ async function handleRequest(req, res) {
|
|
|
3994
4052
|
// Discriminated READ results stay 200 — the body carries the reason, the transport code cannot.
|
|
3995
4053
|
return json(res, { ws: w ? w.id : null, ...out });
|
|
3996
4054
|
}
|
|
4055
|
+
if (p === '/api/session/initiative' && req.method === 'POST') {
|
|
4056
|
+
/* Bind a RUNNING session to an initiative — the PM's own word about work already in flight
|
|
4057
|
+
* (workspace-session-attribution R3/R8).
|
|
4058
|
+
*
|
|
4059
|
+
* Every session running when this shipped has no attribution, and so does every session started
|
|
4060
|
+
* outside PMOS. Without this they would stay unattributed forever, which would make the whole
|
|
4061
|
+
* field a promise about the future and no help with the work on screen today.
|
|
4062
|
+
*
|
|
4063
|
+
* Three properties this endpoint holds:
|
|
4064
|
+
* 1. It is a DECLARATION. `attributedBy: 'declared'` is written, never 'spawn', so the surface
|
|
4065
|
+
* can keep the PM's word apart from what PMOS was told first-hand. Same distinction
|
|
4066
|
+
* `initiative-links.jsonl` already draws with `origin`/`state`.
|
|
4067
|
+
* 2. It is SINGLE-VALUED, and a replacement says what it replaced. `previous` comes back on
|
|
4068
|
+
* the response rather than being overwritten with no trace (R8) — a re-bind the PM did not
|
|
4069
|
+
* mean to do is otherwise unrecoverable and unnoticeable.
|
|
4070
|
+
* 3. It touches NOTHING else. No checkout, no branch, no respawn. The session keeps running
|
|
4071
|
+
* exactly as it was; the only thing that changes is what PMOS says it is for.
|
|
4072
|
+
*
|
|
4073
|
+
* `initiative: null` CLEARS the binding, which is the honest way back from a wrong one — and it
|
|
4074
|
+
* still writes the key, because a session the PM has explicitly unbound is a different state
|
|
4075
|
+
* from one that was never recorded.
|
|
4076
|
+
*/
|
|
4077
|
+
let body = {};
|
|
4078
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
4079
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
4080
|
+
const s = sessions.get(String(body.id || ''));
|
|
4081
|
+
if (!s) return json(res, { ok: false, error: 'no such session' }, 404);
|
|
4082
|
+
const clearing = body.initiative === null || body.initiative === '';
|
|
4083
|
+
const next = clearing ? null : initiativeIdOrNull(body.initiative);
|
|
4084
|
+
if (!clearing && !next) {
|
|
4085
|
+
return json(res, { ok: false, error: 'that initiative id is not a record name' }, 400);
|
|
4086
|
+
}
|
|
4087
|
+
const previous = 'initiative' in s ? (s.initiative || null) : null;
|
|
4088
|
+
s.initiative = next;
|
|
4089
|
+
s.attributedBy = next ? 'declared' : null;
|
|
4090
|
+
persist(s);
|
|
4091
|
+
pushSessions();
|
|
4092
|
+
return json(res, { ok: true, id: s.id, initiative: next, attributed_by: s.attributedBy, previous });
|
|
4093
|
+
}
|
|
3997
4094
|
if (p === '/api/initiative/link' && req.method === 'POST') {
|
|
3998
4095
|
// Recording that an artifact corresponds to an external page. This writes a DECLARATION and
|
|
3999
4096
|
// makes no request: PMOS holds no credential, so it can state what the PM says the mapping is
|
|
@@ -4522,7 +4619,10 @@ wss.on('connection', (ws) => {
|
|
|
4522
4619
|
} else if (m.t === 'resize') {
|
|
4523
4620
|
const s = sessions.get(m.id); if (s && s.pty) { try { s.pty.resize(m.cols, m.rows); s.cols = m.cols; s.rows = m.rows; } catch {} }
|
|
4524
4621
|
} else if (m.t === 'new') {
|
|
4525
|
-
|
|
4622
|
+
// `initiative` is what the client has always held and never sent: `+ session` is clicked on
|
|
4623
|
+
// an initiative row, the tab carries its `slug`, and the spawn message named three other
|
|
4624
|
+
// fields. An absent or unusable value leaves the session unattributed — never a refused spawn.
|
|
4625
|
+
const r = spawnWithBranch({ name: m.name, agent: m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch, isolate: m.isolate, profile: m.profile, initiative: m.initiative });
|
|
4526
4626
|
// A refused profile is its own sentence, not "branch: …" — the composer's error line names
|
|
4527
4627
|
// what PMOS could not read, and nothing was started.
|
|
4528
4628
|
if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^profile /.test(r.error || '') ? '' : 'branch: ') + r.error })); return; }
|