create-pmos 0.17.0 → 0.18.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 +25 -0
- package/workspace/lib/tree-share.js +129 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/server.js +149 -5
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.18.0
|
|
337
|
+
|
|
338
|
+
**Several sessions in one folder now say so.** Run `npx create-pmos@latest workspace`; the version
|
|
339
|
+
bump means an existing install auto-upgrades.
|
|
340
|
+
|
|
341
|
+
- **Starting a session where others are already running tells you what you are sharing.** Sessions
|
|
342
|
+
in one checkout share one branch, one index and one set of files — that was true before and
|
|
343
|
+
nothing said it. The new session's pane now names the sessions already there and points at the
|
|
344
|
+
alternative (a new branch in its own folder). It appears on the ordinary "+ session" path too,
|
|
345
|
+
where no git operation runs at all: the server had already worked out that other sessions would
|
|
346
|
+
be disturbed, and was throwing the answer away unless you happened to pick a branch.
|
|
347
|
+
- **It counts the right thing.** A repository registered at two paths — the project root and a
|
|
348
|
+
subdirectory of it, the ordinary shape when you add a product repo by path — is **one** checkout,
|
|
349
|
+
and its sessions are now told about each other; before, each was told it was alone. Two
|
|
350
|
+
*worktrees* of one repository are **two** checkouts and are deliberately not warned about, because
|
|
351
|
+
those are the sessions that isolation already made safe. A folder with no git says nothing, since
|
|
352
|
+
there is no index to collide over.
|
|
353
|
+
- **One session is the declared writer; the rest read.** The first live session in a checkout holds
|
|
354
|
+
the claim, and you can hand it to another (`POST /api/tree/claim`); `GET /api/tree/peers` reports
|
|
355
|
+
who holds it and who else is there. It moves only when you move it — never on a timer or an exit —
|
|
356
|
+
and when its holder exits the checkout reads *vacant* rather than promoting somebody at random.
|
|
357
|
+
- **Be clear about what that is: a declaration, not a lock.** The workspace hosts your agent's
|
|
358
|
+
terminal; it does not sit between the agent and the files, so nothing here prevents a write, a
|
|
359
|
+
checkout or a commit, and no session is ever refused because another one is there. It makes a
|
|
360
|
+
shared checkout visible. The discipline stays yours.
|
|
361
|
+
- **A folder you are working in can no longer be reclaimed out from under you.** Removing a
|
|
362
|
+
worktree already refused when sessions were running in it — but it counted only sessions in the
|
|
363
|
+
workspace registered at that exact folder, so a session running in a subdirectory of it was
|
|
364
|
+
invisible. It is counted now, and named in the refusal.
|
|
365
|
+
|
|
336
366
|
## What's new in 0.17.0
|
|
337
367
|
|
|
338
368
|
**Your board stops calling shipped work unstarted.** Run `npx create-pmos@latest workspace`; the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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
|
@@ -155,6 +155,31 @@ the folder and **keeps the branch**; nothing is ever removed automatically. Git
|
|
|
155
155
|
between worktrees, so what a folder costs is a checkout of tracked files, not a second copy of the
|
|
156
156
|
repo.
|
|
157
157
|
|
|
158
|
+
## Several sessions in one tree: you are told, never stopped
|
|
159
|
+
|
|
160
|
+
Sessions in one working tree share **one branch, one index and one set of files**. Starting a
|
|
161
|
+
session in a tree that already has live ones now says so in the pane — naming them, and naming the
|
|
162
|
+
way out (⑂ a new branch in its own folder). It fires on the plain `+ session` path too, where no
|
|
163
|
+
git operation runs at all: the server already knew, and used to say nothing.
|
|
164
|
+
|
|
165
|
+
**The tree is the unit, not the folder you registered.** A repo registered twice — say `~/repo` and
|
|
166
|
+
`~/repo/packages/app`, the ordinary shape for a product repo added by path — is **one** tree, and
|
|
167
|
+
its sessions are told about each other. Two *worktrees* of one repo are **two** trees, and are not
|
|
168
|
+
warned about at all: those are the sessions isolation made safe. A folder with no git has no tree
|
|
169
|
+
and says nothing, because there is no index to collide over.
|
|
170
|
+
|
|
171
|
+
**The writer claim is a declaration, not a lock.** The first live session in a tree holds it; the
|
|
172
|
+
others read. `GET /api/tree/peers?ws=…` reports the tree, its holder and every session on it;
|
|
173
|
+
`POST /api/tree/claim {session}` hands it over. It moves **only** when you move it — never on a
|
|
174
|
+
timer, never on an exit. When the holder exits the tree reads *vacant*, and no survivor is
|
|
175
|
+
promoted, because which pane should be driving is your intention and not something to derive.
|
|
176
|
+
|
|
177
|
+
Be clear about what this buys: **PMOS hosts your agent's terminal, it does not sit between it and
|
|
178
|
+
the files.** Nothing here prevents a write, a checkout or a commit, and no spawn is ever refused
|
|
179
|
+
because someone else is in the tree. It makes a shared tree visible; the discipline is still yours.
|
|
180
|
+
(The role rides on `/api/sessions` rows as `tree` / `tree_role` / `tree_peers`; the tab-bar chip is
|
|
181
|
+
not wired yet.)
|
|
182
|
+
|
|
158
183
|
A workspace anywhere **inside** a repo knows its branch — a subdirectory (`repo/packages/app`)
|
|
159
184
|
counts, which matters for a product repo added by path. A folder with **no git** simply says so and
|
|
160
185
|
starts the session; nothing about git can block work in a repo that doesn't use it.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Who shares a working tree with whom, and who is DECLARED to be driving it.
|
|
3
|
+
//
|
|
4
|
+
// The premise, stated once so nothing below has to hedge: PMOS hosts real PTYs and the agent CLI
|
|
5
|
+
// inside one is untouched (D62). There is nothing between an agent and the filesystem, so this
|
|
6
|
+
// module cannot prevent a write, a checkout or a commit — and never claims to. What it can do is
|
|
7
|
+
// compute the right SET of sessions that would collide, and carry one declaration about which of
|
|
8
|
+
// them the PM says is driving. Advisory, and every sentence it produces says so.
|
|
9
|
+
//
|
|
10
|
+
// The one idea worth holding: **three keys look like "which repo is this" and only one of them is
|
|
11
|
+
// the collision unit.**
|
|
12
|
+
// • the workspace id — a registered DIRECTORY. Too narrow: one repo can be registered twice
|
|
13
|
+
// (`~/repo` and `~/repo/packages/app`, the ordinary shape for a tenant repo added by path),
|
|
14
|
+
// and those two ids share one HEAD, one index, one set of files.
|
|
15
|
+
// • `repo_key` — the repository, i.e. its MAIN worktree. Too wide: it deliberately groups
|
|
16
|
+
// a repo's linked worktrees, which are exactly the sessions that are SAFELY isolated. Warning
|
|
17
|
+
// on those is how a warning gets trained away.
|
|
18
|
+
// • the working-tree top (`git rev-parse --show-toplevel`) — one HEAD, one index, one
|
|
19
|
+
// `index.lock`. This is the one. A folder with no git has no tree key, and therefore no
|
|
20
|
+
// co-tenants: there is no index to corrupt, so there is nothing to say.
|
|
21
|
+
//
|
|
22
|
+
// Pure functions over plain rows, on purpose: the server owns the git shell-outs and the session
|
|
23
|
+
// registry, and the rules are the part worth testing without either.
|
|
24
|
+
|
|
25
|
+
/** Live = a session the PM could still be driving. `exited` is a corpse and collides with nobody. */
|
|
26
|
+
function isLive(s) { return !!s && s.state !== 'exited'; }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The live sessions sharing `tree`, excluding one id (normally the session asking).
|
|
30
|
+
* `treeOfWs` maps a workspace id → its working-tree top (null for a folder with no git).
|
|
31
|
+
* A null/empty `tree` matches nothing — "not in a repo" is never a group.
|
|
32
|
+
*/
|
|
33
|
+
function coTenants(sessions, treeOfWs, tree, exclude) {
|
|
34
|
+
if (!tree) return [];
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const s of sessions || []) {
|
|
37
|
+
if (!isLive(s) || s.id === exclude) continue;
|
|
38
|
+
if (treeOfWs.get(s.ws) === tree) out.push(s);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Who holds the writer claim on `tree` — or null when nobody does.
|
|
45
|
+
*
|
|
46
|
+
* The claim is stored, but it is only BELIEVED while its holder is live: a claim is a statement
|
|
47
|
+
* about a running session, and a dead session cannot be driving anything. Deriving the answer this
|
|
48
|
+
* way is what makes "the holder exited" read as **vacant** rather than as a stale name, without a
|
|
49
|
+
* cleanup pass having to be correct for the UI to be honest.
|
|
50
|
+
*
|
|
51
|
+
* Note what this deliberately does NOT do: promote the oldest survivor. Which pane should drive is
|
|
52
|
+
* the PM's intention, not an observable, and a derived answer must never impersonate a decision.
|
|
53
|
+
*/
|
|
54
|
+
function writerOf(claims, treeOfWs, sessions, tree) {
|
|
55
|
+
if (!tree) return null;
|
|
56
|
+
const held = claims.get(tree);
|
|
57
|
+
if (!held) return null;
|
|
58
|
+
const s = (sessions || []).find((x) => x && x.id === held);
|
|
59
|
+
if (!isLive(s) || treeOfWs.get(s.ws) !== tree) return null;
|
|
60
|
+
return held;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 'writer' | 'reader' for a session in a tree; null when it is not in one (no git → no role). */
|
|
64
|
+
function roleOf(sessionId, tree, writerId) {
|
|
65
|
+
if (!tree) return null;
|
|
66
|
+
return sessionId === writerId ? 'writer' : 'reader';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Name a handful of panes without letting a 12-session tree produce a paragraph. */
|
|
70
|
+
function nameList(rows, cap = 3) {
|
|
71
|
+
const names = (rows || []).map((s) => `“${s.name}”`);
|
|
72
|
+
if (names.length <= cap) return names.join(', ');
|
|
73
|
+
return `${names.slice(0, cap).join(', ')} and ${names.length - cap} more`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The sentence a session gets when it starts in a tree somebody is already working in.
|
|
78
|
+
*
|
|
79
|
+
* It names the panes (a bare count tells the PM nothing they can act on), states the ONE fact that
|
|
80
|
+
* makes this matter — a shared branch, index and file set — and names the way out that already
|
|
81
|
+
* exists, because a warning with no alternative is just a nag. Returns null when there is nobody
|
|
82
|
+
* there, so the caller never has to test for emptiness.
|
|
83
|
+
*/
|
|
84
|
+
function coTenancyNotice(mates, opts) {
|
|
85
|
+
const rows = mates || [];
|
|
86
|
+
if (!rows.length) return null;
|
|
87
|
+
const n = rows.length;
|
|
88
|
+
const role = opts && opts.role === 'writer' ? 'writer' : 'reader';
|
|
89
|
+
const held = opts && opts.writerName;
|
|
90
|
+
const who = role === 'writer'
|
|
91
|
+
// Vacant tree: this session took the claim. Say so — the PM should know a declaration was made
|
|
92
|
+
// on their behalf, not discover it later in a list.
|
|
93
|
+
? 'nobody had claimed it, so this session now holds the writer claim'
|
|
94
|
+
: held
|
|
95
|
+
? `“${held}” holds the writer claim`
|
|
96
|
+
: 'no session holds the writer claim';
|
|
97
|
+
return `${n} session${n === 1 ? '' : 's'} already running in this working tree (${nameList(rows)}) — `
|
|
98
|
+
+ `you share one branch, one index and one set of files with ${n === 1 ? 'it' : 'them'}. ${who}. `
|
|
99
|
+
+ 'Claims are advisory: PMOS hosts your agent’s terminal, it does not sit between it and the '
|
|
100
|
+
+ 'files. To work in parallel for real, start the session on a new branch with its own folder (⑂).';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The same fact, written into the pane's own scrollback at spawn.
|
|
105
|
+
*
|
|
106
|
+
* 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, one line,
|
|
108
|
+
* and it never claims anything was prevented.
|
|
109
|
+
*/
|
|
110
|
+
function paneAdvisory(mates, opts) {
|
|
111
|
+
const line = coTenancyNotice(mates, opts);
|
|
112
|
+
if (!line) return null;
|
|
113
|
+
return `\x1b[38;5;244m⑂ ${line}\x1b[0m\r\n`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Carry two non-fatal truths on a channel that has only ever carried one.
|
|
118
|
+
*
|
|
119
|
+
* `spawnWithBranch` returns a single `notice` string and, until now, at most one of its facts could
|
|
120
|
+
* be set at a time. A branch notice and a co-tenancy notice can both be true of the same spawn, and
|
|
121
|
+
* the second must not evict the first — dropping one would make the wire format quietly lossy in
|
|
122
|
+
* exactly the situation the PM most needs both.
|
|
123
|
+
*/
|
|
124
|
+
function joinNotices(...parts) {
|
|
125
|
+
const kept = parts.filter((p) => p != null && String(p).trim() !== '');
|
|
126
|
+
return kept.length ? kept.join(' · ') : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { isLive, coTenants, writerOf, roleOf, coTenancyNotice, paneAdvisory, joinNotices, nameList };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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.18.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
package/workspace/server.js
CHANGED
|
@@ -71,6 +71,7 @@ const consoleState = require('./lib/console-state'); // read-only console surf
|
|
|
71
71
|
const boardCache = require('./lib/board-cache'); // the workflow board, cached + derived off the event loop (R4)
|
|
72
72
|
const fmParse = require('./lib/file-mode-parse'); // canonical file-mode parsers, shared with metrics.py (D64 D)
|
|
73
73
|
const gitInfo = require('./lib/git-info'); // read-mostly branch awareness + select/create at session start
|
|
74
|
+
const treeShare = require('./lib/tree-share'); // who shares a working tree, and who is DECLARED to be driving it
|
|
74
75
|
const tmuxControl = require('./lib/tmux-control'); // tmux -CC protocol: the pane's raw stream, so xterm.js keeps its scrollback
|
|
75
76
|
const relatedDocs = require('./lib/related-docs'); // which documents a change puts out of step (R5/R6)
|
|
76
77
|
const applyChange = require('./lib/apply-change'); // anchored + whole-document saves, refuse-on-stale
|
|
@@ -312,11 +313,18 @@ function repoIdentity(root) {
|
|
|
312
313
|
const now = Date.now();
|
|
313
314
|
if (hit && now - hit.at < REPO_KEY_TTL_MS) return hit;
|
|
314
315
|
let key = root, name = path.basename(root), branch = null, branchError = null, branchCode = null;
|
|
315
|
-
let remote = null;
|
|
316
|
+
let remote = null, tree = null;
|
|
316
317
|
try {
|
|
317
318
|
const trees = gitInfo.worktrees(root);
|
|
318
319
|
if (trees.length && trees[0].dir) { key = path.resolve(trees[0].dir); name = path.basename(key); }
|
|
319
320
|
} catch { /* not a repo, or git absent — the tree is its own group, which is the truth */ }
|
|
321
|
+
// The THIRD key, and the only one that answers "would these two sessions collide": the working
|
|
322
|
+
// tree this workspace sits in — one HEAD, one index, one set of files. `key` above is the
|
|
323
|
+
// REPOSITORY (its main worktree), which deliberately groups a repo's linked worktrees; those are
|
|
324
|
+
// exactly the sessions that are safely isolated, so it is the wrong key for collisions. Null for
|
|
325
|
+
// a folder with no git, and null is meaningful: no index, nothing to collide over. Memoised in
|
|
326
|
+
// this same entry because `wsList()` runs on every workspace push and this is a third shell-out.
|
|
327
|
+
try { tree = gitInfo.repoRoot(root); } catch { tree = null; }
|
|
320
328
|
// Where this tree came FROM. Memoised with the rest of the identity (same 15s TTL) because it is
|
|
321
329
|
// another `git` shell-out on a call that runs for every workspace on every push. A failure is not
|
|
322
330
|
// a fault: a folder registered by path legitimately has no origin, and null is the honest answer.
|
|
@@ -333,10 +341,62 @@ function repoIdentity(root) {
|
|
|
333
341
|
// one drifts.
|
|
334
342
|
else { branchError = bi.error || 'branch could not be read'; branchCode = bi.code || 'git-error'; }
|
|
335
343
|
} catch { branchError = 'branch could not be read'; branchCode = 'git-error'; }
|
|
336
|
-
const val = { at: now, key, name, branch, branchError, branchCode, remote };
|
|
344
|
+
const val = { at: now, key, name, branch, branchError, branchCode, remote, tree };
|
|
337
345
|
repoKeyCache.set(root, val);
|
|
338
346
|
return val;
|
|
339
347
|
}
|
|
348
|
+
|
|
349
|
+
// ── Shared-tree co-tenancy (workspace-session-concurrency) ────────────────────────────────────
|
|
350
|
+
// The rules live in lib/tree-share.js, pure and unit-tested; what lives here is the two things the
|
|
351
|
+
// server owns — the git answer (via the memoised identity above) and the session registry.
|
|
352
|
+
//
|
|
353
|
+
// Held claims, tree top → session id. Only ever BELIEVED while its holder is live (treeShare.
|
|
354
|
+
// writerOf re-checks), so an exited writer leaves the tree vacant rather than leaving a stale name
|
|
355
|
+
// on it. Nothing is promoted automatically: which pane drives is the PM's intention, not ours.
|
|
356
|
+
const treeClaims = new Map();
|
|
357
|
+
/** workspace id → its working-tree top (null when the folder is not in a repo). */
|
|
358
|
+
function treeOfWsMap() {
|
|
359
|
+
const m = new Map();
|
|
360
|
+
for (const w of workspaces.values()) m.set(w.id, repoIdentity(w.root).tree || null);
|
|
361
|
+
return m;
|
|
362
|
+
}
|
|
363
|
+
function treeForWs(wsIdent) {
|
|
364
|
+
const w = wsIdent && workspaces.get(wsIdent);
|
|
365
|
+
return w ? (repoIdentity(w.root).tree || null) : null;
|
|
366
|
+
}
|
|
367
|
+
/** Live sessions sharing a tree, excluding one id. Rows are the registry's own objects. */
|
|
368
|
+
function treeTenants(tree, excludeId) {
|
|
369
|
+
return treeShare.coTenants([...sessions.values()], treeOfWsMap(), tree, excludeId);
|
|
370
|
+
}
|
|
371
|
+
function treeWriter(tree) {
|
|
372
|
+
return treeShare.writerOf(treeClaims, treeOfWsMap(), [...sessions.values()], tree);
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* A freshly-live session meets its tree: take the claim if nobody holds one, and write what it
|
|
376
|
+
* found into its own scrollback. The pane line is deliberately part of the session's record rather
|
|
377
|
+
* than only a transient notice frame — re-open the tab tomorrow and it still says what it was told.
|
|
378
|
+
* Returns the co-tenants so the caller can put the same fact on the wire.
|
|
379
|
+
*/
|
|
380
|
+
function announceTree(s) {
|
|
381
|
+
const tree = treeForWs(s.ws);
|
|
382
|
+
s.tree = tree || null;
|
|
383
|
+
if (!tree) return [];
|
|
384
|
+
const mates = treeTenants(tree, s.id);
|
|
385
|
+
const holder = treeWriter(tree);
|
|
386
|
+
if (!holder) treeClaims.set(tree, s.id); // vacant → this session declares itself
|
|
387
|
+
if (!mates.length) return []; // alone in the tree: nothing to say
|
|
388
|
+
const held = holder ? (sessions.get(holder) || {}).name : null;
|
|
389
|
+
const line = treeShare.paneAdvisory(mates, { role: holder ? 'reader' : 'writer', writerName: held });
|
|
390
|
+
if (line) appendBuf(s, line);
|
|
391
|
+
return mates;
|
|
392
|
+
}
|
|
393
|
+
/** The sentence for the spawn's notice frame — same facts as the pane line, same source. */
|
|
394
|
+
function treeNoticeFor(s, mates) {
|
|
395
|
+
if (!mates || !mates.length) return null;
|
|
396
|
+
const holder = treeWriter(s.tree);
|
|
397
|
+
const held = holder && holder !== s.id ? (sessions.get(holder) || {}).name : null;
|
|
398
|
+
return treeShare.coTenancyNotice(mates, { role: holder === s.id ? 'writer' : 'reader', writerName: held });
|
|
399
|
+
}
|
|
340
400
|
function wsList() {
|
|
341
401
|
return [...workspaces.values()].map((w) => {
|
|
342
402
|
const rid = repoIdentity(w.root);
|
|
@@ -1472,7 +1532,16 @@ function sessionList() {
|
|
|
1472
1532
|
// failure to avoid claiming "no profiles are configured on this machine" when PMOS never looked.
|
|
1473
1533
|
const pread = readProfilesNow();
|
|
1474
1534
|
const byId = new Map((pread.profiles || []).map((p) => [p.id, p]));
|
|
1475
|
-
|
|
1535
|
+
// ONE tree map and ONE writer lookup per broadcast, for the same reason profiles are read once:
|
|
1536
|
+
// the heartbeat runs every 2s and a per-row `git rev-parse` would be a shell-out per pane per
|
|
1537
|
+
// tick. `repoIdentity` is memoised, but the map is still built once and reused.
|
|
1538
|
+
const rowsAll = [...sessions.values()];
|
|
1539
|
+
const treeOfWs = treeOfWsMap();
|
|
1540
|
+
const writerByTree = new Map();
|
|
1541
|
+
for (const t of new Set([...treeOfWs.values()].filter(Boolean))) {
|
|
1542
|
+
writerByTree.set(t, treeShare.writerOf(treeClaims, treeOfWs, rowsAll, t));
|
|
1543
|
+
}
|
|
1544
|
+
return rowsAll.map((s) => {
|
|
1476
1545
|
const d = harness.deriveState(s.state, s.lastOutputAt, now);
|
|
1477
1546
|
const h = harnessOf(s);
|
|
1478
1547
|
const hx = transcriptHarness(s);
|
|
@@ -1504,6 +1573,18 @@ function sessionList() {
|
|
|
1504
1573
|
// BARE ID — the module rejects a bare string on purpose ("the recorded value is not a
|
|
1505
1574
|
// profile descriptor"), which is the honest answer. Resolving it to `null` instead would
|
|
1506
1575
|
// print "none was chosen" over a session that did choose one (R9).
|
|
1576
|
+
// ── the shared working tree, and the DECLARED writer on it ──
|
|
1577
|
+
// `tree` is the collision unit (one HEAD, one index) — null for a folder with no git, and
|
|
1578
|
+
// null is the honest answer there rather than a group of one. `tree_role` is 'writer' for the
|
|
1579
|
+
// one session that holds the claim, 'reader' for every other session in that tree, and null
|
|
1580
|
+
// where there is no tree. `tree_peers` counts the OTHER live sessions sharing it. All three
|
|
1581
|
+
// are advisory: PMOS hosts the terminal, it does not sit between the agent and the files.
|
|
1582
|
+
tree: treeOfWs.get(s.ws) || null,
|
|
1583
|
+
// an exited pane holds no role: a corpse is neither writing nor waiting to.
|
|
1584
|
+
tree_role: treeShare.isLive(s)
|
|
1585
|
+
? treeShare.roleOf(s.id, treeOfWs.get(s.ws) || null, writerByTree.get(treeOfWs.get(s.ws)))
|
|
1586
|
+
: null,
|
|
1587
|
+
tree_peers: treeShare.coTenants(rowsAll, treeOfWs, treeOfWs.get(s.ws) || null, s.id).length,
|
|
1507
1588
|
profile_id: 'profile' in s ? (s.profile == null ? null : s.profile) : null,
|
|
1508
1589
|
profile_line: profilesLib.profileLine(
|
|
1509
1590
|
!('profile' in s) ? undefined
|
|
@@ -1922,6 +2003,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
|
|
|
1922
2003
|
s.buf = `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`;
|
|
1923
2004
|
persist(s); pushSessions(); pushWorkspaces(); return s;
|
|
1924
2005
|
}
|
|
2006
|
+
announceTree(s); // before attach, so the advisory is the first line of the pane's record
|
|
1925
2007
|
attachPty(s);
|
|
1926
2008
|
feed(s);
|
|
1927
2009
|
persist(s); pushSessions(); pushWorkspaces(); return s;
|
|
@@ -1940,6 +2022,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
|
|
|
1940
2022
|
pty: p, tmux: null, note: '', buf: '', cols: 80, rows: 24, clients: new Set(), created: nowISO(),
|
|
1941
2023
|
lastOutputAt: Date.now() };
|
|
1942
2024
|
sessions.set(id, s);
|
|
2025
|
+
announceTree(s); // registered first: a session's co-tenancy question includes itself in the map
|
|
1943
2026
|
p.onData((d) => {
|
|
1944
2027
|
appendBuf(s, d);
|
|
1945
2028
|
for (const c of s.clients) if (c.readyState === 1) c.send(JSON.stringify({ t: 'data', id, data: d }));
|
|
@@ -2017,7 +2100,16 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
|
|
|
2017
2100
|
}
|
|
2018
2101
|
}
|
|
2019
2102
|
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id });
|
|
2020
|
-
|
|
2103
|
+
// The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
|
|
2104
|
+
// block above. The isolate default already computes that other sessions would be disturbed — and
|
|
2105
|
+
// then the whole git block is skipped when no branch was picked, which is the common path and the
|
|
2106
|
+
// one the PM has been living in. It said nothing. Now it says what it already knew.
|
|
2107
|
+
//
|
|
2108
|
+
// Both facts travel: `notice` is a single string on the wire and a branch notice and a co-tenancy
|
|
2109
|
+
// notice can both be true of one spawn. joinNotices keeps them both rather than letting the
|
|
2110
|
+
// second evict the first exactly when the PM most needs both.
|
|
2111
|
+
const shared = treeNoticeFor(s, treeTenants(s.tree, s.id));
|
|
2112
|
+
return { ok: true, session: s, notice: treeShare.joinNotices(notice, shared) };
|
|
2021
2113
|
}
|
|
2022
2114
|
|
|
2023
2115
|
// ── Worktree housekeeping: the app that creates the folders shows and clears them ──────────────
|
|
@@ -2033,7 +2125,11 @@ function worktreeReport(w) {
|
|
|
2033
2125
|
const rows = trees.map((t, i) => {
|
|
2034
2126
|
const main = i === 0;
|
|
2035
2127
|
const owner = [...workspaces.values()].find((x) => path.resolve(x.root) === path.resolve(t.dir));
|
|
2036
|
-
|
|
2128
|
+
// Counted by TREE, not by workspace id. A session running in a workspace registered at a
|
|
2129
|
+
// SUBDIRECTORY of this tree (`repo/packages/app` — the ordinary tenant-repo shape) is a session
|
|
2130
|
+
// whose files this removal would pull out from under it, and the old workspace-id count could
|
|
2131
|
+
// not see it: two ids, one checkout. `owner` is still the row's workspace for the client.
|
|
2132
|
+
const live = treeTenants(path.resolve(t.dir), null).length;
|
|
2037
2133
|
// the main worktree is the repo itself — never removable from here, and saying so beats an
|
|
2038
2134
|
// absent button the PM has to reason about
|
|
2039
2135
|
const gone = !main && !isDir(t.dir);
|
|
@@ -2137,6 +2233,10 @@ function removeSession(id) {
|
|
|
2137
2233
|
if (!s || s.pty) return false;
|
|
2138
2234
|
if (s.tmux) tmuxKillSession(s.tmux); // best-effort: never orphan a detached tmux session
|
|
2139
2235
|
forgetScratchpad(s); // the tab's staging text goes with the tab
|
|
2236
|
+
// Drop a claim this session held. Correctness does not depend on this — treeShare.writerOf only
|
|
2237
|
+
// believes a claim whose holder is still live, so a stale entry already reads as vacant — but a
|
|
2238
|
+
// map that only grows is a leak waiting to be found by someone else.
|
|
2239
|
+
if (s.tree && treeClaims.get(s.tree) === id) treeClaims.delete(s.tree);
|
|
2140
2240
|
sessions.delete(id); unpersist(id); pushSessions(); pushWorkspaces();
|
|
2141
2241
|
return true;
|
|
2142
2242
|
}
|
|
@@ -2947,6 +3047,25 @@ async function handleRequest(req, res) {
|
|
|
2947
3047
|
return serveFile(res, file, null, true);
|
|
2948
3048
|
}
|
|
2949
3049
|
|
|
3050
|
+
if (p === '/api/tree/claim' && req.method === 'POST') {
|
|
3051
|
+
// Hand the writer claim to a named session. The ONLY way a claim moves: never on a timer, never
|
|
3052
|
+
// on an exit, never on a heuristic. Which pane should be driving is the PM's intention, and a
|
|
3053
|
+
// derived answer must not impersonate a decision — so an exited holder leaves the tree vacant
|
|
3054
|
+
// and this endpoint is how it stops being vacant.
|
|
3055
|
+
let body = {};
|
|
3056
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
3057
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
3058
|
+
const s = body.session && sessions.get(String(body.session));
|
|
3059
|
+
if (!s) return json(res, { ok: false, error: 'no such session' }, 400);
|
|
3060
|
+
if (!treeShare.isLive(s)) return json(res, { ok: false, error: 'that session has exited — an exited pane cannot hold a claim' }, 400);
|
|
3061
|
+
const tree = treeForWs(s.ws);
|
|
3062
|
+
if (!tree) return json(res, { ok: false, error: 'that session is not in a git working tree — there is nothing to claim' }, 400);
|
|
3063
|
+
const prev = treeWriter(tree);
|
|
3064
|
+
treeClaims.set(tree, s.id);
|
|
3065
|
+
pushSessions();
|
|
3066
|
+
return json(res, { ok: true, tree, writer: s.id, previous: prev || null, advisory: true,
|
|
3067
|
+
note: 'a declaration, not a guarantee — nothing about either process changed' });
|
|
3068
|
+
}
|
|
2950
3069
|
if (p === '/api/workspaces' && req.method === 'GET') return json(res, { workspaces: wsList() });
|
|
2951
3070
|
if (p === '/api/workspaces' && req.method === 'POST') {
|
|
2952
3071
|
// How `pmos` in another repo becomes another workspace (R1). Local-only, like every
|
|
@@ -3393,6 +3512,27 @@ async function handleRequest(req, res) {
|
|
|
3393
3512
|
if (!w) return json(res, { ok: false, error: 'no workspace' });
|
|
3394
3513
|
return json(res, worktreeReport(w));
|
|
3395
3514
|
}
|
|
3515
|
+
if (p === '/api/tree/peers') {
|
|
3516
|
+
// Who this workspace's sessions actually share mutable git state with, and who is declared to
|
|
3517
|
+
// be driving. `advisory` is a FIELD, not a footnote: a caller that renders this must not be
|
|
3518
|
+
// able to read it as enforcement without having read the word.
|
|
3519
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
3520
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' });
|
|
3521
|
+
const tree = repoIdentity(w.root).tree || null;
|
|
3522
|
+
if (!tree) {
|
|
3523
|
+
return json(res, { ok: true, tree: null, writer: null, sessions: [], advisory: true,
|
|
3524
|
+
note: 'this workspace is not inside a git working tree — there is no shared index to collide over' });
|
|
3525
|
+
}
|
|
3526
|
+
const writer = treeWriter(tree);
|
|
3527
|
+
const rows = treeTenants(tree, null).map((s) => ({
|
|
3528
|
+
id: s.id, name: s.name, ws: s.ws, agent: s.agent, state: s.state,
|
|
3529
|
+
role: treeShare.roleOf(s.id, tree, writer),
|
|
3530
|
+
}));
|
|
3531
|
+
return json(res, { ok: true, tree, writer, sessions: rows, advisory: true,
|
|
3532
|
+
note: writer
|
|
3533
|
+
? 'the writer claim is a declaration, not a guarantee — PMOS hosts the terminal, it does not sit between the agent and the files'
|
|
3534
|
+
: 'no session holds the writer claim on this tree' });
|
|
3535
|
+
}
|
|
3396
3536
|
// The pad belongs to a SESSION when one is named — and the workspace then comes from that
|
|
3397
3537
|
// session, not from the caller, so a stale `ws` can never file one tab's text under another
|
|
3398
3538
|
// repo. `session` omitted still addresses the workspace pad (a window with no tabs yet).
|
|
@@ -4147,6 +4287,10 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
|
|
|
4147
4287
|
// non-repo folder from a real git fault, and the whole legibility defect was that distinction
|
|
4148
4288
|
// being dropped one layer above it.
|
|
4149
4289
|
repoIdentity, wsList,
|
|
4290
|
+
// the shared-tree co-tenancy layer — exported so the suite can assert the KEY in both directions
|
|
4291
|
+
// (one repo registered twice is one tree; two worktrees of one repo are two) without driving
|
|
4292
|
+
// every case through a spawn.
|
|
4293
|
+
treeForWs, treeTenants, treeWriter, treeClaims,
|
|
4150
4294
|
// exported so the suite can assert the D08 invariant end-to-end: the harness parsers consume
|
|
4151
4295
|
// scrubbed text, so a secret printed into scrollback must not survive into a rendered value.
|
|
4152
4296
|
scrub, harnessOf,
|