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.
- package/README.md +27 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/README.md +33 -18
- package/workspace/bin/pmos.js +5 -1
- package/workspace/doctor.js +5 -1
- package/workspace/lib/skill-store.js +180 -6
- package/workspace/lib/state-root.js +587 -0
- package/workspace/lib/working-set.js +124 -28
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +2 -2
- package/workspace/public/pm/data.js +86 -0
- package/workspace/public/pm/panel-detail.js +174 -2
- package/workspace/public/pm/panel-initiatives.js +131 -16
- package/workspace/public/pm/panel-newsess.js +507 -0
- package/workspace/public/pm/panel-repos.js +31 -0
- package/workspace/public/pm/panel-tabs.js +34 -2
- package/workspace/public/pm/state.js +45 -1
- package/workspace/public/pm/tab-term.js +288 -15
- package/workspace/server.js +278 -24
- package/workspace/test-run.js +84 -0
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "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
|
-
|
|
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);
|