create-pmos 0.18.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/workspace/README.md +21 -1
- package/workspace/lib/tree-share.js +171 -13
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/index.html +6 -0
- package/workspace/public/pm/app.js +44 -7
- package/workspace/public/pm/data.js +30 -2
- package/workspace/public/pm/panel-tabs.js +192 -4
- package/workspace/public/pm/state.js +6 -0
- package/workspace/public/pm/tab-doc.js +5 -5
- package/workspace/public/pm/tab-task.js +1 -1
- package/workspace/public/pm/tab-term.js +17 -1
- package/workspace/server.js +68 -6
package/README.md
CHANGED
|
@@ -333,6 +333,36 @@ 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.19.0
|
|
337
|
+
|
|
338
|
+
**A long document scrolls its own panel, and starting a session asks what is already running.** Run
|
|
339
|
+
`npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
|
|
340
|
+
|
|
341
|
+
- **Opening a long document no longer scrolls the whole window.** Scrolling a document in the right
|
|
342
|
+
panel moved the entire interface — the top bar slid away, the status bar rose from below — while
|
|
343
|
+
the panel's own scrollbar never engaged. Every panel now scrolls inside itself and the app frame
|
|
344
|
+
never moves. (If you are curious: the shell's middle row was written `1fr`, which is
|
|
345
|
+
`minmax(auto, 1fr)`, so a tall document enlarged the row rather than overflowing it. Every style
|
|
346
|
+
involved read as correct, which is why it survived review.)
|
|
347
|
+
- **`+ session` now asks before it starts one, when the checkout is already busy.** It used to
|
|
348
|
+
start the session and *then* tell you how many others were in there — a count arriving after the
|
|
349
|
+
decision, naming something you cannot act on. Now you get a chooser first, listing every session
|
|
350
|
+
in that checkout.
|
|
351
|
+
- **It says what each one is.** Its name, its agent, the same word the sidebar uses (`working` ·
|
|
352
|
+
`quiet 12m` · `needs you` · `maybe blocked`), the agent's own last words, and who holds the writer
|
|
353
|
+
claim.
|
|
354
|
+
- **…and what ending it would cost, without over-claiming.** Only `needs you` reads as settled —
|
|
355
|
+
its turn finished and it is waiting on you. `working` says output is arriving right now. `quiet`
|
|
356
|
+
and `maybe blocked` say, in as many words, that the workspace *cannot tell* a finished turn from a
|
|
357
|
+
permission prompt from a stuck tool. A confident "safe to end" that discarded a running turn would
|
|
358
|
+
be worse than no reading at all.
|
|
359
|
+
- **You can take the running session instead of making another.** `open it` attaches a tab to it and
|
|
360
|
+
starts nothing. `end it` is there too — the first click arms it and names what it will end, the
|
|
361
|
+
second does it. `+ start another session here anyway` is always one click: being told is never
|
|
362
|
+
being blocked, and no session is ever refused because another is there.
|
|
363
|
+
- **The same detail is written into the new session's scrollback**, so scrolling back tomorrow
|
|
364
|
+
answers the same question the chooser did.
|
|
365
|
+
|
|
336
366
|
## What's new in 0.18.1
|
|
337
367
|
|
|
338
368
|
**Three fixes to the workspace terminal, all of them about what the window knew about a session.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → 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. `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
|
@@ -162,6 +162,24 @@ session in a tree that already has live ones now says so in the pane — naming
|
|
|
162
162
|
way out (⑂ a new branch in its own folder). It fires on the plain `+ session` path too, where no
|
|
163
163
|
git operation runs at all: the server already knew, and used to say nothing.
|
|
164
164
|
|
|
165
|
+
**`+ session` asks before it starts one.** A count arriving *after* the spawn is a fact you cannot
|
|
166
|
+
act on, and the answer you usually want is not "start another" at all — it is "which of those is the
|
|
167
|
+
one I was already working in?". So clicking `+ session` into an occupied tree opens a chooser
|
|
168
|
+
first, listing every session in that tree with:
|
|
169
|
+
|
|
170
|
+
- **what it is** — its name, its agent, the sidebar's own word for its state (`working` ·
|
|
171
|
+
`quiet 12m` · `needs you` · `maybe blocked`), the agent's last words, and the writer claim;
|
|
172
|
+
- **what ending it would cost** — one sentence per session, worded from the evidence. Only
|
|
173
|
+
`needs you` reads as settled ("its turn finished and it is waiting on you"). `quiet` and
|
|
174
|
+
`maybe blocked` say, in as many words, that PMOS *cannot tell* — a confident "safe" that
|
|
175
|
+
discards a running turn is worse than no reading at all;
|
|
176
|
+
- **`open it`** — attach a tab to that session and start nothing.
|
|
177
|
+
|
|
178
|
+
`end it` is there too, and is the only destructive control on the surface: two clicks, the first
|
|
179
|
+
arming it. `+ start another session here anyway` is always one click away — informing you is never
|
|
180
|
+
obstructing you. The same per-session detail is written into the new pane's scrollback, so scrolling
|
|
181
|
+
back tomorrow answers the same question the chooser did.
|
|
182
|
+
|
|
165
183
|
**The tree is the unit, not the folder you registered.** A repo registered twice — say `~/repo` and
|
|
166
184
|
`~/repo/packages/app`, the ordinary shape for a product repo added by path — is **one** tree, and
|
|
167
185
|
its sessions are told about each other. Two *worktrees* of one repo are **two** trees, and are not
|
|
@@ -178,7 +196,9 @@ Be clear about what this buys: **PMOS hosts your agent's terminal, it does not s
|
|
|
178
196
|
the files.** Nothing here prevents a write, a checkout or a commit, and no spawn is ever refused
|
|
179
197
|
because someone else is in the tree. It makes a shared tree visible; the discipline is still yours.
|
|
180
198
|
(The role rides on `/api/sessions` rows as `tree` / `tree_role` / `tree_peers`; the tab-bar chip is
|
|
181
|
-
not wired yet.
|
|
199
|
+
not wired yet. `/api/workspaces` publishes each registration's `tree`, which is what lets the
|
|
200
|
+
chooser answer "who is already in there?" *before* anything is started; `/api/tree/peers` returns
|
|
201
|
+
each session described — `shown`, `label`, `doing` and a `risk` — rather than a list of ids.)
|
|
182
202
|
|
|
183
203
|
A workspace anywhere **inside** a repo knows its branch — a subdirectory (`repo/packages/app`)
|
|
184
204
|
counts, which matters for a product repo added by path. A folder with **no git** simply says so and
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// Who shares a working tree with whom,
|
|
2
|
+
// Who shares a working tree with whom, who is DECLARED to be driving it, and — since
|
|
3
|
+
// workspace-session-choice — what each of those sessions is actually DOING and what ending it
|
|
4
|
+
// would cost.
|
|
3
5
|
//
|
|
4
6
|
// The premise, stated once so nothing below has to hedge: PMOS hosts real PTYs and the agent CLI
|
|
5
7
|
// inside one is untouched (D62). There is nothing between an agent and the filesystem, so this
|
|
@@ -21,6 +23,23 @@
|
|
|
21
23
|
//
|
|
22
24
|
// Pure functions over plain rows, on purpose: the server owns the git shell-outs and the session
|
|
23
25
|
// registry, and the rules are the part worth testing without either.
|
|
26
|
+
//
|
|
27
|
+
// ── ONE module, TWO consumers ────────────────────────────────────────────────────────────────
|
|
28
|
+
// This file is now a UMD, for the same reason lib/session-state.js is: the SERVER computes the
|
|
29
|
+
// co-tenancy sentence at spawn, and the BROWSER computes the same descriptions again when the PM
|
|
30
|
+
// clicks `+ session` into an occupied tree. A second copy of "what does ending this cost?" would
|
|
31
|
+
// be free to drift, and the two surfaces would end up holding different opinions about one
|
|
32
|
+
// session — the exact failure the session-state table was extracted to stop.
|
|
33
|
+
//
|
|
34
|
+
// What this module deliberately does NOT do is name a session's STATE. The vocabulary (working ·
|
|
35
|
+
// quiet · needs you · maybe blocked · exited) belongs to lib/session-state.js and arrives here
|
|
36
|
+
// already worded, on the row, as `shown` + `label`. Re-deriving a word here would be that second
|
|
37
|
+
// copy in a new costume.
|
|
38
|
+
(function (root, factory) {
|
|
39
|
+
const api = factory();
|
|
40
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
41
|
+
else root.PMOSTreeShare = api;
|
|
42
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
24
43
|
|
|
25
44
|
/** Live = a session the PM could still be driving. `exited` is a corpse and collides with nobody. */
|
|
26
45
|
function isLive(s) { return !!s && s.state !== 'exited'; }
|
|
@@ -73,18 +92,138 @@ function nameList(rows, cap = 3) {
|
|
|
73
92
|
return `${names.slice(0, cap).join(', ')} and ${names.length - cap} more`;
|
|
74
93
|
}
|
|
75
94
|
|
|
95
|
+
/** The same list, each pane carrying the word beside it — `label` when the caller supplied one. */
|
|
96
|
+
function statedList(rows, cap = 3) {
|
|
97
|
+
const parts = (rows || []).map((s) => (s && s.label ? `“${s.name}” (${s.label})` : `“${s.name}”`));
|
|
98
|
+
if (parts.length <= cap) return parts.join(', ');
|
|
99
|
+
return `${parts.slice(0, cap).join(', ')} and ${parts.length - cap} more`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A terse age, in the voice the sidebar already uses. `null` in, `null` out — never "0s". */
|
|
103
|
+
function ago(ms) {
|
|
104
|
+
if (ms == null || !Number.isFinite(ms) || ms < 0) return null;
|
|
105
|
+
if (ms < 60000) return `${Math.max(1, Math.round(ms / 1000))}s`;
|
|
106
|
+
if (ms < 3600000) return `${Math.round(ms / 60000)}m`;
|
|
107
|
+
return `${Math.round(ms / 360000) / 10}h`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* WHAT ENDING THIS SESSION WOULD COST — read only from what the workspace can actually observe.
|
|
112
|
+
*
|
|
113
|
+
* The PM's question, asked in as many words, is "is it safe to end?". The honest answer has three
|
|
114
|
+
* shapes and only one of them is a yes, so the levels are named for the EVIDENCE rather than for a
|
|
115
|
+
* recommendation:
|
|
116
|
+
*
|
|
117
|
+
* 'in-flight' — bytes are arriving right now. Ending it cuts a turn that is running. Not safe,
|
|
118
|
+
* and this one PMOS is sure of.
|
|
119
|
+
* 'settled' — the turn finished and the session is waiting on the human (`needs`). This is the
|
|
120
|
+
* only state where "nothing is in flight" is CORROBORATED — session-state reaches
|
|
121
|
+
* `needs` only from a transcript with no unresolved tool call, over a quiet clock.
|
|
122
|
+
* 'unknown' — quiet, or a tool call open with nothing moving. The workspace cannot tell a
|
|
123
|
+
* finished turn from a permission prompt from a hung tool, and this is the state it
|
|
124
|
+
* must not launder into a yes. Saying "probably idle" is the whole point: an
|
|
125
|
+
* over-confident "safe" here is the watermelon this repo has a word for.
|
|
126
|
+
* 'gone' — already exited; there is nothing left to end.
|
|
127
|
+
*
|
|
128
|
+
* `shown` is the presented state from lib/session-state.js. An unrecognised value falls to
|
|
129
|
+
* 'unknown', because a state this module has never heard of is precisely not a state it may
|
|
130
|
+
* declare safe.
|
|
131
|
+
*/
|
|
132
|
+
function endRisk(shown, quietMs) {
|
|
133
|
+
const age = ago(quietMs);
|
|
134
|
+
if (shown === 'exited') {
|
|
135
|
+
return { level: 'gone', why: 'already exited — there is nothing left to end' };
|
|
136
|
+
}
|
|
137
|
+
if (shown === 'working') {
|
|
138
|
+
return { level: 'in-flight', why: 'output is arriving right now — ending it cuts a turn that is still running' };
|
|
139
|
+
}
|
|
140
|
+
if (shown === 'needs') {
|
|
141
|
+
return {
|
|
142
|
+
level: 'settled',
|
|
143
|
+
why: 'its turn finished and it is waiting on you — ending it costs the scrollback, not work in flight',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (shown === 'blocked') {
|
|
147
|
+
return {
|
|
148
|
+
level: 'unknown',
|
|
149
|
+
why: `a tool call has been open${age ? ` ${age}` : ''} with nothing moving — PMOS cannot tell a permission prompt from a stuck tool, so ending it may or may not discard work`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
level: 'unknown',
|
|
154
|
+
why: `nothing has moved${age ? ` for ${age}` : ''}, and PMOS cannot see whether the turn finished — read this as “probably idle”, not “safe”`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* One co-tenant, described for a human who is deciding between joining it and starting another.
|
|
160
|
+
*
|
|
161
|
+
* Everything here is either copied off the row or derived by `endRisk`; nothing is invented. In
|
|
162
|
+
* particular `doing` is the agent's own last words as the sidebar already shows them, and it stays
|
|
163
|
+
* null when the workspace could not read them rather than becoming a cheerful placeholder.
|
|
164
|
+
*/
|
|
165
|
+
function describeTenant(row, writerId) {
|
|
166
|
+
const s = row || {};
|
|
167
|
+
const shown = s.shown || (s.state === 'exited' ? 'exited' : 'working');
|
|
168
|
+
return {
|
|
169
|
+
id: s.id || null,
|
|
170
|
+
ws: s.ws || null,
|
|
171
|
+
name: s.name || '(unnamed)',
|
|
172
|
+
agent: s.agent || null,
|
|
173
|
+
shown,
|
|
174
|
+
label: s.label || shown,
|
|
175
|
+
doing: s.doing || null,
|
|
176
|
+
branch: s.branch || null,
|
|
177
|
+
writer: !!(writerId && s.id === writerId),
|
|
178
|
+
risk: endRisk(shown, s.quietMs),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The whole answer to "you are about to start a session in a tree that is already busy".
|
|
184
|
+
*
|
|
185
|
+
* Returned as DATA rather than as a paragraph, because the two consumers need different shapes of
|
|
186
|
+
* it: the server writes a sentence into the pane's scrollback, and the browser draws a chooser with
|
|
187
|
+
* a button per peer. Both read the same `peers`, so a peer the chooser offers and a peer the pane
|
|
188
|
+
* line names can never be different sets.
|
|
189
|
+
*/
|
|
190
|
+
function coTenancyReport(mates, opts) {
|
|
191
|
+
const o = opts || {};
|
|
192
|
+
const peers = (mates || []).map((m) => describeTenant(m, o.writerId));
|
|
193
|
+
const n = peers.length;
|
|
194
|
+
return {
|
|
195
|
+
tree: o.tree || null,
|
|
196
|
+
branch: o.branch || null,
|
|
197
|
+
peers,
|
|
198
|
+
headline: n
|
|
199
|
+
? `${n} session${n === 1 ? '' : 's'} already running in this working tree`
|
|
200
|
+
: 'nothing else is running in this working tree',
|
|
201
|
+
// Stated once, here, so every surface that renders this report carries the same limit.
|
|
202
|
+
shared: 'you share one branch, one index and one set of files with '
|
|
203
|
+
+ (n === 1 ? 'it' : 'them'),
|
|
204
|
+
advisory: 'Claims are advisory: PMOS hosts your agent’s terminal, it does not sit between it and the files.',
|
|
205
|
+
escape: 'To work in parallel for real, start the session on a new branch with its own folder (⑂).',
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
76
209
|
/**
|
|
77
210
|
* The sentence a session gets when it starts in a tree somebody is already working in.
|
|
78
211
|
*
|
|
79
|
-
* It names the panes
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
212
|
+
* It names the panes AND what each of them is doing — a name alone tells the PM which pane, but not
|
|
213
|
+
* whether walking over to it is a good idea. `label` is the sidebar's own word for the session
|
|
214
|
+
* (working · quiet 12m · needs you · maybe blocked), passed in rather than re-derived, so this
|
|
215
|
+
* sentence and the sidebar cannot disagree. Rows with no label degrade to bare names rather than
|
|
216
|
+
* inventing a state.
|
|
217
|
+
*
|
|
218
|
+
* It states the ONE fact that makes this matter — a shared branch, index and file set — and names
|
|
219
|
+
* the way out that already exists, because a warning with no alternative is just a nag. Returns
|
|
220
|
+
* null when there is nobody there, so the caller never has to test for emptiness.
|
|
83
221
|
*/
|
|
84
222
|
function coTenancyNotice(mates, opts) {
|
|
85
223
|
const rows = mates || [];
|
|
86
224
|
if (!rows.length) return null;
|
|
87
225
|
const n = rows.length;
|
|
226
|
+
const rep = coTenancyReport(rows, opts);
|
|
88
227
|
const role = opts && opts.role === 'writer' ? 'writer' : 'reader';
|
|
89
228
|
const held = opts && opts.writerName;
|
|
90
229
|
const who = role === 'writer'
|
|
@@ -94,23 +233,40 @@ function coTenancyNotice(mates, opts) {
|
|
|
94
233
|
: held
|
|
95
234
|
? `“${held}” holds the writer claim`
|
|
96
235
|
: 'no session holds the writer claim';
|
|
97
|
-
return `${
|
|
98
|
-
+
|
|
99
|
-
+
|
|
100
|
-
+ '
|
|
236
|
+
return `${rep.headline} (${statedList(rows)}) — ${rep.shared}. ${who}. `
|
|
237
|
+
+ `${rep.advisory} ${rep.escape} `
|
|
238
|
+
+ `To join ${n === 1 ? 'it' : 'one of them'} instead of starting another, open it from + session — `
|
|
239
|
+
+ 'the chooser says what each one is doing and what ending it would cost.';
|
|
101
240
|
}
|
|
102
241
|
|
|
103
242
|
/**
|
|
104
243
|
* The same fact, written into the pane's own scrollback at spawn.
|
|
105
244
|
*
|
|
106
245
|
* The notice frame is a transient line in whichever client was listening; this one is part of the
|
|
107
|
-
* session's record, so re-opening the tab tomorrow still shows what it was told. Dim,
|
|
108
|
-
*
|
|
246
|
+
* session's record, so re-opening the tab tomorrow still shows what it was told. Dim, and it never
|
|
247
|
+
* claims anything was prevented.
|
|
248
|
+
*
|
|
249
|
+
* It is no longer ONE line. The sentence is followed by a row per peer carrying that peer's state
|
|
250
|
+
* and what ending it would cost — the same `peers` the chooser draws, in the same order, because
|
|
251
|
+
* the PM who scrolls back to this tomorrow is asking the identical question they asked at the
|
|
252
|
+
* chooser and deserves the identical answer. Bounded at four rows so a twelve-session tree cannot
|
|
253
|
+
* push the shell's first prompt off the screen; the sentence above already says the true count.
|
|
109
254
|
*/
|
|
255
|
+
const ADVISORY_ROWS = 4;
|
|
110
256
|
function paneAdvisory(mates, opts) {
|
|
111
257
|
const line = coTenancyNotice(mates, opts);
|
|
112
258
|
if (!line) return null;
|
|
113
|
-
|
|
259
|
+
const rep = coTenancyReport(mates, opts);
|
|
260
|
+
const shown = rep.peers.slice(0, ADVISORY_ROWS);
|
|
261
|
+
const rows = shown.map((p) => {
|
|
262
|
+
const who = `${p.name}${p.writer ? ' (writer)' : ''}`;
|
|
263
|
+
const doing = p.doing ? ` · ${p.doing}` : '';
|
|
264
|
+
return `\x1b[38;5;244m · ${who} — ${p.label}${doing}; ${p.risk.why}\x1b[0m\r\n`;
|
|
265
|
+
});
|
|
266
|
+
const more = rep.peers.length > shown.length
|
|
267
|
+
? [`\x1b[38;5;244m · …and ${rep.peers.length - shown.length} more, listed in + session\x1b[0m\r\n`]
|
|
268
|
+
: [];
|
|
269
|
+
return `\x1b[38;5;244m⑂ ${line}\x1b[0m\r\n` + rows.join('') + more.join('');
|
|
114
270
|
}
|
|
115
271
|
|
|
116
272
|
/**
|
|
@@ -126,4 +282,6 @@ function joinNotices(...parts) {
|
|
|
126
282
|
return kept.length ? kept.join(' · ') : null;
|
|
127
283
|
}
|
|
128
284
|
|
|
129
|
-
|
|
285
|
+
return { isLive, coTenants, writerOf, roleOf, coTenancyNotice, paneAdvisory, joinNotices,
|
|
286
|
+
nameList, statedList, ago, endRisk, describeTenant, coTenancyReport, ADVISORY_ROWS };
|
|
287
|
+
});
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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.19.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -29,6 +29,12 @@
|
|
|
29
29
|
holding different opinions about one session. Loaded before the module graph so a panel can
|
|
30
30
|
bind from window.PMOSSessionState rather than re-typing a word. -->
|
|
31
31
|
<script src="/lib/session-state.js"></script>
|
|
32
|
+
<!-- ONE module, TWO consumers, again (workspace-session-choice). Who shares a working tree, and
|
|
33
|
+
what ending one of those sessions would cost, is computed by the SERVER when it writes the
|
|
34
|
+
advisory into a new pane's scrollback and by the BROWSER when it draws the + session chooser.
|
|
35
|
+
Both read this file, so the sentence in the terminal and the sentence in the chooser cannot
|
|
36
|
+
drift apart. Classic script (a UMD setting window.PMOSTreeShare), like the two above. -->
|
|
37
|
+
<script src="/lib/tree-share.js"></script>
|
|
32
38
|
<!-- The phone's navigation reducer. DOM-free, loaded as a classic script (it is a UMD that sets
|
|
33
39
|
window.PMOSMobileNav) and required by test.js, so the whole screen domain is walkable in node
|
|
34
40
|
rather than only in a browser. pm/mobile.js reads it from the global; the module graph does not
|
|
@@ -83,9 +83,35 @@ function runningCount() {
|
|
|
83
83
|
|
|
84
84
|
/* ── the shell, built once ─────────────────────────────────────────────────────────────────── */
|
|
85
85
|
|
|
86
|
+
/* ── Why every track and every column here is `minmax(0, …)` / `min-height:0` ─────────────────
|
|
87
|
+
*
|
|
88
|
+
* The shell is 100vh and nothing in it is supposed to scroll the PAGE — a panel scrolls, the app
|
|
89
|
+
* never does. That held right up until a long document was opened in panel 4, and then the whole
|
|
90
|
+
* user interface scrolled: the top bar slid away, the status bar came up from below, and the doc
|
|
91
|
+
* pane's own `overflow-y:auto` never engaged.
|
|
92
|
+
*
|
|
93
|
+
* The cause is one CSS default, in two guises, and it is worth naming precisely because it is
|
|
94
|
+
* invisible in a style string that looks correct:
|
|
95
|
+
*
|
|
96
|
+
* • `grid-template-rows: 44px 1fr 26px` — `1fr` is shorthand for `minmax(auto, 1fr)`, and that
|
|
97
|
+
* `auto` MINIMUM is the item's min-content height. A tall document therefore does not overflow
|
|
98
|
+
* the middle row; it ENLARGES it, past the 100vh the row was supposed to be trapped inside.
|
|
99
|
+
* • a grid item / flex item with `min-height: auto` cannot shrink below its content either, so
|
|
100
|
+
* even a correctly-sized track hands the overflow straight through to the next box down.
|
|
101
|
+
*
|
|
102
|
+
* Overflow that is `visible` still contributes to the SCROLLABLE overflow region of every ancestor
|
|
103
|
+
* that does not clip it, all the way up to the viewport — which is exactly the reported symptom:
|
|
104
|
+
* the page scrolls, not the pane. So the fix is the whole chain, not one box. Each ancestor between
|
|
105
|
+
* the viewport and a scrolling pane declares a zero minimum, and the root clips as the backstop so
|
|
106
|
+
* a future panel that forgets cannot reach the page again.
|
|
107
|
+
*
|
|
108
|
+
* The design prototype had this right (`grid-template-rows:44px minmax(0,1fr) 28px`, see
|
|
109
|
+
* prototype/workspace-layout-variants/*.html); the port dropped the `minmax(0, …)` and read
|
|
110
|
+
* identically until something tall was rendered into it.
|
|
111
|
+
*/
|
|
86
112
|
const root = el('div', {
|
|
87
113
|
'data-pm-theme': state.theme,
|
|
88
|
-
style: 'display:grid;grid-template-rows:44px 1fr 26px;height:100vh;background:var(--background);color:var(--foreground);font-family:var(--sans);font-size:13px;line-height:1.5',
|
|
114
|
+
style: 'display:grid;grid-template-rows:44px minmax(0,1fr) 26px;height:100vh;overflow:hidden;background:var(--background);color:var(--foreground);font-family:var(--sans);font-size:13px;line-height:1.5',
|
|
89
115
|
});
|
|
90
116
|
|
|
91
117
|
const themeBtn = el('button', {
|
|
@@ -113,7 +139,14 @@ const statusBar = el('div', {
|
|
|
113
139
|
el('span', { style: 'margin-left:auto' }, 'sessions survive detach · ⌘K search · Esc closes tab'),
|
|
114
140
|
);
|
|
115
141
|
|
|
116
|
-
|
|
142
|
+
/* The body grid's own geometry, in one place so the initial build and every repaint below cannot
|
|
143
|
+
* disagree. The implicit ROW is stated explicitly as `minmax(0,1fr)` for the same reason the root's
|
|
144
|
+
* middle track is: an auto row is floored at its items' min-content height, and one tall panel
|
|
145
|
+
* would push the grid taller than the box it was given. */
|
|
146
|
+
const BODY_GRID_STYLE = () =>
|
|
147
|
+
'display:grid;grid-template-columns:' + gridCols() + ';grid-template-rows:minmax(0,1fr);min-height:0;overflow:hidden';
|
|
148
|
+
|
|
149
|
+
const bodyGrid = el('div', { style: BODY_GRID_STYLE() });
|
|
117
150
|
|
|
118
151
|
/** The collapsed 36px strip: a full-height button with a rotated vertical-rl label. */
|
|
119
152
|
function makeRail(flag) {
|
|
@@ -131,10 +164,14 @@ const rail1 = makeRail('col1');
|
|
|
131
164
|
const rail2 = makeRail('col2');
|
|
132
165
|
rail0.label.textContent = 'repositories ⟩';
|
|
133
166
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
167
|
+
/* `min-height:0` on all four: a grid item's automatic minimum is its content, so a panel taller
|
|
168
|
+
* than the viewport would otherwise stretch the row it sits in rather than scroll inside it. The
|
|
169
|
+
* two that already carry `overflow-y:auto` get it for free under the spec — stated anyway, because
|
|
170
|
+
* "this one is safe because of a rule elsewhere in the same line" is how the next edit breaks it. */
|
|
171
|
+
const hostRepos = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;min-width:0;min-height:0;overflow-y:auto' });
|
|
172
|
+
const hostInits = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0' });
|
|
173
|
+
const hostDetail = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0;display:flex;flex-direction:column' });
|
|
174
|
+
const hostTabs = el('div', { style: 'background:var(--term-bg);display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden' });
|
|
138
175
|
|
|
139
176
|
setChildren(root, [topBar, bodyGrid, statusBar]);
|
|
140
177
|
|
|
@@ -172,7 +209,7 @@ function render() {
|
|
|
172
209
|
rail1.label.textContent = modeTitle() + ' ⟩';
|
|
173
210
|
rail2.label.textContent = detailTitle() + ' ⟩';
|
|
174
211
|
|
|
175
|
-
bodyGrid.setAttribute('style',
|
|
212
|
+
bodyGrid.setAttribute('style', BODY_GRID_STYLE());
|
|
176
213
|
|
|
177
214
|
// On a phone there are no rails to collapse into and no second column to fold away: the four
|
|
178
215
|
// hosts all stay mounted, one is DISPLAYED (pm/mobile.css picks it from `data-mpanel`), and the
|
|
@@ -277,9 +277,13 @@ function evalFor(ladderSteps) {
|
|
|
277
277
|
|
|
278
278
|
/** A live session, in the shape panel 2 and panel 4 read. `shown` is the server's own derived state. */
|
|
279
279
|
function sessionRow(s) {
|
|
280
|
-
|
|
280
|
+
// `s.label` is the SERVER's rendering of the presented state — one join (lib/session-state's
|
|
281
|
+
// `label`), one clock, one sentence. This used to be re-derived here from `shown` + `quietMs`,
|
|
282
|
+
// which is how the sidebar and the pane could describe one session with two different words. The
|
|
283
|
+
// local derivation survives only as a fallback for a server that predates the field.
|
|
284
|
+
const label = s.label || (s.shown === 'quiet' && Number.isFinite(s.quietMs)
|
|
281
285
|
? 'quiet ' + Math.max(1, Math.round(s.quietMs / 60000)) + 'm'
|
|
282
|
-
: (s.shown === 'needs' ? 'needs you' : s.shown);
|
|
286
|
+
: (s.shown === 'needs' ? 'needs you' : s.shown));
|
|
283
287
|
return { name: s.name, st: s.shown, label, profile: s.profile_id || null, id: s.id };
|
|
284
288
|
}
|
|
285
289
|
|
|
@@ -441,10 +445,34 @@ function groupsFromSpine(spine) {
|
|
|
441
445
|
const products = new Map(); // product name → {name, ws, root, count, board, records}
|
|
442
446
|
let productOrder = [];
|
|
443
447
|
|
|
448
|
+
/* workspace id → the working-tree top it sits in (null for a folder with no git).
|
|
449
|
+
*
|
|
450
|
+
* This exists so panel 4 can answer "who else is already in the tree I am about to start a session
|
|
451
|
+
* in?" BEFORE it starts one. The session rows carry `tree` too, but only for trees that already
|
|
452
|
+
* have a session — which is one session too late for a chooser that runs on the FIRST click. The
|
|
453
|
+
* server derives this (`git rev-parse --show-toplevel`, via `repoIdentity`) and publishes it on
|
|
454
|
+
* /api/workspaces; it is read here and never re-derived. */
|
|
455
|
+
const wsTree = new Map();
|
|
456
|
+
|
|
457
|
+
/** The working tree a workspace id sits in, or null — for a folder with no git, or an unknown id. */
|
|
458
|
+
export function treeOfWorkspace(id) {
|
|
459
|
+
return (id && wsTree.get(id)) || null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** The branch that tree is on, or null. Shown beside the tree so a chooser names what is shared. */
|
|
463
|
+
const wsBranch = new Map();
|
|
464
|
+
export function branchOfWorkspace(id) {
|
|
465
|
+
return (id && wsBranch.get(id)) || null;
|
|
466
|
+
}
|
|
467
|
+
|
|
444
468
|
/** Group registered working trees into products: several worktrees of one repo share a `repo_key`. */
|
|
445
469
|
function foldWorkspaces(rows) {
|
|
446
470
|
const byRepo = new Map();
|
|
447
471
|
for (const w of rows || []) {
|
|
472
|
+
// Recorded per REGISTERED id, before the fold — folding is by repository, and the whole point
|
|
473
|
+
// of the tree key is that two registered ids can share one tree while a repo's two worktrees do
|
|
474
|
+
// not. Reading it off the folded row would lose exactly that distinction.
|
|
475
|
+
if (w && w.id) { wsTree.set(w.id, w.tree || null); wsBranch.set(w.id, w.branch || null); }
|
|
448
476
|
const key = w.repo_key || w.root;
|
|
449
477
|
if (!byRepo.has(key)) {
|
|
450
478
|
byRepo.set(key, {
|
|
@@ -173,14 +173,68 @@ function nextSessionName() {
|
|
|
173
173
|
return { n: max + 1, name: 'agent ' + (max + 1) };
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/* ── who is already in the tree this session would land in ───────────────────────────────────
|
|
177
|
+
*
|
|
178
|
+
* The workspace used to answer this AFTER the fact: it started the session, then wrote "2 sessions
|
|
179
|
+
* already running in this working tree" into the new pane. That sentence is true and arrives one
|
|
180
|
+
* step too late — the PM has already made the thing they might not have wanted, and the sentence
|
|
181
|
+
* names a count they cannot act on. Worse, the answer they usually want is not "start another"
|
|
182
|
+
* at all; it is "which of those is the one I was already working in?".
|
|
183
|
+
*
|
|
184
|
+
* So the same facts are computed BEFORE the spawn, from data the page already holds:
|
|
185
|
+
* • the tree the new session would land in — data.js publishes it per registered workspace id
|
|
186
|
+
* (`/api/workspaces` → `tree`), which is available even when that tree has no session yet;
|
|
187
|
+
* • the live sessions in it — `state.sessions`, each row carrying the server's own `tree`,
|
|
188
|
+
* `tree_role`, presented state and rendered label;
|
|
189
|
+
* • what ending each one would cost — lib/tree-share's `endRisk`, the same module the server uses
|
|
190
|
+
* for the pane advisory, so the two readings are one reading.
|
|
191
|
+
*
|
|
192
|
+
* Nothing is derived twice and nothing is guessed. When any input is missing — fixture mode, no
|
|
193
|
+
* workspace selected, a folder with no git, the shared module not loaded — this answers "nobody",
|
|
194
|
+
* and `+ session` behaves exactly as it always did. A chooser that appears on a bad guess would be
|
|
195
|
+
* worse than no chooser.
|
|
196
|
+
*/
|
|
197
|
+
function treePeersNow() {
|
|
198
|
+
if (FIXTURE) return null;
|
|
199
|
+
const TS = typeof window !== 'undefined' ? window.PMOSTreeShare : null;
|
|
200
|
+
if (!TS) return null;
|
|
201
|
+
const ws = state.wsId || null;
|
|
202
|
+
const tree = ws && dataMod && typeof dataMod.treeOfWorkspace === 'function'
|
|
203
|
+
? dataMod.treeOfWorkspace(ws) : null;
|
|
204
|
+
if (!tree) return null; // no git, or nothing selected — no index to share
|
|
205
|
+
const rows = Array.isArray(state.sessions) ? state.sessions : [];
|
|
206
|
+
// The map coTenants() keys on. Built from each row's OWN `tree`, which the server derived — the
|
|
207
|
+
// client never runs git and must not pretend to. The target workspace is added explicitly so a
|
|
208
|
+
// tree whose only registration has no session yet still resolves.
|
|
209
|
+
const treeOfWs = new Map([[ws, tree]]);
|
|
210
|
+
for (const r of rows) if (r && r.ws && !treeOfWs.has(r.ws)) treeOfWs.set(r.ws, r.tree || null);
|
|
211
|
+
const mates = TS.coTenants(rows, treeOfWs, tree, null);
|
|
212
|
+
if (!mates.length) return null;
|
|
213
|
+
// The declared writer, read off the row the server already marked rather than recomputed.
|
|
214
|
+
const held = rows.find((r) => r && r.tree === tree && r.tree_role === 'writer');
|
|
215
|
+
return {
|
|
216
|
+
tree,
|
|
217
|
+
branch: (dataMod && typeof dataMod.branchOfWorkspace === 'function' && dataMod.branchOfWorkspace(ws)) || null,
|
|
218
|
+
peers: mates.map((m) => TS.describeTenant(m, held ? held.id : null)),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
176
222
|
/**
|
|
177
223
|
* `+ session` (line 2087): file a new session against the selected initiative and open its tab.
|
|
178
224
|
* The session lands in `extraSess` — the prototype's own list of sessions started in this window —
|
|
179
225
|
* and the terminal tab is opened in the callback, i.e. after the render that added it.
|
|
226
|
+
*
|
|
227
|
+
* `opts.force` skips the chooser. It is what the chooser's own "start another session here anyway"
|
|
228
|
+
* calls, so the guard is asked exactly once per click and a PM who has already answered the
|
|
229
|
+
* question is never asked it twice.
|
|
180
230
|
*/
|
|
181
|
-
export function newSession(slug) {
|
|
231
|
+
export function newSession(slug, opts) {
|
|
182
232
|
const target = slug || selSlug();
|
|
183
233
|
if (!target) return;
|
|
234
|
+
if (!(opts && opts.force)) {
|
|
235
|
+
const found = treePeersNow();
|
|
236
|
+
if (found) { setState({ startPrompt: { slug: target, ...found }, endConfirm: null }); return; }
|
|
237
|
+
}
|
|
184
238
|
const { n, name } = nextSessionName();
|
|
185
239
|
/* `spawn` is carried on the session and thence onto the tab: it is an INSTRUCTION to start a PTY,
|
|
186
240
|
* and it is the only thing that authorises one. Without it the terminal resolves by name, which
|
|
@@ -188,6 +242,8 @@ export function newSession(slug) {
|
|
|
188
242
|
const sess = { name, st: 'working', label: 'working', profile: state.newSessProfile, spawn: true };
|
|
189
243
|
setState((s) => ({
|
|
190
244
|
startN: n + 1,
|
|
245
|
+
startPrompt: null,
|
|
246
|
+
endConfirm: null,
|
|
191
247
|
extraSess: { ...s.extraSess, [target]: [...(s.extraSess[target] || []), sess] },
|
|
192
248
|
}), () => openTermTab(target, sess));
|
|
193
249
|
if (!FIXTURE && dataMod && typeof dataMod.startSession === 'function') {
|
|
@@ -195,6 +251,25 @@ export function newSession(slug) {
|
|
|
195
251
|
}
|
|
196
252
|
}
|
|
197
253
|
|
|
254
|
+
/** Join a session that is already running instead of starting one: open its tab, start nothing. */
|
|
255
|
+
export function joinSession(slug, peer) {
|
|
256
|
+
if (!peer) return;
|
|
257
|
+
setState({ startPrompt: null, endConfirm: null }, () => openTermTab(slug, {
|
|
258
|
+
name: peer.name, st: peer.shown, profile: peer.profile || null,
|
|
259
|
+
/* The SERVER's id, and NO `spawn`. Both halves matter and they are the exact difference
|
|
260
|
+
* between joining and starting: `id` is the join, so the tab attaches to precisely the PTY the
|
|
261
|
+
* PM pointed at rather than matching by a name that repeats across repositories; the absent
|
|
262
|
+
* `spawn` is what withholds authorisation to create one. A peer described by
|
|
263
|
+
* treeShare.describeTenant always carries its id, so this is never a name-match in practice. */
|
|
264
|
+
id: peer.id || null,
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Dismiss the chooser without starting or joining anything. */
|
|
269
|
+
export function dismissStartPrompt() {
|
|
270
|
+
setState({ startPrompt: null, endConfirm: null });
|
|
271
|
+
}
|
|
272
|
+
|
|
198
273
|
/** `⫽ split` / `⫽ unsplit` (lines 2088–2092). */
|
|
199
274
|
export function toggleSplit() {
|
|
200
275
|
setState((s) => {
|
|
@@ -226,7 +301,7 @@ const emptyState = el('div', {
|
|
|
226
301
|
style: 'flex:1;display:grid;place-items:center;color:oklch(0.55 0.02 213);font-size:12.5px;padding:20px;text-align:center',
|
|
227
302
|
}, "nothing open — click a task's output or an artifact, or + session above");
|
|
228
303
|
|
|
229
|
-
const panesRow = el('div', { style: 'flex:1;display:flex;min-height:0' });
|
|
304
|
+
const panesRow = el('div', { style: 'flex:1;display:flex;min-height:0;min-width:0;overflow:hidden' });
|
|
230
305
|
|
|
231
306
|
/* ── the scratchpad (lines 983–991) ──────────────────────────────────────────────────────────
|
|
232
307
|
* One textarea, created once. Its text is written back as a CHILD TEXT NODE as well as a value,
|
|
@@ -432,9 +507,117 @@ function copyActive() {
|
|
|
432
507
|
setTimeout(() => setState({ copied: false }), 1200);
|
|
433
508
|
}
|
|
434
509
|
|
|
510
|
+
/* ── the + session chooser ──────────────────────────────────────────────────────────────────
|
|
511
|
+
*
|
|
512
|
+
* Shown INSTEAD of a spawn when the working tree already has live sessions. It answers, in this
|
|
513
|
+
* order, the three questions the old one-line notice left the PM holding:
|
|
514
|
+
*
|
|
515
|
+
* what is running? — every peer named, with the sidebar's own word for its state, the
|
|
516
|
+
* agent's last words, and the writer claim where one is held.
|
|
517
|
+
* is it safe to end? — one sentence per peer from lib/tree-share's `endRisk`, worded from the
|
|
518
|
+
* EVIDENCE. Only `needs you` gets a plain "nothing is in flight"; quiet
|
|
519
|
+
* and maybe-blocked say, in as many words, that the workspace cannot
|
|
520
|
+
* tell — because a confident "safe" that discards a running turn is
|
|
521
|
+
* exactly the false confidence this repo calls a watermelon.
|
|
522
|
+
* can I just use it? — `open it`, which attaches a tab to that session and starts NOTHING.
|
|
523
|
+
*
|
|
524
|
+
* `end it` is the one destructive control on this surface and it is two clicks: the first arms it
|
|
525
|
+
* and the button says what will happen, the second sends it. Nothing else here can lose work.
|
|
526
|
+
*
|
|
527
|
+
* Rebuilt on each render, deliberately: it holds no PTY and no caret, so there is nothing for a
|
|
528
|
+
* persistent node to protect, and a chooser rebuilt from state cannot show a session that has
|
|
529
|
+
* since exited.
|
|
530
|
+
*/
|
|
531
|
+
const RISK_COLOR = {
|
|
532
|
+
'in-flight': 'var(--warning)',
|
|
533
|
+
settled: 'var(--success)',
|
|
534
|
+
unknown: 'var(--muted-foreground)',
|
|
535
|
+
gone: 'var(--muted-foreground)',
|
|
536
|
+
};
|
|
537
|
+
const CH_BTN = 'flex:none;font-family:var(--mono);font-size:10.5px;padding:4px 10px;min-height:26px;'
|
|
538
|
+
+ 'background:oklch(1 0 0 / 6%);border:1px solid oklch(1 0 0 / 12%);color:oklch(0.85 0.01 214);white-space:nowrap';
|
|
539
|
+
|
|
540
|
+
function peerRow(prompt, p) {
|
|
541
|
+
const armed = state.endConfirm === p.id;
|
|
542
|
+
return el('div', {
|
|
543
|
+
style: 'display:flex;gap:10px;align-items:flex-start;padding:8px 10px;border:1px solid oklch(1 0 0 / 10%);background:oklch(1 0 0 / 3%)',
|
|
544
|
+
},
|
|
545
|
+
el('span', { style: 'flex:none;font-size:9px;line-height:18px;color:' + RISK_COLOR[p.risk.level] }, '●'),
|
|
546
|
+
el('div', { style: 'flex:1;min-width:0;display:flex;flex-direction:column;gap:2px' },
|
|
547
|
+
el('div', { style: 'display:flex;gap:8px;align-items:baseline;flex-wrap:wrap' },
|
|
548
|
+
el('span', { style: 'font-family:var(--mono);font-size:11.5px;color:oklch(0.95 0.01 214)' }, iv(p.name)),
|
|
549
|
+
el('span', { style: 'font-family:var(--mono);font-size:10.5px;color:oklch(0.72 0.014 214)' }, iv(p.label)),
|
|
550
|
+
p.agent ? el('span', { style: 'font-family:var(--mono);font-size:10px;color:oklch(0.55 0.02 213)' }, iv(p.agent)) : null,
|
|
551
|
+
p.writer ? el('span', {
|
|
552
|
+
style: 'font-family:var(--mono);font-size:9.5px;color:var(--brand);border:1px solid oklch(1 0 0 / 12%);padding:1px 5px',
|
|
553
|
+
}, 'writer') : null,
|
|
554
|
+
),
|
|
555
|
+
// The agent's own last words, when the workspace could read them. Absent rather than
|
|
556
|
+
// substituted: a placeholder here would be a claim about a session nobody read.
|
|
557
|
+
p.doing ? el('div', {
|
|
558
|
+
style: 'font-family:var(--mono);font-size:10.5px;color:oklch(0.62 0.02 213);overflow:hidden;text-overflow:ellipsis;white-space:nowrap',
|
|
559
|
+
}, iv(p.doing)) : null,
|
|
560
|
+
el('div', { style: 'font-size:11px;color:' + RISK_COLOR[p.risk.level] + ';line-height:1.45' }, iv(p.risk.why)),
|
|
561
|
+
),
|
|
562
|
+
el('div', { style: 'flex:none;display:flex;gap:6px;align-items:center' },
|
|
563
|
+
el('button', {
|
|
564
|
+
onClick: () => joinSession(prompt.slug, p),
|
|
565
|
+
style: CH_BTN + ';color:var(--brand)', hover: CHIP_HOVER,
|
|
566
|
+
}, 'open it'),
|
|
567
|
+
el('button', {
|
|
568
|
+
onClick: () => {
|
|
569
|
+
if (!armed) { setState({ endConfirm: p.id }); return; }
|
|
570
|
+
if (termMod && typeof termMod.killSession === 'function') termMod.killSession(p.id);
|
|
571
|
+
setState({ endConfirm: null });
|
|
572
|
+
},
|
|
573
|
+
style: CH_BTN + (armed ? ';color:var(--destructive);border-color:var(--destructive)' : ''),
|
|
574
|
+
hover: CHIP_HOVER,
|
|
575
|
+
}, iv(armed ? 'end “' + p.name + '” — click again' : 'end it')),
|
|
576
|
+
),
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function startPromptBlock() {
|
|
581
|
+
const prompt = state.startPrompt;
|
|
582
|
+
if (!prompt) return null;
|
|
583
|
+
const peers = prompt.peers || [];
|
|
584
|
+
const n = peers.length;
|
|
585
|
+
return el('div', {
|
|
586
|
+
style: 'flex:none;max-height:52vh;overflow-y:auto;display:flex;flex-direction:column;gap:8px;'
|
|
587
|
+
+ 'padding:12px 14px;border-bottom:1px solid oklch(1 0 0 / 10%);background:oklch(0.11 0.004 228.8)',
|
|
588
|
+
},
|
|
589
|
+
el('div', { style: 'display:flex;gap:8px;align-items:baseline;flex-wrap:wrap' },
|
|
590
|
+
el('span', { style: 'font-size:12.5px;color:oklch(0.95 0.01 214)' },
|
|
591
|
+
iv('⑂ ' + n + ' session' + (n === 1 ? '' : 's') + ' already running in this working tree')),
|
|
592
|
+
el('span', { style: 'font-family:var(--mono);font-size:10.5px;color:oklch(0.55 0.02 213);overflow-wrap:anywhere' },
|
|
593
|
+
iv(prompt.tree + (prompt.branch ? ' · ' + prompt.branch : ''))),
|
|
594
|
+
),
|
|
595
|
+
el('div', { style: 'font-size:11.5px;color:oklch(0.72 0.014 214);line-height:1.5' },
|
|
596
|
+
iv('You would share one branch, one index and one set of files with '
|
|
597
|
+
+ (n === 1 ? 'it' : 'them') + '. Open one instead, end one, or start another anyway.')),
|
|
598
|
+
peers.map((p) => peerRow(prompt, p)),
|
|
599
|
+
el('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;padding-top:2px' },
|
|
600
|
+
el('button', {
|
|
601
|
+
onClick: () => newSession(prompt.slug, { force: true }),
|
|
602
|
+
style: CH_BTN, hover: CHIP_HOVER,
|
|
603
|
+
}, '+ start another session here anyway'),
|
|
604
|
+
el('button', { onClick: () => dismissStartPrompt(), style: CH_BTN, hover: CHIP_HOVER }, 'cancel'),
|
|
605
|
+
),
|
|
606
|
+
// The limit, where the PM meets the feature — never only in a comment. PMOS hosts the terminal;
|
|
607
|
+
// it does not sit between an agent and the files, and no button above changes that.
|
|
608
|
+
el('div', { style: 'font-size:10.5px;color:oklch(0.52 0.02 213);line-height:1.5' },
|
|
609
|
+
'Nothing here is enforced: PMOS hosts your agent’s terminal, it does not sit between it and '
|
|
610
|
+
+ 'the files. To work in parallel for real, start on a new branch with its own folder (⑂).'),
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
435
614
|
/* ── pane hosts ────────────────────────────────────────────────────────────────────────────── */
|
|
436
615
|
|
|
437
|
-
|
|
616
|
+
/* `min-height:0` is load-bearing, not tidiness. A pane is a flex COLUMN whose body declares
|
|
617
|
+
* `flex:1;overflow-y:auto` — and a flex item's automatic minimum is its content, so without this
|
|
618
|
+
* the pane grows to fit a long document instead of letting the document scroll inside it, and the
|
|
619
|
+
* growth escapes upward until the whole page scrolls. See the chain documented in pm/app.js. */
|
|
620
|
+
const PANE_STYLE = 'flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;border-right:1px solid oklch(1 0 0 / 10%)';
|
|
438
621
|
const paneHosts = new Map(); // tab id -> the pane element, kept for the tab's whole life
|
|
439
622
|
|
|
440
623
|
function paneFor(tab) {
|
|
@@ -618,7 +801,12 @@ export function renderTabs(host) {
|
|
|
618
801
|
|
|
619
802
|
setChildren(host, [
|
|
620
803
|
tabBar,
|
|
621
|
-
|
|
804
|
+
// Above the panes and below the tab bar: the chooser is about the click that was just made in
|
|
805
|
+
// that bar, and putting it here leaves every open pane — and every live PTY — exactly where it
|
|
806
|
+
// was. Replacing the panes with it would re-parent a terminal, which is the quiet regression
|
|
807
|
+
// the persistent pane hosts exist to prevent.
|
|
808
|
+
startPromptBlock(),
|
|
809
|
+
visTabs.length === 0 && !state.startPrompt ? emptyState : null,
|
|
622
810
|
panesRow,
|
|
623
811
|
state.scratchOpen ? scratchBlock : null,
|
|
624
812
|
bottomBar,
|
|
@@ -38,6 +38,12 @@ function initial() {
|
|
|
38
38
|
tabs: [], active: null, splitId: null,
|
|
39
39
|
initLinks: {}, artLinks: {}, artSyncOpen: {}, artSyncState: {},
|
|
40
40
|
extraSess: {}, termHist: {}, startN: 1, docEdits: {}, docModes: {},
|
|
41
|
+
/* `+ session` into a working tree that already has sessions in it opens a CHOOSER instead of
|
|
42
|
+
* spawning (workspace-session-choice): `{slug, tree, branch, peers}` while it is open, null
|
|
43
|
+
* otherwise. `endConfirm` is the id of the one peer whose `end it` has been armed — a
|
|
44
|
+
* second click ends it, and anything else disarms. Neither is persisted: a chooser that
|
|
45
|
+
* reappeared on reload would be a question about a moment that has passed. */
|
|
46
|
+
startPrompt: null, endConfirm: null,
|
|
41
47
|
col0: true, col1: true, col2: true, scratchOpen: false, scratchText: '', copied: false,
|
|
42
48
|
flowsMap: null, schedList: null, agentList: null, memory: null,
|
|
43
49
|
savedDocs: {}, scratchCopied: false,
|
|
@@ -148,7 +148,7 @@ function viewBody(text) {
|
|
|
148
148
|
const fm = [];
|
|
149
149
|
doc.fm.forEach((fp) => { fm.push(el('span', { style: FM_K }, iv(fp.k)), el('span', { style: FM_V }, iv(fp.v))); });
|
|
150
150
|
return el('div', {
|
|
151
|
-
style: 'flex:1;overflow-y:auto;background:var(--background);color:var(--foreground);padding:20px 24px 60px;font-size:14px;line-height:1.65',
|
|
151
|
+
style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);color:var(--foreground);padding:20px 24px 60px;font-size:14px;line-height:1.65',
|
|
152
152
|
},
|
|
153
153
|
el('div', { style: 'max-width:72ch' },
|
|
154
154
|
el('div', {
|
|
@@ -171,7 +171,7 @@ function viewBody(text) {
|
|
|
171
171
|
|
|
172
172
|
function rawBody(text) {
|
|
173
173
|
return el('div', {
|
|
174
|
-
style: 'flex:1;overflow-y:auto;background:var(--background);padding:14px 16px;font-family:var(--mono);font-size:12px;line-height:1.7',
|
|
174
|
+
style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);padding:14px 16px;font-family:var(--mono);font-size:12px;line-height:1.7',
|
|
175
175
|
}, text.split('\n').map((l) => {
|
|
176
176
|
const ln = FX.rawLine(l);
|
|
177
177
|
return el('div', {
|
|
@@ -183,7 +183,7 @@ function rawBody(text) {
|
|
|
183
183
|
function diffBody(raw, text, dirty) {
|
|
184
184
|
const rows = dirty ? FX.diffLines(raw, text) : [FX.NO_DIFF_ROW];
|
|
185
185
|
return el('div', {
|
|
186
|
-
style: 'flex:1;overflow-y:auto;background:var(--background);padding:14px 0;font-family:var(--mono);font-size:12px;line-height:1.7',
|
|
186
|
+
style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);padding:14px 0;font-family:var(--mono);font-size:12px;line-height:1.7',
|
|
187
187
|
}, rows.map((dr) => el('div', {
|
|
188
188
|
style: 'color:' + dr.c + ';background:' + dr.bg + ';padding:0 16px;white-space:pre-wrap;overflow-wrap:anywhere;min-height:1.2em',
|
|
189
189
|
}, iv(dr.t))));
|
|
@@ -197,7 +197,7 @@ function editBox(tab) {
|
|
|
197
197
|
let ta = boxes.get(tab.id);
|
|
198
198
|
if (ta) return ta;
|
|
199
199
|
ta = el('textarea', {
|
|
200
|
-
style: 'flex:1;background:var(--background);color:var(--foreground);padding:14px 16px;font-family:var(--mono);font-size:12px;line-height:1.7',
|
|
200
|
+
style: 'flex:1;min-height:0;background:var(--background);color:var(--foreground);padding:14px 16px;font-family:var(--mono);font-size:12px;line-height:1.7',
|
|
201
201
|
onInput: (e) => {
|
|
202
202
|
const v = e.target.value;
|
|
203
203
|
saveErr.delete(tab.path);
|
|
@@ -253,7 +253,7 @@ export function mountDoc(host, tab) {
|
|
|
253
253
|
const dirtyColor = err ? 'var(--destructive)' : (dirty ? 'var(--warning)' : 'var(--muted-foreground)');
|
|
254
254
|
|
|
255
255
|
const modeBar = el('div', {
|
|
256
|
-
style: 'display:flex;gap:2px;align-items:center;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--background)',
|
|
256
|
+
style: 'flex:none;display:flex;gap:2px;align-items:center;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--background)',
|
|
257
257
|
},
|
|
258
258
|
modeBtns, saveBtn, revertBtn,
|
|
259
259
|
el('span', { style: 'margin-left:auto;font-family:var(--mono);font-size:10px;color:' + dirtyColor }, iv(dirtyLabel)),
|
|
@@ -52,7 +52,7 @@ export function mountTask(host, tab) {
|
|
|
52
52
|
'no artifact yet — produced when the agent runs this task');
|
|
53
53
|
|
|
54
54
|
const body = el('div', {
|
|
55
|
-
style: 'flex:1;overflow-y:auto;background:var(--background);color:var(--foreground);padding:22px 26px 60px;font-size:14px;line-height:1.65',
|
|
55
|
+
style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);color:var(--foreground);padding:22px 26px 60px;font-size:14px;line-height:1.65',
|
|
56
56
|
},
|
|
57
57
|
el('div', { style: 'max-width:76ch' },
|
|
58
58
|
el('div', { style: 'display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:5px' },
|
|
@@ -446,7 +446,7 @@ if (typeof window !== 'undefined') {
|
|
|
446
446
|
|
|
447
447
|
/* ── fixture pane ──────────────────────────────────────────────────────────────────────────── */
|
|
448
448
|
|
|
449
|
-
const SCROLL_STYLE = 'flex:1;overflow-y:auto;padding:14px 16px;font-family:var(--mono);font-size:12.5px;line-height:1.65;color:#c4ccce';
|
|
449
|
+
const SCROLL_STYLE = 'flex:1;min-height:0;overflow-y:auto;padding:14px 16px;font-family:var(--mono);font-size:12.5px;line-height:1.65;color:#c4ccce';
|
|
450
450
|
const LINE_STYLE = (c) => 'color:' + c + ';white-space:pre-wrap;overflow-wrap:anywhere';
|
|
451
451
|
const PROMPT_ROW_STYLE = 'display:flex;gap:8px;align-items:baseline';
|
|
452
452
|
const PROMPT_STYLE = 'color:#47cf79;flex:none';
|
|
@@ -639,6 +639,22 @@ export function sendToTerm(tab, data) {
|
|
|
639
639
|
return send({ t: 'input', id: rec.sessionId, data });
|
|
640
640
|
}
|
|
641
641
|
|
|
642
|
+
/**
|
|
643
|
+
* End a running session, by id — the `+ session` chooser's one destructive action.
|
|
644
|
+
*
|
|
645
|
+
* The wire message already existed (`t:'kill'`); what did not exist anywhere in the four-panel
|
|
646
|
+
* surface was a way to reach it, which is why "is it safe to end this?" had no answer the PM could
|
|
647
|
+
* act on. This is a bare send: the CONFIRMATION lives in the chooser, next to the reading of what
|
|
648
|
+
* ending it would cost, because that is the only place the PM is looking at both at once.
|
|
649
|
+
*
|
|
650
|
+
* Returns false when the socket is not up, so a caller can say "not sent" rather than imply a kill
|
|
651
|
+
* that never left the page.
|
|
652
|
+
*/
|
|
653
|
+
export function killSession(id) {
|
|
654
|
+
if (FIXTURE || !id) return false;
|
|
655
|
+
return send({ t: 'kill', id });
|
|
656
|
+
}
|
|
657
|
+
|
|
642
658
|
/**
|
|
643
659
|
* The SERVER's session id this tab is bound to, or null. The scratchpad reads it: a pad belongs to
|
|
644
660
|
* the session, and the session's identity is this id — not the tab id (which survives a session
|
package/workspace/server.js
CHANGED
|
@@ -369,6 +369,36 @@ function treeForWs(wsIdent) {
|
|
|
369
369
|
function treeTenants(tree, excludeId) {
|
|
370
370
|
return treeShare.coTenants([...sessions.values()], treeOfWsMap(), tree, excludeId);
|
|
371
371
|
}
|
|
372
|
+
/**
|
|
373
|
+
* A registry row, described for a human deciding whether to JOIN it rather than start another
|
|
374
|
+
* (workspace-session-choice). "2 sessions already running" names a fact the PM cannot act on; what
|
|
375
|
+
* they need is which one, what it is doing, and what ending it would cost.
|
|
376
|
+
*
|
|
377
|
+
* Every field is one the sidebar already publishes — the joined presented state, the quiet clock,
|
|
378
|
+
* the sidebar's own word for it, and the agent's last words. Nothing new is derived here and no
|
|
379
|
+
* word is re-invented: `sessionState` owns the vocabulary and `treeShare.endRisk` owns the
|
|
380
|
+
* consequence, and this function only joins them onto one row.
|
|
381
|
+
*/
|
|
382
|
+
function describePeer(s, now) {
|
|
383
|
+
const d = harness.deriveState(s.state, s.lastOutputAt, now || Date.now());
|
|
384
|
+
const hx = transcriptHarness(s);
|
|
385
|
+
const shown = sessionState.presented(d.state, hx && hx.turn);
|
|
386
|
+
const oc = hx && hx.openCall;
|
|
387
|
+
const call = oc && oc.verb
|
|
388
|
+
? { verb: oc.verb, ageMs: oc.sinceTs ? Math.max(0, (now || Date.now()) - Date.parse(oc.sinceTs)) : null }
|
|
389
|
+
: null;
|
|
390
|
+
return {
|
|
391
|
+
id: s.id, ws: s.ws, name: s.name, agent: s.agent, state: s.state, shown,
|
|
392
|
+
quietMs: d.quietMs,
|
|
393
|
+
label: sessionState.label(shown, { quietMs: d.quietMs, call, fmtDur: treeShare.ago }),
|
|
394
|
+
doing: (harnessOf(s) || {}).lastWords || null,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/** The same set as `treeTenants`, each row described. The order is the registry's, unchanged. */
|
|
398
|
+
function treePeersDescribed(tree, excludeId) {
|
|
399
|
+
const now = Date.now();
|
|
400
|
+
return treeTenants(tree, excludeId).map((s) => describePeer(s, now));
|
|
401
|
+
}
|
|
372
402
|
function treeWriter(tree) {
|
|
373
403
|
return treeShare.writerOf(treeClaims, treeOfWsMap(), [...sessions.values()], tree);
|
|
374
404
|
}
|
|
@@ -382,12 +412,16 @@ function announceTree(s) {
|
|
|
382
412
|
const tree = treeForWs(s.ws);
|
|
383
413
|
s.tree = tree || null;
|
|
384
414
|
if (!tree) return [];
|
|
385
|
-
|
|
415
|
+
// DESCRIBED rows, not bare registry objects: the pane line now carries what each co-tenant is
|
|
416
|
+
// doing and what ending it would cost, and it must read from the same join the sidebar does.
|
|
417
|
+
const mates = treePeersDescribed(tree, s.id);
|
|
386
418
|
const holder = treeWriter(tree);
|
|
387
419
|
if (!holder) treeClaims.set(tree, s.id); // vacant → this session declares itself
|
|
388
420
|
if (!mates.length) return []; // alone in the tree: nothing to say
|
|
389
421
|
const held = holder ? (sessions.get(holder) || {}).name : null;
|
|
390
|
-
const line = treeShare.paneAdvisory(mates, {
|
|
422
|
+
const line = treeShare.paneAdvisory(mates, {
|
|
423
|
+
role: holder ? 'reader' : 'writer', writerName: held, writerId: holder || s.id, tree,
|
|
424
|
+
});
|
|
391
425
|
if (line) appendBuf(s, line);
|
|
392
426
|
return mates;
|
|
393
427
|
}
|
|
@@ -396,7 +430,9 @@ function treeNoticeFor(s, mates) {
|
|
|
396
430
|
if (!mates || !mates.length) return null;
|
|
397
431
|
const holder = treeWriter(s.tree);
|
|
398
432
|
const held = holder && holder !== s.id ? (sessions.get(holder) || {}).name : null;
|
|
399
|
-
return treeShare.coTenancyNotice(mates, {
|
|
433
|
+
return treeShare.coTenancyNotice(mates, {
|
|
434
|
+
role: holder === s.id ? 'writer' : 'reader', writerName: held, writerId: holder, tree: s.tree,
|
|
435
|
+
});
|
|
400
436
|
}
|
|
401
437
|
function wsList() {
|
|
402
438
|
return [...workspaces.values()].map((w) => {
|
|
@@ -410,6 +446,12 @@ function wsList() {
|
|
|
410
446
|
// the client renders the first quietly and the second as a fault (R1/R2).
|
|
411
447
|
repo_key: rid.key, repo_name: rid.name, branch: rid.branch,
|
|
412
448
|
branch_error: rid.branchError, branch_code: rid.branchCode,
|
|
449
|
+
// The COLLISION UNIT (workspace-session-concurrency), published so a client can answer "who
|
|
450
|
+
// else is in the tree I am about to start in?" BEFORE it starts anything. Without it the
|
|
451
|
+
// browser could only find co-tenants via a session that already exists there, which is
|
|
452
|
+
// exactly one session too late for a chooser. Null for a folder with no git — no index to
|
|
453
|
+
// collide over, so no group.
|
|
454
|
+
tree: rid.tree || null,
|
|
413
455
|
// Where the tree came from, and the ONE label the repository list can honestly put on a row.
|
|
414
456
|
// `remote` is the raw origin URL (null when there is none) so a caller can derive a richer
|
|
415
457
|
// answer without guessing; `kind` is the surface's own two-value vocabulary — a repository
|
|
@@ -1559,6 +1601,17 @@ function sessionList() {
|
|
|
1559
1601
|
// `failed` does not exist (no observable for it). The client re-derives this from the SAME
|
|
1560
1602
|
// module — see /lib/session-state.js below — so the two can never hold different opinions.
|
|
1561
1603
|
shown: sessionState.presented(d.state, hx && hx.turn), quietMs: d.quietMs,
|
|
1604
|
+
// The rendered word for that state — computed HERE rather than in each client, because it is
|
|
1605
|
+
// the same join (`sessionState.label`) with the same clock, and a client re-deriving it was
|
|
1606
|
+
// already producing a second, slightly different sentence for one session.
|
|
1607
|
+
label: sessionState.label(sessionState.presented(d.state, hx && hx.turn), {
|
|
1608
|
+
quietMs: d.quietMs,
|
|
1609
|
+
call: hx && hx.openCall && hx.openCall.verb
|
|
1610
|
+
? { verb: hx.openCall.verb,
|
|
1611
|
+
ageMs: hx.openCall.sinceTs ? Math.max(0, now - Date.parse(hx.openCall.sinceTs)) : null }
|
|
1612
|
+
: null,
|
|
1613
|
+
fmtDur: treeShare.ago,
|
|
1614
|
+
}),
|
|
1562
1615
|
lastOutputAt: s.lastOutputAt || null,
|
|
1563
1616
|
lastWords: h.lastWords,
|
|
1564
1617
|
// the harness proper — structured record, not rendered bytes. Resolved ONCE above and
|
|
@@ -2115,7 +2168,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
|
|
|
2115
2168
|
// Both facts travel: `notice` is a single string on the wire and a branch notice and a co-tenancy
|
|
2116
2169
|
// notice can both be true of one spawn. joinNotices keeps them both rather than letting the
|
|
2117
2170
|
// second evict the first exactly when the PM most needs both.
|
|
2118
|
-
const shared = treeNoticeFor(s,
|
|
2171
|
+
const shared = treeNoticeFor(s, treePeersDescribed(s.tree, s.id));
|
|
2119
2172
|
return { ok: true, session: s, notice: treeShare.joinNotices(notice, shared) };
|
|
2120
2173
|
}
|
|
2121
2174
|
|
|
@@ -3091,6 +3144,7 @@ async function handleRequest(req, res) {
|
|
|
3091
3144
|
if (p === '/lib/layout.js') return serveFile(res, path.join(__dirname, 'lib', 'layout.js'));
|
|
3092
3145
|
if (p === '/lib/mobile-nav.js') return serveFile(res, path.join(__dirname, 'lib', 'mobile-nav.js'));
|
|
3093
3146
|
if (p === '/lib/session-state.js') return serveFile(res, path.join(__dirname, 'lib', 'session-state.js'));
|
|
3147
|
+
if (p === '/lib/tree-share.js') return serveFile(res, path.join(__dirname, 'lib', 'tree-share.js'));
|
|
3094
3148
|
// The editor computes its live diff with the SAME module the server saves through, so what the PM
|
|
3095
3149
|
// sees while typing and what gets written cannot disagree. NB: /lib/ is an explicit allowlist, not
|
|
3096
3150
|
// a directory route — a <script src> for a file missing from this list 404s silently, and the
|
|
@@ -3630,9 +3684,13 @@ async function handleRequest(req, res) {
|
|
|
3630
3684
|
note: 'this workspace is not inside a git working tree — there is no shared index to collide over' });
|
|
3631
3685
|
}
|
|
3632
3686
|
const writer = treeWriter(tree);
|
|
3633
|
-
|
|
3634
|
-
|
|
3687
|
+
// Described, not merely listed (workspace-session-choice): a caller deciding between joining a
|
|
3688
|
+
// running session and starting another needs the state, the word for it, what the agent is
|
|
3689
|
+
// doing and what ending it would cost — the same four facts the chooser and the pane line show.
|
|
3690
|
+
const rows = treePeersDescribed(tree, null).map((s) => ({
|
|
3691
|
+
...s,
|
|
3635
3692
|
role: treeShare.roleOf(s.id, tree, writer),
|
|
3693
|
+
risk: treeShare.endRisk(s.shown, s.quietMs),
|
|
3636
3694
|
}));
|
|
3637
3695
|
return json(res, { ok: true, tree, writer, sessions: rows, advisory: true,
|
|
3638
3696
|
note: writer
|
|
@@ -4406,6 +4464,10 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
|
|
|
4406
4464
|
// (one repo registered twice is one tree; two worktrees of one repo are two) without driving
|
|
4407
4465
|
// every case through a spawn.
|
|
4408
4466
|
treeForWs, treeTenants, treeWriter, treeClaims,
|
|
4467
|
+
// …and the DESCRIBED form, exported for the same reason: "what is that session doing and what
|
|
4468
|
+
// would ending it cost?" is the half of co-tenancy the PM actually acts on, so the suite asserts
|
|
4469
|
+
// it directly rather than reading it back out of a rendered sentence.
|
|
4470
|
+
describePeer, treePeersDescribed,
|
|
4409
4471
|
// exported so the suite can assert the D08 invariant end-to-end: the harness parsers consume
|
|
4410
4472
|
// scrubbed text, so a secret printed into scrollback must not survive into a rendered value.
|
|
4411
4473
|
scrub, harnessOf,
|