create-pmos 0.18.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/scripts/metrics.py +9 -2
- package/workspace/lib/file-mode-parse.js +12 -1
- package/workspace/lib/start-policy.js +118 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +2 -2
- package/workspace/public/pm/data.js +87 -3
- package/workspace/public/pm/panel-initiatives.js +7 -0
- package/workspace/public/pm/panel-tabs.js +147 -6
- package/workspace/public/pm/state.js +65 -6
- package/workspace/public/pm/tab-term.js +134 -18
- package/workspace/server.js +118 -3
package/README.md
CHANGED
|
@@ -333,6 +333,33 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
|
|
|
333
333
|
`okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
|
|
334
334
|
exactly what you're running.
|
|
335
335
|
|
|
336
|
+
## What's new in 0.18.1
|
|
337
|
+
|
|
338
|
+
**Three fixes to the workspace terminal, all of them about what the window knew about a session.**
|
|
339
|
+
Run `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
|
|
340
|
+
|
|
341
|
+
- **Your terminal now uses its whole width.** Nothing ever told the shell how wide the browser
|
|
342
|
+
was. A session starts at 80 columns; the server stated its own size on attach rather than
|
|
343
|
+
adopting yours, and the browser only spoke up when a resize *changed* its grid — which happened
|
|
344
|
+
once, while the connection was still opening and there was no session to tell. So the process on
|
|
345
|
+
the other end kept wrapping at 80 columns inside a window three times that wide, and a
|
|
346
|
+
full-screen agent CLI painted into a box that was not there.
|
|
347
|
+
- **The window stops rebuilding itself while you type.** It repainted every panel every four
|
|
348
|
+
seconds whether or not anything had changed — on a project with a hundred-odd initiatives that is
|
|
349
|
+
a real stall, on a timer, next to a terminal you are typing into. It now repaints when something
|
|
350
|
+
it shows actually moves. Separately, the server rewrote every session's scrollback file to disk
|
|
351
|
+
every five seconds, synchronously, whether or not a byte had changed; it now writes only what
|
|
352
|
+
moved.
|
|
353
|
+
- **The scratchpad belongs to a session again.** It had become one shared buffer for the whole
|
|
354
|
+
page, so every terminal — in every repository — showed and overwrote the same text, and none of
|
|
355
|
+
it outlived the tab. Each session has its own pad again, saved on your machine, and it comes back
|
|
356
|
+
with the session.
|
|
357
|
+
- **Reopening the workspace brings your sessions back.** Open tabs are remembered and re-attach to
|
|
358
|
+
the exact session they were on, named on the tab so you can tell which one it is. Previously they
|
|
359
|
+
were forgotten on reload and had to be re-found by hand — and the re-finding matched on a session
|
|
360
|
+
*name*, which is not unique between repositories, so a tab in one project could silently attach
|
|
361
|
+
to a shell running in another. `+ session` also always starts a new session now, instead of
|
|
362
|
+
sometimes adopting one that was already running.
|
|
336
363
|
## What's new in 0.18.0
|
|
337
364
|
|
|
338
365
|
**Several sessions in one folder now say so.** Run `npx create-pmos@latest workspace`; the version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.1",
|
|
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
|
@@ -49,11 +49,18 @@ def review_gate_pass(verdict, score):
|
|
|
49
49
|
|
|
50
50
|
def leading_frontmatter(path):
|
|
51
51
|
"""The LEADING frontmatter block only — body text cannot fake a declaration. Mirror of
|
|
52
|
-
workspace/lib/file-mode-parse.js parseLeadingFrontmatter (agreement enforced by the fixture test).
|
|
52
|
+
workspace/lib/file-mode-parse.js parseLeadingFrontmatter (agreement enforced by the fixture test).
|
|
53
|
+
|
|
54
|
+
The value group is `(.*?)`, NOT `(.+?)`. A key written bare (`prd_path:` with nothing after it)
|
|
55
|
+
is legal YAML for a null value; under `.+?` the line does not match, so it reads as the first
|
|
56
|
+
body line, the loop breaks, and every key below it is dropped. Agreement with the JS parser was
|
|
57
|
+
never sufficient to catch this — both carried the same wrong regex, so the fixture compared two
|
|
58
|
+
identical mistakes and passed. See kit/fixtures/state/initiatives/epsilon-empty-value.md.
|
|
59
|
+
"""
|
|
53
60
|
fm = {}
|
|
54
61
|
with open(path, encoding="utf-8") as fh:
|
|
55
62
|
for line in fh:
|
|
56
|
-
m = re.match(r"^([a-z_]+):\s*(
|
|
63
|
+
m = re.match(r"^([a-z_]+):\s*(.*?)\s*$", line.rstrip("\n"))
|
|
57
64
|
if m:
|
|
58
65
|
fm.setdefault(m.group(1), m.group(2))
|
|
59
66
|
elif line.strip() and not line.startswith("---"):
|
|
@@ -14,10 +14,21 @@ const reviewGate = require('./review-gate');
|
|
|
14
14
|
// The LEADING YAML-ish frontmatter block ONLY — body text must never be able to fake a declaration.
|
|
15
15
|
// Mirrors metrics.py exactly: `key: value` lines (first value wins), `---` fences and blank lines are
|
|
16
16
|
// skipped, and the FIRST non-frontmatter, non-fence, non-blank line ends the block.
|
|
17
|
+
//
|
|
18
|
+
// The value group is `(.*?)`, NOT `(.+?)`. A key written bare — `prd_path:` with nothing after it —
|
|
19
|
+
// is legal YAML for a null value, and 30 of the 136 records in planning/initiatives/ are written
|
|
20
|
+
// that way. Under `.+?` such a line does not match, so it is treated as the first body line, the
|
|
21
|
+
// loop BREAKS, and every key below it is silently dropped: parent_kr, outcome_metric, the lot. The
|
|
22
|
+
// spine then rendered those initiatives as "unanchored — rejected work under D59".
|
|
23
|
+
//
|
|
24
|
+
// This is not a hypothetical. metrics.py carried the identical regex and kit/fixtures/test_parsers.py
|
|
25
|
+
// existed to prove the two agreed — which they did, on being wrong, for as long as the fixture only
|
|
26
|
+
// ever compared them to each other. scripts/workflow-state.js:105 was the one correct parser and was
|
|
27
|
+
// compared against nothing. The fixture now asserts VALUES; see epsilon-empty-value.md.
|
|
17
28
|
function parseLeadingFrontmatter(text) {
|
|
18
29
|
const fm = {};
|
|
19
30
|
for (const line of String(text == null ? '' : text).split('\n')) {
|
|
20
|
-
const m = line.match(/^([a-z_]+):\s*(
|
|
31
|
+
const m = line.match(/^([a-z_]+):\s*(.*?)\s*$/);
|
|
21
32
|
if (m) { if (!(m[1] in fm)) fm[m[1]] = m[2]; continue; }
|
|
22
33
|
if (line.trim() && !line.startsWith('---')) break;
|
|
23
34
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/* workspace-repo-first · the start-work policy (PRD R1 · R3)
|
|
2
|
+
* ──────────────────────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
* ONE declared rule for where a session starts, replacing a decision the server used to make per
|
|
4
|
+
* call from invisible state.
|
|
5
|
+
*
|
|
6
|
+
* What it replaced, and why that had to go. `spawnWithBranch` chose isolation with
|
|
7
|
+
*
|
|
8
|
+
* if (isolate === undefined || isolate === null) isolate = liveSessionCount(w.id) > 0;
|
|
9
|
+
*
|
|
10
|
+
* — so the SAME click created a worktree or moved the shared checkout depending on whether some
|
|
11
|
+
* other session happened to be alive at that moment. A PM cannot hold that rule in their head,
|
|
12
|
+
* cannot predict it, and cannot see it in the UI before pressing the button. It is not a policy;
|
|
13
|
+
* it is a coin flip with a plausible-looking reason.
|
|
14
|
+
*
|
|
15
|
+
* The rule this module states instead:
|
|
16
|
+
*
|
|
17
|
+
* no initiative chosen → the repository's MAIN working tree, on whatever branch it is on.
|
|
18
|
+
* No git operation at all. Nothing moves, no folder appears.
|
|
19
|
+
* an initiative chosen → that initiative's own worktree on `initiative/<id>`, created if absent
|
|
20
|
+
* and REUSED if present. The main tree is never moved off its branch.
|
|
21
|
+
*
|
|
22
|
+
* The PM's framing, which this encodes: keep the main checkout on main, and let work live in
|
|
23
|
+
* worktrees rather than accumulating branches in the shared tree.
|
|
24
|
+
*
|
|
25
|
+
* Two consequences worth stating because they are easy to get wrong:
|
|
26
|
+
*
|
|
27
|
+
* * If the MAIN tree already happens to be on `initiative/<id>`, the answer is the main tree —
|
|
28
|
+
* not a second folder for a branch that is already checked out. git allows one working tree
|
|
29
|
+
* per branch, so building a worktree here would fail, and failing is the better outcome only
|
|
30
|
+
* if the alternative were wrong. It isn't: the PM asked to work on that initiative and the
|
|
31
|
+
* repository is already there.
|
|
32
|
+
* * `create` is derived from whether the branch EXISTS, never assumed. `addWorktree(root, b,
|
|
33
|
+
* {create:true})` errors when the branch is already there, and `{create:false}` errors when it
|
|
34
|
+
* is not. Guessing produces a failed git operation, and a failed git operation is the one thing
|
|
35
|
+
* the rubric's veto forbids being followed by a spawned session.
|
|
36
|
+
*
|
|
37
|
+
* PURE ON PURPOSE. Everything it needs arrives as plain data, so the whole policy — including the
|
|
38
|
+
* paths that touch git — is exercisable in node with no repository, no socket and no PTY. The
|
|
39
|
+
* predecessor's hard floor ("a failed git operation must never start a session in the wrong
|
|
40
|
+
* directory") is inherited by this initiative, and a floor whose only proof is a manual click is
|
|
41
|
+
* not a floor.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const BRANCH_PREFIX = 'initiative/';
|
|
45
|
+
|
|
46
|
+
// The branch an initiative's work belongs on. One definition, so the client's preview and the
|
|
47
|
+
// server's git call cannot disagree about what the PM is about to get.
|
|
48
|
+
function branchForInitiative(id) {
|
|
49
|
+
const s = String(id == null ? '' : id).trim();
|
|
50
|
+
return s ? BRANCH_PREFIX + s : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The repository's MAIN working tree among the registered ones. `repo_key` IS the main worktree's
|
|
54
|
+
// directory (repoIdentity resolves it from `git worktree list`, which names the main tree first),
|
|
55
|
+
// so the main tree is the one whose root equals the key. A repo where only LINKED worktrees are
|
|
56
|
+
// registered has no main tree on screen; the first registered tree stands in, which is the same
|
|
57
|
+
// fallback surfaceProducts already makes for the same reason.
|
|
58
|
+
function mainTreeOf(trees) {
|
|
59
|
+
const list = (trees || []).filter(Boolean);
|
|
60
|
+
if (!list.length) return null;
|
|
61
|
+
return list.find((t) => t.repo_key && t.root && String(t.root) === String(t.repo_key)) || list[0];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* Resolve one "start work here" request to a plan.
|
|
65
|
+
*
|
|
66
|
+
* trees — the registered workspaces of ONE repository: { id, root, repo_key, branch }
|
|
67
|
+
* initiative — an initiative id, or null/'' for "just give me a terminal"
|
|
68
|
+
* branches — branch names known to the repo (branchInfo().branches), or null when unread
|
|
69
|
+
*
|
|
70
|
+
* Returns { ok, plan, wsId, branch, createBranch, isolate, why } — or { ok:false, error } when
|
|
71
|
+
* there is nothing to act on. `wsId` is the workspace the session is spawned against; `branch`
|
|
72
|
+
* non-null means a git operation is required first, and `isolate` says which kind.
|
|
73
|
+
*/
|
|
74
|
+
function resolveStart({ initiative, trees, branches } = {}) {
|
|
75
|
+
const list = (trees || []).filter(Boolean);
|
|
76
|
+
if (!list.length) return { ok: false, error: 'no working tree is registered for this repository' };
|
|
77
|
+
const main = mainTreeOf(list);
|
|
78
|
+
if (!main) return { ok: false, error: 'no working tree is registered for this repository' };
|
|
79
|
+
|
|
80
|
+
const id = String(initiative == null ? '' : initiative).trim();
|
|
81
|
+
if (!id) {
|
|
82
|
+
return {
|
|
83
|
+
ok: true, plan: 'main-tree', wsId: main.id, branch: null, createBranch: false, isolate: false,
|
|
84
|
+
why: `runs in ${main.name || 'the main working tree'}${main.branch ? ` on ${main.branch}` : ''} — no branch is checked out and nothing else moves`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const branch = branchForInitiative(id);
|
|
89
|
+
if (!branch) return { ok: false, error: 'an initiative id is required to derive a branch' };
|
|
90
|
+
|
|
91
|
+
// Already checked out in a LINKED worktree → go there. No git operation; the folder exists.
|
|
92
|
+
const existing = list.find((t) => t.id !== main.id && t.branch === branch);
|
|
93
|
+
if (existing) {
|
|
94
|
+
return {
|
|
95
|
+
ok: true, plan: 'reuse-worktree', wsId: existing.id, branch: null, createBranch: false, isolate: false,
|
|
96
|
+
why: `${id} already has its own folder — this reuses it, and creates nothing`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// The MAIN tree is already on it. Use it rather than attempting a worktree git would refuse.
|
|
100
|
+
if (main.branch === branch) {
|
|
101
|
+
return {
|
|
102
|
+
ok: true, plan: 'main-tree-on-branch', wsId: main.id, branch: null, createBranch: false, isolate: false,
|
|
103
|
+
why: `this repository is already on ${branch} — the session runs here, and no second folder is made`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// Otherwise: a new worktree, off the main tree, which stays exactly where it is.
|
|
107
|
+
// `create` is DERIVED. A null `branches` means the branch list was never read — assuming the
|
|
108
|
+
// branch is new would make `git worktree add -b` fail on every existing branch, so the honest
|
|
109
|
+
// default is to treat it as existing and let git's own error surface if it is not.
|
|
110
|
+
const known = Array.isArray(branches) ? branches : null;
|
|
111
|
+
const createBranch = known ? !known.includes(branch) : false;
|
|
112
|
+
return {
|
|
113
|
+
ok: true, plan: 'new-worktree', wsId: main.id, branch, createBranch, isolate: true,
|
|
114
|
+
why: `${branch} gets its own folder — ${main.name || 'the main working tree'} stays on ${main.branch || 'its branch'} and every running session stays put`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { resolveStart, branchForInitiative, mainTreeOf, BRANCH_PREFIX };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.18.
|
|
9
|
+
"version": "0.18.1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node server.js",
|
|
12
12
|
"test": "node test.js && npm run test:modules",
|
|
13
|
-
"test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node no-network.test.js && node fixture-parity.test.mjs",
|
|
13
|
+
"test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node no-network.test.js && node fixture-parity.test.mjs && node start-policy.test.js",
|
|
14
14
|
"test:ui": "node ui-test.js",
|
|
15
15
|
"test:parity": "node design-parity.js --states",
|
|
16
16
|
"test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
|
|
@@ -552,14 +552,22 @@ async function loadBoard(prod) {
|
|
|
552
552
|
/** Publish every loaded product's board and records into state, in one patch. */
|
|
553
553
|
function publishBoards() {
|
|
554
554
|
const board = {};
|
|
555
|
+
/* Product NAME → workspace id, for every registered repository — not only the selected one.
|
|
556
|
+
* panel 4 keeps every open terminal live, including those belonging to a repository the PM is not
|
|
557
|
+
* currently looking at, and a parked tab's session must be resolved inside ITS OWN repository.
|
|
558
|
+
* Scoping it by the selected workspace is how a tab in one repo attached to a shell in another
|
|
559
|
+
* (tab-term.js `wsForTab`). */
|
|
560
|
+
const productWs = {};
|
|
555
561
|
let records = {};
|
|
556
562
|
for (const name of productOrder) {
|
|
557
563
|
const p = products.get(name);
|
|
558
|
-
if (!p
|
|
564
|
+
if (!p) continue;
|
|
565
|
+
if (p.ws) productWs[name] = p.ws;
|
|
566
|
+
if (!p.board) continue;
|
|
559
567
|
board[name] = p.board;
|
|
560
568
|
records = { ...records, ...p.records };
|
|
561
569
|
}
|
|
562
|
-
setState({ board, records: { ...records, ...(hydrated) } });
|
|
570
|
+
setState({ board, productWs, records: { ...records, ...(hydrated) } });
|
|
563
571
|
}
|
|
564
572
|
|
|
565
573
|
/* Hydrated (fully derived) records live apart from the light ones so that a board reload cannot
|
|
@@ -617,12 +625,48 @@ function currentProductName() {
|
|
|
617
625
|
*/
|
|
618
626
|
|
|
619
627
|
let pollTimer = null;
|
|
628
|
+
let sessionDigest = null;
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* WHAT THE SURFACE ACTUALLY RENDERS from a session row, as one string.
|
|
632
|
+
*
|
|
633
|
+
* Every `setState` repaints all four panels, and panel 2 rebuilds a board that is 138 initiatives
|
|
634
|
+
* deep on this repository. Publishing the poll unconditionally therefore tore down and rebuilt the
|
|
635
|
+
* whole window every four seconds, forever, whether or not anything had changed — which is a
|
|
636
|
+
* hundreds-of-milliseconds stall on the main thread, on a cadence, next to a terminal the PM is
|
|
637
|
+
* typing into. That is the jitter: input queued behind a repaint nobody asked for.
|
|
638
|
+
*
|
|
639
|
+
* The rows themselves change constantly (`lastOutputAt` moves with every byte an agent writes), so
|
|
640
|
+
* comparing the payload is useless. What is compared is the PROJECTION the panels read — id, name,
|
|
641
|
+
* workspace, derived state, the quiet reading ROUNDED THE WAY THE LABEL ROUNDS IT (minutes), and
|
|
642
|
+
* the fields the sidebar prints. A working agent produces an identical projection minute after
|
|
643
|
+
* minute, so the window stays still while it works, and repaints the moment something it shows
|
|
644
|
+
* actually moves.
|
|
645
|
+
*/
|
|
646
|
+
function sessionProjection(rows) {
|
|
647
|
+
return (rows || []).map((s) => [
|
|
648
|
+
s.id, s.ws, s.name, s.agent, s.state, s.shown, s.note || '', s.cwd || '',
|
|
649
|
+
s.profile_id || '', s.profile_line || '',
|
|
650
|
+
// the label's own resolution, not the millisecond behind it
|
|
651
|
+
s.shown === 'quiet' && Number.isFinite(s.quietMs) ? Math.max(1, Math.round(s.quietMs / 60000)) : '',
|
|
652
|
+
// Joined on U+0000, fields and rows both. A separator that cannot occur in any of these
|
|
653
|
+
// values is what stops two different lists folding into one identical string and
|
|
654
|
+
// suppressing a repaint that was real.
|
|
655
|
+
].join('\u0000')).join('\u0000\u0000');
|
|
656
|
+
}
|
|
620
657
|
|
|
621
658
|
async function pollSessions() {
|
|
622
659
|
if (FIXTURE) return;
|
|
623
660
|
const out = await getJson('/api/sessions');
|
|
624
661
|
if (out && Array.isArray(out.sessions)) {
|
|
625
|
-
|
|
662
|
+
const digest = sessionProjection(out.sessions);
|
|
663
|
+
const count = liveCount(out.sessions);
|
|
664
|
+
// A poll that found nothing new is not a render. `runningCount` is compared too: it folds in
|
|
665
|
+
// `extraSess`, so a session started in this window still publishes on the very next poll.
|
|
666
|
+
if (digest !== sessionDigest || count !== state.runningCount) {
|
|
667
|
+
sessionDigest = digest;
|
|
668
|
+
setState({ sessions: out.sessions, runningCount: count });
|
|
669
|
+
}
|
|
626
670
|
}
|
|
627
671
|
schedulePoll();
|
|
628
672
|
}
|
|
@@ -817,6 +861,46 @@ export function startSession(slug, sess) {
|
|
|
817
861
|
setTimeout(pollSessions, 800);
|
|
818
862
|
}
|
|
819
863
|
|
|
864
|
+
/* ── the scratchpad ─────────────────────────────────────────────────────────────────────────────
|
|
865
|
+
* The pad is PER SESSION, and the server has always stored it that way: `/api/scratchpad` is keyed
|
|
866
|
+
* by the server-issued session id (workspace-scoped, one file per session, deleted with it). What
|
|
867
|
+
* was lost in the four-panel port was the CLIENT half — the pad became one in-memory string for the
|
|
868
|
+
* whole page, so every tab in every repository showed, and overwrote, the same text.
|
|
869
|
+
*
|
|
870
|
+
* These two functions are the whole restoration. The key is `sess:<id>` or `ws:<id>`; the panel
|
|
871
|
+
* derives it, this file only speaks the two shapes the endpoint accepts.
|
|
872
|
+
*/
|
|
873
|
+
|
|
874
|
+
function padParams(key) {
|
|
875
|
+
const k = String(key || '');
|
|
876
|
+
if (k.startsWith('sess:')) return { session: k.slice(5) };
|
|
877
|
+
if (k.startsWith('ws:')) return { ws: k.slice(3) };
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** panel-tabs.js — the stored text for one pad, or '' when there is none (and on any failure). */
|
|
882
|
+
export async function loadScratchpad(key) {
|
|
883
|
+
if (FIXTURE) return '';
|
|
884
|
+
const params = padParams(key);
|
|
885
|
+
if (!params) return '';
|
|
886
|
+
const out = await getJson('/api/scratchpad', params);
|
|
887
|
+
return out && out.ok !== false && typeof out.text === 'string' ? out.text : '';
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* panel-tabs.js — write one pad. A failure is reported to the console and NOT surfaced as a lost
|
|
892
|
+
* buffer: the text stays in `state.scratchPads`, so the PM keeps what they typed for this page even
|
|
893
|
+
* when the disk write refused.
|
|
894
|
+
*/
|
|
895
|
+
export async function saveScratchpad(key, text) {
|
|
896
|
+
if (FIXTURE) return { ok: true };
|
|
897
|
+
const params = padParams(key);
|
|
898
|
+
if (!params) return { ok: false, error: 'no session or workspace to file this pad under' };
|
|
899
|
+
const out = await postJson('/api/scratchpad', { ...params, text });
|
|
900
|
+
if (out && out.ok === false) console.warn('[pm] the scratchpad was NOT saved: ' + out.error);
|
|
901
|
+
return out;
|
|
902
|
+
}
|
|
903
|
+
|
|
820
904
|
/** tab-doc.js — `readDoc(path)`. The raw document body, or a rejected promise carrying the reason. */
|
|
821
905
|
export async function readDoc(path) {
|
|
822
906
|
if (FIXTURE) return '';
|
|
@@ -131,6 +131,13 @@ export function openTerm(slug, sess) {
|
|
|
131
131
|
openTab({
|
|
132
132
|
id: 'term:' + slug + ':' + sess.name, kind: 'term', title: sess.name, slug,
|
|
133
133
|
repo: slugProduct()[slug], sess: sess.name, state: sess.st, profile: sess.profile || 'claude-work',
|
|
134
|
+
/* The SERVER's session id, when the row that was clicked carries one. This is the exact join:
|
|
135
|
+
* with it, the terminal attaches to precisely the PTY the PM pointed at, and a reload attaches
|
|
136
|
+
* to the same one again. Without it the tab falls back to matching by NAME, and names repeat
|
|
137
|
+
* across repositories — which is how a click in one repo opened another repo's shell.
|
|
138
|
+
* `spawn` is the opposite instruction, set only by `+ session`: start one, never adopt one. */
|
|
139
|
+
sessionId: (sess && sess.id) || null,
|
|
140
|
+
spawn: !!(sess && sess.spawn),
|
|
134
141
|
});
|
|
135
142
|
}
|
|
136
143
|
|
|
@@ -146,7 +146,32 @@ export function closeTab(id) {
|
|
|
146
146
|
/* The data layer's optional session-start hook. In fixture mode nothing is ever started; in real
|
|
147
147
|
* mode the PTY is data.js's to create, and panel 4's part is only to show the tab for it. */
|
|
148
148
|
let dataMod = null;
|
|
149
|
-
|
|
149
|
+
/* The repaint matters: `syncPad` below cannot name a pad before this module exists, so the render
|
|
150
|
+
* that lands it is the render that first loads the PM's staged text. */
|
|
151
|
+
import('./data.js').then((m) => { dataMod = m; setState({}); }).catch(() => { dataMod = null; });
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The next free `agent <n>`, counted against EVERY name the window can see — the server's live
|
|
155
|
+
* sessions, the ones started in this window, and the tabs already open — not just this page's
|
|
156
|
+
* counter.
|
|
157
|
+
*
|
|
158
|
+
* `startN` resets to 1 on every reload, and the name is what the terminal used to join on and what
|
|
159
|
+
* the tab id is built from. So after a reload the first `+ session` proposed `agent 1`, which was
|
|
160
|
+
* already running from before the reload: the click attached to that old session instead of
|
|
161
|
+
* starting a new one, and a second click collided with its own tab id. Counting past what exists
|
|
162
|
+
* is what makes `+ session` mean "a new one" again.
|
|
163
|
+
*/
|
|
164
|
+
function nextSessionName() {
|
|
165
|
+
let max = state.startN - 1;
|
|
166
|
+
const bump = (name) => {
|
|
167
|
+
const m = /^agent (\d+)$/.exec(String(name || ''));
|
|
168
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
169
|
+
};
|
|
170
|
+
for (const s of (state.sessions || [])) bump(s && s.name);
|
|
171
|
+
for (const k in (state.extraSess || {})) for (const s of (state.extraSess[k] || [])) bump(s && s.name);
|
|
172
|
+
for (const t of (state.tabs || [])) if (t && t.kind === 'term') bump(t.sess);
|
|
173
|
+
return { n: max + 1, name: 'agent ' + (max + 1) };
|
|
174
|
+
}
|
|
150
175
|
|
|
151
176
|
/**
|
|
152
177
|
* `+ session` (line 2087): file a new session against the selected initiative and open its tab.
|
|
@@ -156,8 +181,11 @@ import('./data.js').then((m) => { dataMod = m; }).catch(() => { dataMod = null;
|
|
|
156
181
|
export function newSession(slug) {
|
|
157
182
|
const target = slug || selSlug();
|
|
158
183
|
if (!target) return;
|
|
159
|
-
const n =
|
|
160
|
-
|
|
184
|
+
const { n, name } = nextSessionName();
|
|
185
|
+
/* `spawn` is carried on the session and thence onto the tab: it is an INSTRUCTION to start a PTY,
|
|
186
|
+
* and it is the only thing that authorises one. Without it the terminal resolves by name, which
|
|
187
|
+
* is a question about what already exists, not a request for something new. */
|
|
188
|
+
const sess = { name, st: 'working', label: 'working', profile: state.newSessProfile, spawn: true };
|
|
161
189
|
setState((s) => ({
|
|
162
190
|
startN: n + 1,
|
|
163
191
|
extraSess: { ...s.extraSess, [target]: [...(s.extraSess[target] || []), sess] },
|
|
@@ -204,13 +232,118 @@ const panesRow = el('div', { style: 'flex:1;display:flex;min-height:0' });
|
|
|
204
232
|
* One textarea, created once. Its text is written back as a CHILD TEXT NODE as well as a value,
|
|
205
233
|
* because that is what the prototype's runtime does and the comparator reads text nodes — but only
|
|
206
234
|
* when the two differ, so a repaint mid-sentence never moves the PM's caret.
|
|
235
|
+
*
|
|
236
|
+
* ── ONE PAD PER SESSION ─────────────────────────────────────────────────────────────────────
|
|
237
|
+
* The pad stages the next thing to type into a PARTICULAR agent, so it belongs to that agent's
|
|
238
|
+
* session. The four-panel port shipped it as one in-memory string for the whole page, which meant
|
|
239
|
+
* every terminal — in every repository, in every window — showed and overwrote the same buffer.
|
|
240
|
+
*
|
|
241
|
+
* The store is `state.scratchPads`, keyed by:
|
|
242
|
+
* `sess:<server session id>` — a terminal tab bound to a live PTY. The id is the SERVER's, so two
|
|
243
|
+
* sessions both called `agent 1` in two repositories are two pads.
|
|
244
|
+
* `ws:<workspace id>` — everything else (a doc, a task, nothing open): the repository pad.
|
|
245
|
+
* That is exactly the key space `/api/scratchpad` already stores under — the endpoint has been
|
|
246
|
+
* per-session all along, and it is the client half that went missing. `state.scratchText` remains
|
|
247
|
+
* the text ON SCREEN, so the copy button, `⇥ send to terminal` and the fixture render are unchanged.
|
|
248
|
+
*
|
|
249
|
+
* FIXTURE MODE IS UNTOUCHED: no key, no fetch, one pad, the prototype's own placeholder. The offline
|
|
250
|
+
* demo path must make no network call, and the comparator must see the design's string.
|
|
207
251
|
*/
|
|
252
|
+
const SCRATCH_PLACEHOLDER = FIXTURE
|
|
253
|
+
? 'scratchpad — survives tab switches, dies with the page'
|
|
254
|
+
: 'scratchpad — one pad per session, saved on this machine';
|
|
255
|
+
|
|
208
256
|
const scratchTa = el('textarea', {
|
|
209
|
-
placeholder:
|
|
257
|
+
placeholder: SCRATCH_PLACEHOLDER,
|
|
210
258
|
style: 'height:96px;background:oklch(0.11 0.004 228.8);color:#c4ccce;padding:10px 14px;font-family:var(--mono);font-size:12px',
|
|
211
|
-
onInput: (e) =>
|
|
259
|
+
onInput: (e) => onScratchInput(e.target.value),
|
|
212
260
|
});
|
|
213
261
|
|
|
262
|
+
/* ── pad identity, loading and autosave ─────────────────────────────────────────────────────── */
|
|
263
|
+
|
|
264
|
+
const padLoading = new Set(); // keys with a read in flight — one read per key, ever
|
|
265
|
+
let padTimer = null;
|
|
266
|
+
let padPending = null; // {key, text} not yet written
|
|
267
|
+
|
|
268
|
+
/** The pad the ACTIVE tab stages against, or null when it cannot be named yet. */
|
|
269
|
+
function padKey() {
|
|
270
|
+
if (FIXTURE) return null;
|
|
271
|
+
const act = activeTab();
|
|
272
|
+
if (act && act.kind === 'term') {
|
|
273
|
+
const id = (termMod && typeof termMod.sessionIdOf === 'function')
|
|
274
|
+
? termMod.sessionIdOf(act) : (act.sessionId || null);
|
|
275
|
+
// A terminal whose session has not resolved yet has no pad of its own YET. Null holds the
|
|
276
|
+
// drawer on whatever it was showing rather than flashing an empty pad and then SAVING it over
|
|
277
|
+
// the real one the moment the session lands.
|
|
278
|
+
return id ? 'sess:' + id : null;
|
|
279
|
+
}
|
|
280
|
+
return state.wsId ? 'ws:' + state.wsId : null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Write the pending pad now. Called before leaving a pad, and when the page goes away. */
|
|
284
|
+
function flushPad() {
|
|
285
|
+
if (padTimer) { clearTimeout(padTimer); padTimer = null; }
|
|
286
|
+
if (!padPending) return;
|
|
287
|
+
const { key, text } = padPending;
|
|
288
|
+
padPending = null;
|
|
289
|
+
if (dataMod && typeof dataMod.saveScratchpad === 'function') {
|
|
290
|
+
try { dataMod.saveScratchpad(key, text); } catch { /* the text stays in state either way */ }
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function schedulePadSave(key, text) {
|
|
295
|
+
padPending = { key, text };
|
|
296
|
+
if (padTimer) clearTimeout(padTimer);
|
|
297
|
+
padTimer = setTimeout(flushPad, 500); // one write per pause, not one per keystroke
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function onScratchInput(text) {
|
|
301
|
+
if (FIXTURE) { setState({ scratchText: text }); return; }
|
|
302
|
+
const key = state.scratchKey;
|
|
303
|
+
setState((s) => ({
|
|
304
|
+
scratchText: text,
|
|
305
|
+
scratchPads: key ? { ...s.scratchPads, [key]: text } : s.scratchPads,
|
|
306
|
+
}));
|
|
307
|
+
if (key) schedulePadSave(key, text);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Bring the drawer onto the pad the active tab belongs to, reading it from the server the first
|
|
312
|
+
* time that pad is seen. Called at the top of every render; a no-op unless the pad CHANGED.
|
|
313
|
+
*/
|
|
314
|
+
function syncPad() {
|
|
315
|
+
if (FIXTURE || !dataMod) return; // no data layer yet — retried on the next render
|
|
316
|
+
const key = padKey();
|
|
317
|
+
if (!key || key === state.scratchKey) return;
|
|
318
|
+
flushPad(); // the pad being left is written before it is left
|
|
319
|
+
if (Object.prototype.hasOwnProperty.call(state.scratchPads, key)) {
|
|
320
|
+
setState({ scratchKey: key, scratchText: state.scratchPads[key] });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
setState({ scratchKey: key, scratchText: '' });
|
|
324
|
+
if (padLoading.has(key)) return;
|
|
325
|
+
padLoading.add(key);
|
|
326
|
+
const landed = (text) => {
|
|
327
|
+
padLoading.delete(key);
|
|
328
|
+
setState((s) => ({
|
|
329
|
+
scratchPads: { ...s.scratchPads, [key]: text },
|
|
330
|
+
// …and onto the screen only if this is still the pad on screen. A PM who switched tabs while
|
|
331
|
+
// the read was in flight must not have the arriving text land in the pad they moved to.
|
|
332
|
+
...(s.scratchKey === key ? { scratchText: text } : {}),
|
|
333
|
+
}));
|
|
334
|
+
};
|
|
335
|
+
try { dataMod.loadScratchpad(key).then(landed, () => landed('')); }
|
|
336
|
+
catch { padLoading.delete(key); }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/* A pad edited in the last half-second is still worth keeping when the tab is closed or the machine
|
|
340
|
+
* sleeps. `pagehide` fires where `beforeunload` is unreliable (bfcache, mobile), and both are cheap. */
|
|
341
|
+
if (typeof window !== 'undefined' && !FIXTURE) {
|
|
342
|
+
window.addEventListener('pagehide', flushPad);
|
|
343
|
+
window.addEventListener('beforeunload', flushPad);
|
|
344
|
+
document.addEventListener('visibilitychange', () => { if (document.hidden) flushPad(); });
|
|
345
|
+
}
|
|
346
|
+
|
|
214
347
|
const scratchCopyLabel = iv('⧉ copy');
|
|
215
348
|
const scratchCopyBtn = el('button', {
|
|
216
349
|
onClick: () => {
|
|
@@ -396,7 +529,13 @@ function tabNode(t, act, splitTab) {
|
|
|
396
529
|
const bc = selected ? FX.TAB_COLORS.activeBorder : FX.TAB_COLORS.idleBorder;
|
|
397
530
|
const color = act && act.id === t.id ? FX.TAB_COLORS.activeText : FX.TAB_COLORS.idleText;
|
|
398
531
|
const dotColor = FX.TAB_COLORS.dot[t.kind] || FX.TAB_COLORS.dot.doc;
|
|
399
|
-
|
|
532
|
+
/* A terminal's sub-line names the SESSION IT IS ON, not the word "session". After a reload the PM
|
|
533
|
+
* could not tell which session a re-opened tab had landed on, and the answer was on screen
|
|
534
|
+
* nowhere; the server's id is the one identifier that does not repeat across repositories. The
|
|
535
|
+
* fixture render keeps the design's literal — the comparator reads this text node. */
|
|
536
|
+
const meta = t.kind === 'term'
|
|
537
|
+
? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
|
|
538
|
+
: (t.kind === 'task' ? 'task' : t.role);
|
|
400
539
|
|
|
401
540
|
return el('div', {
|
|
402
541
|
style: 'flex:none;display:flex;align-items:center;gap:8px;padding:0 10px;background:' + bg
|
|
@@ -433,6 +572,8 @@ function tabNode(t, act, splitTab) {
|
|
|
433
572
|
* bar. The panes row renders even when nothing is open, exactly as the design does.
|
|
434
573
|
*/
|
|
435
574
|
export function renderTabs(host) {
|
|
575
|
+
// Which pad the drawer is on is decided from the active tab, before anything is painted from it.
|
|
576
|
+
syncPad();
|
|
436
577
|
const visTabs = visibleTabs();
|
|
437
578
|
const act = visTabs.find((t) => t.id === state.active) || null;
|
|
438
579
|
const splitTab = state.splitId
|
|
@@ -41,15 +41,37 @@ function initial() {
|
|
|
41
41
|
col0: true, col1: true, col2: true, scratchOpen: false, scratchText: '', copied: false,
|
|
42
42
|
flowsMap: null, schedList: null, agentList: null, memory: null,
|
|
43
43
|
savedDocs: {}, scratchCopied: false,
|
|
44
|
+
/* The scratchpad is ONE PAD PER SESSION, not one per page (see panel-tabs.js). `scratchPads`
|
|
45
|
+
* holds every pad this window has loaded, keyed by the pad key the panel derives — `sess:<id>`
|
|
46
|
+
* for a terminal bound to a PTY, `ws:<id>` for the repository pad a non-terminal tab stages
|
|
47
|
+
* against. `scratchText` remains the text ON SCREEN, so every existing reader (the copy button,
|
|
48
|
+
* ⇥ send to terminal, the fixture render) is unchanged. `scratchKey` is which pad that is. */
|
|
49
|
+
scratchPads: {}, scratchKey: null,
|
|
44
50
|
};
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
/* ── persistence ──────────────────────────────────────────────────────────────────────────────
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
54
|
+
* The PM's *arrangement*, and — since the reload defect — WHICH VIEWS WERE OPEN. Never content,
|
|
55
|
+
* never a pad's text, never a live PTY: a tab is a view of a session the server owns, and what is
|
|
56
|
+
* stored here is the identity of the view, not the thing it looks at.
|
|
57
|
+
*
|
|
58
|
+
* Each key carries its own validator, and optionally a `pack` that decides what is WRITTEN. A
|
|
59
|
+
* stored value that fails its validator is DROPPED and the rest of the record is still applied:
|
|
60
|
+
* "layout amnesia" in the PRD's failure-mode list means a stale record from an older build
|
|
61
|
+
* throwing, or silently wiping a layout the PM is working in. An unknown key is simply not read,
|
|
62
|
+
* so an older or newer schema costs nothing.
|
|
63
|
+
*
|
|
64
|
+
* ── Why `tabs` is here, and why it was not ──────────────────────────────────────────────────
|
|
65
|
+
* It read "never tabs" because a tab was thought to be a live thing. It is not: the PTY lives in
|
|
66
|
+
* the server and survives both detach and a server restart (R7), so a window that forgot its tabs
|
|
67
|
+
* was not protecting anything — it was throwing away the only record of which sessions the PM had
|
|
68
|
+
* open. The PM then had to re-find them by hand, one click at a time, with no way to tell which
|
|
69
|
+
* session a re-opened tab had landed on. Restoring the views is what makes the surviving sessions
|
|
70
|
+
* visible again on reload.
|
|
71
|
+
*
|
|
72
|
+
* The stored tab is PACKED, not the live record: a whitelist of scalar fields, capped at 40 tabs.
|
|
73
|
+
* A doc tab's edited buffer, a task's derived prose, and anything a future field might carry stay
|
|
74
|
+
* out of localStorage by construction rather than by remembering to exclude them.
|
|
53
75
|
*/
|
|
54
76
|
const isBool = (v) => typeof v === 'boolean';
|
|
55
77
|
const isSlug = (v) => v === null || typeof v === 'string';
|
|
@@ -57,6 +79,28 @@ const isFlagMap = (v) =>
|
|
|
57
79
|
!!v && typeof v === 'object' && !Array.isArray(v) &&
|
|
58
80
|
Object.keys(v).every((k) => typeof k === 'string' && typeof v[k] === 'boolean');
|
|
59
81
|
|
|
82
|
+
/* The only tab fields that are written. `sessionId` is the load-bearing one: it is the SERVER's own
|
|
83
|
+
* id, so a restored terminal re-attaches to exactly the PTY it was showing rather than re-running
|
|
84
|
+
* the name join — which is what made a reloaded tab land on a session the PM could not identify. */
|
|
85
|
+
const TAB_FIELDS = ['id', 'kind', 'title', 'slug', 'repo', 'sess', 'sessionId', 'state', 'profile',
|
|
86
|
+
'path', 'role', 'phase', 'status'];
|
|
87
|
+
const MAX_TABS = 40;
|
|
88
|
+
|
|
89
|
+
function packTab(t) {
|
|
90
|
+
if (!t || typeof t !== 'object' || typeof t.id !== 'string' || typeof t.kind !== 'string') return null;
|
|
91
|
+
const out = {};
|
|
92
|
+
for (const k of TAB_FIELDS) {
|
|
93
|
+
const v = t[k];
|
|
94
|
+
if (v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') out[k] = v;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const isTabList = (v) =>
|
|
100
|
+
Array.isArray(v) && v.length <= MAX_TABS &&
|
|
101
|
+
v.every((t) => t && typeof t === 'object' && !Array.isArray(t)
|
|
102
|
+
&& typeof t.id === 'string' && typeof t.kind === 'string');
|
|
103
|
+
|
|
60
104
|
const PERSIST = {
|
|
61
105
|
theme: (v) => v === 'dark' || v === 'light',
|
|
62
106
|
col0: isBool, col1: isBool, col2: isBool,
|
|
@@ -65,6 +109,15 @@ const PERSIST = {
|
|
|
65
109
|
sel: isSlug,
|
|
66
110
|
collapsedGroups: isFlagMap,
|
|
67
111
|
collapsedPhases: isFlagMap,
|
|
112
|
+
tabs: isTabList,
|
|
113
|
+
active: isSlug,
|
|
114
|
+
splitId: isSlug,
|
|
115
|
+
scratchOpen: isBool,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** What is WRITTEN for a key, when it is not the live value verbatim. */
|
|
119
|
+
const PACK = {
|
|
120
|
+
tabs: (v) => (Array.isArray(v) ? v.slice(0, MAX_TABS).map(packTab).filter(Boolean) : []),
|
|
68
121
|
};
|
|
69
122
|
|
|
70
123
|
/** The exact set of keys written to localStorage, in write order. */
|
|
@@ -84,13 +137,19 @@ function restore(target) {
|
|
|
84
137
|
try { ok = PERSIST[k](saved[k]); } catch { ok = false; }
|
|
85
138
|
if (ok) target[k] = saved[k];
|
|
86
139
|
}
|
|
140
|
+
/* `active` and `splitId` name tabs. A record whose tab list was dropped (or that names a tab the
|
|
141
|
+
* list no longer holds) must not restore a pointer into nothing — panel 4 would render an empty
|
|
142
|
+
* pane and the PM would see a window with no tab selected and no way to guess why. */
|
|
143
|
+
const ids = new Set((target.tabs || []).map((t) => t.id));
|
|
144
|
+
if (!ids.has(target.active)) target.active = target.tabs.length ? target.tabs[target.tabs.length - 1].id : null;
|
|
145
|
+
if (!ids.has(target.splitId)) target.splitId = null;
|
|
87
146
|
return target;
|
|
88
147
|
}
|
|
89
148
|
|
|
90
149
|
function persist() {
|
|
91
150
|
if (FIXTURE) return;
|
|
92
151
|
const out = {};
|
|
93
|
-
for (const k of persistKeys) out[k] = state[k];
|
|
152
|
+
for (const k of persistKeys) out[k] = PACK[k] ? PACK[k](state[k]) : state[k];
|
|
94
153
|
try { localStorage.setItem(STORE_KEY, JSON.stringify(out)); } catch { /* quota / private mode */ }
|
|
95
154
|
}
|
|
96
155
|
|
|
@@ -149,6 +149,7 @@ function connect() {
|
|
|
149
149
|
for (const rec of panes.values()) {
|
|
150
150
|
if (rec.kind !== 'real') continue;
|
|
151
151
|
rec.attachedId = null;
|
|
152
|
+
rec.sentCols = null; rec.sentRows = null; // the next connection has stated no grid yet
|
|
152
153
|
note(rec, 'pmos: connection to the workspace server lost — retrying');
|
|
153
154
|
}
|
|
154
155
|
clearTimeout(SOCK.timer);
|
|
@@ -181,6 +182,17 @@ function handle(m) {
|
|
|
181
182
|
rec.noted = null;
|
|
182
183
|
try { rec.term.reset(); } catch { /* a disposed terminal */ }
|
|
183
184
|
if (m.buf) { try { rec.term.write(m.buf); } catch { /* ignore */ } }
|
|
185
|
+
/* THE PTY DOES NOT KNOW HOW WIDE THIS BROWSER IS. `attached` carries the SERVER's cols/rows —
|
|
186
|
+
* 80×24 for a session nobody ever resized — and the server never adopts the client's grid on
|
|
187
|
+
* attach. The only thing that ever told it was xterm's `onResize`, which fires when a fit
|
|
188
|
+
* CHANGES the grid; the first fit runs while the socket is still connecting, so `sessionId` was
|
|
189
|
+
* null and the message was dropped, and every later fit measured the same box and changed
|
|
190
|
+
* nothing, so it never fired again. Result: a 200-column window rendering a shell that believes
|
|
191
|
+
* it has 80, wrapping a third of the way across — "the terminal only shows a small portion".
|
|
192
|
+
* The grid is therefore PUSHED here, unconditionally, the moment there is a session to push it
|
|
193
|
+
* to. `sent*` is cleared first so a re-attach after a reconnect re-states it. */
|
|
194
|
+
rec.sentCols = null; rec.sentRows = null;
|
|
195
|
+
syncSize(rec);
|
|
184
196
|
refitVisible();
|
|
185
197
|
return;
|
|
186
198
|
}
|
|
@@ -199,6 +211,8 @@ function handle(m) {
|
|
|
199
211
|
const rec = panes.get(id);
|
|
200
212
|
if (!rec) { send({ t: 'detach', id: m.id }); return; } // the tab closed while we waited
|
|
201
213
|
rec.sessionId = m.id;
|
|
214
|
+
rec.sentCols = null; rec.sentRows = null;
|
|
215
|
+
rememberSession(rec.tab, m.id);
|
|
202
216
|
attach(rec);
|
|
203
217
|
return;
|
|
204
218
|
}
|
|
@@ -218,22 +232,38 @@ const recFor = (sessionId) => {
|
|
|
218
232
|
};
|
|
219
233
|
|
|
220
234
|
/* ── binding a tab to a real session ───────────────────────────────────────────────────────────
|
|
221
|
-
*
|
|
222
|
-
* server
|
|
223
|
-
*
|
|
224
|
-
*
|
|
235
|
+
* `sessionId` on the tab is the ONLY exact join, and it is the one that is used whenever it exists:
|
|
236
|
+
* it is the server's own id, the data layer puts it on every tab opened from a listed session, and
|
|
237
|
+
* `rememberSession` writes it back (and so into localStorage) the first time a tab resolves. The
|
|
238
|
+
* name join below is the fallback for a tab that predates all of that.
|
|
239
|
+
*
|
|
240
|
+
* THE NAME JOIN IS SCOPED TO THE TAB'S OWN WORKSPACE, and no longer falls back to "a session with
|
|
241
|
+
* this name anywhere". Session names are not unique across repositories — every `+ session` in
|
|
242
|
+
* every repo produces `agent 1` — so the unscoped fallback quietly attached a tab in one repository
|
|
243
|
+
* to a PTY running in another, and the PM was shown another repo's shell under their own repo's
|
|
244
|
+
* initiative. A name that resolves nowhere in this workspace is now reported as not found, which is
|
|
245
|
+
* the true answer.
|
|
225
246
|
*/
|
|
226
247
|
function resolveSessionId(tab) {
|
|
227
|
-
if (tab.sessionId) return tab.sessionId;
|
|
228
248
|
const pool = SOCK.sessions.length ? SOCK.sessions
|
|
229
249
|
: (Array.isArray(state.sessions) ? state.sessions : []);
|
|
250
|
+
if (tab.sessionId) {
|
|
251
|
+
// A restored tab names a session from a previous run of this window — or of this SERVER. Trust
|
|
252
|
+
// it while nothing is known (the socket has not said `hello` yet), but once there is a list,
|
|
253
|
+
// an id that is not in it is a session that no longer exists: fall through rather than
|
|
254
|
+
// `attach`ing to it, which the server answers with silence and the PM reads as a dead pane.
|
|
255
|
+
if (!pool.length || pool.some((s) => s && s.id === tab.sessionId)) return tab.sessionId;
|
|
256
|
+
}
|
|
257
|
+
if (tab.spawn) return null; // a `+ session` tab must START one, never adopt one
|
|
230
258
|
const name = tab.sess;
|
|
231
259
|
if (!name) return null;
|
|
232
260
|
const byId = pool.find((s) => s && s.id === name);
|
|
233
261
|
if (byId) return byId.id;
|
|
234
|
-
const ws =
|
|
262
|
+
const ws = wsForTab(tab);
|
|
235
263
|
const inWs = pool.find((s) => s && s.name === name && (!ws || s.ws === ws));
|
|
236
264
|
if (inWs) return inWs.id;
|
|
265
|
+
// No cross-workspace fallback. See the note above: it is how a repository showed another's shell.
|
|
266
|
+
if (ws) return null;
|
|
237
267
|
const anywhere = pool.find((s) => s && s.name === name);
|
|
238
268
|
return anywhere ? anywhere.id : null;
|
|
239
269
|
}
|
|
@@ -254,40 +284,95 @@ function workspaceId() {
|
|
|
254
284
|
}
|
|
255
285
|
|
|
256
286
|
/**
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
* the
|
|
287
|
+
* The workspace THIS TAB belongs to — which is not always the selected one. Every open terminal is
|
|
288
|
+
* kept live, including the ones belonging to a repository the PM is not currently looking at (see
|
|
289
|
+
* panel-tabs.js `keepSessionsLive`), so scoping a parked tab's name join by the SELECTED repository
|
|
290
|
+
* would scope it by the wrong repo entirely. `state.productWs` is the data layer's product-name →
|
|
291
|
+
* workspace-id map; a tab whose repository is not in it falls back to the selected workspace, which
|
|
292
|
+
* is right for the tab on screen and is all a fixture render has.
|
|
293
|
+
*/
|
|
294
|
+
function wsForTab(tab) {
|
|
295
|
+
const map = state.productWs;
|
|
296
|
+
if (tab && tab.repo && map && typeof map === 'object' && map[tab.repo]) return map[tab.repo];
|
|
297
|
+
return workspaceId();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Does this tab carry an instruction to START a session? Only then may a terminal spawn a PTY on
|
|
302
|
+
* its own: a tab opened against a session the board listed must attach to that session or say it is
|
|
303
|
+
* gone — silently starting a second shell with the same name would be a lie about what the PM is
|
|
304
|
+
* looking at.
|
|
305
|
+
*
|
|
306
|
+
* The flag is set by `+ session` and travels ON THE TAB. It used to be inferred by looking the
|
|
307
|
+
* tab's session NAME up in `state.extraSess`, which is a different question wearing the same
|
|
308
|
+
* clothes: after a reload `extraSess` is empty and `startN` is back at 1, so a fresh `+ session`
|
|
309
|
+
* produced the name `agent 1`, the name join found the `agent 1` still running from before the
|
|
310
|
+
* reload, and the click ATTACHED to an old session instead of starting a new one. The PM asked for
|
|
311
|
+
* a session and got someone else's.
|
|
261
312
|
*/
|
|
262
|
-
function
|
|
313
|
+
function wantsSpawn(tab) {
|
|
314
|
+
if (tab && tab.spawn) return true;
|
|
263
315
|
const list = (state.extraSess || {})[tab.slug];
|
|
264
316
|
return Array.isArray(list) && list.some((s) => s && s.name === tab.sess);
|
|
265
317
|
}
|
|
266
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Record the session a tab actually landed on, on the tab record itself — so it is written to
|
|
321
|
+
* localStorage and a reload re-attaches to exactly this PTY rather than re-running the name join.
|
|
322
|
+
* "I don't know which session it has reloaded" is that join being re-run against a list that has
|
|
323
|
+
* moved on; an id does not have that failure mode.
|
|
324
|
+
*/
|
|
325
|
+
function rememberSession(tab, sessionId) {
|
|
326
|
+
if (!tab || !sessionId) return;
|
|
327
|
+
// The guard is checked BEFORE the mutation, and on the tab record itself. `rec.tab` is very often
|
|
328
|
+
// the same object that sits in `state.tabs`, so mutating first and then comparing would always
|
|
329
|
+
// find them equal and the repaint that shows the id on the tab would never be scheduled.
|
|
330
|
+
if (tab.sessionId === sessionId && tab.spawn === false) return;
|
|
331
|
+
tab.sessionId = sessionId;
|
|
332
|
+
tab.spawn = false;
|
|
333
|
+
setState((s) => {
|
|
334
|
+
const i = (s.tabs || []).findIndex((t) => t.id === tab.id);
|
|
335
|
+
if (i < 0) return null;
|
|
336
|
+
const next = [...s.tabs];
|
|
337
|
+
next[i] = { ...next[i], sessionId, spawn: false };
|
|
338
|
+
return { tabs: next };
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
267
342
|
function bind(rec) {
|
|
268
343
|
if (rec.kind !== 'real' || !rec.term) return;
|
|
269
344
|
const id = resolveSessionId(rec.tab);
|
|
270
345
|
if (id) {
|
|
271
|
-
if (rec.sessionId !== id) {
|
|
346
|
+
if (rec.sessionId !== id) {
|
|
347
|
+
rec.sessionId = id; rec.attachedId = null; rec.sentCols = null; rec.sentRows = null;
|
|
348
|
+
}
|
|
349
|
+
rememberSession(rec.tab, id);
|
|
272
350
|
attach(rec);
|
|
273
351
|
return;
|
|
274
352
|
}
|
|
275
353
|
if (!SOCK.open) { note(rec, 'pmos: connecting to the workspace server…'); return; }
|
|
276
354
|
if (SOCK.spawning.get(rec.tab.id)) return;
|
|
277
|
-
if (
|
|
355
|
+
if (wantsSpawn(rec.tab)) {
|
|
278
356
|
// agent 'shell' deliberately: PMOS cannot know which agent CLI the PM meant from a tab alone,
|
|
279
357
|
// and falling back from an agent to a shell after the fact would be the dishonest version.
|
|
280
358
|
SOCK.spawning.set(rec.tab.id, true);
|
|
281
359
|
SOCK.spawnQueue.push(rec.tab.id);
|
|
282
360
|
note(rec, 'pmos: starting a session…', true);
|
|
283
|
-
const ok = send({ t: 'new', name: rec.tab.sess, agent: 'shell', ws:
|
|
361
|
+
const ok = send({ t: 'new', name: rec.tab.sess, agent: 'shell', ws: wsForTab(rec.tab) });
|
|
284
362
|
if (!ok) {
|
|
285
363
|
SOCK.spawning.delete(rec.tab.id);
|
|
286
364
|
SOCK.spawnQueue = SOCK.spawnQueue.filter((x) => x !== rec.tab.id);
|
|
287
365
|
}
|
|
288
366
|
return;
|
|
289
367
|
}
|
|
290
|
-
|
|
368
|
+
/* Say WHICH session was looked for and why it could not be found. A tab restored from a previous
|
|
369
|
+
* run names an id; one opened from the board names a name. Both are worth printing — "nothing was
|
|
370
|
+
* attached" without the identity is exactly the report that left the PM unable to tell which
|
|
371
|
+
* session a reloaded tab had landed on. */
|
|
372
|
+
note(rec, rec.tab.sessionId
|
|
373
|
+
? 'pmos: session ' + rec.tab.sessionId + ' ("' + (rec.tab.sess || '?')
|
|
374
|
+
+ '") is no longer running — nothing was attached'
|
|
375
|
+
: 'pmos: no live session named "' + rec.tab.sess + '" in this repository — nothing was attached');
|
|
291
376
|
}
|
|
292
377
|
|
|
293
378
|
function attach(rec) {
|
|
@@ -296,6 +381,24 @@ function attach(rec) {
|
|
|
296
381
|
if (send({ t: 'attach', id: rec.sessionId })) SOCK.attached.add(rec.sessionId);
|
|
297
382
|
}
|
|
298
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Tell the PTY the grid this browser is actually rendering. Sent whenever the pane is attached and
|
|
386
|
+
* the terminal's cols/rows differ from what this connection last stated — which covers the attach
|
|
387
|
+
* itself, every refit that moves the grid, and a reconnect (where `sent*` is cleared).
|
|
388
|
+
*
|
|
389
|
+
* Idempotent by design: `sent*` makes a re-render cost nothing, and the guard is on what was SENT
|
|
390
|
+
* rather than on what xterm reports, because the server holds the authoritative copy and a resize
|
|
391
|
+
* this connection never sent is a resize the shell never heard.
|
|
392
|
+
*/
|
|
393
|
+
function syncSize(rec) {
|
|
394
|
+
if (!rec || rec.kind !== 'real' || !rec.term) return;
|
|
395
|
+
if (!rec.sessionId || rec.attachedId !== rec.sessionId) return;
|
|
396
|
+
const cols = rec.term.cols, rows = rec.term.rows;
|
|
397
|
+
if (!cols || !rows) return;
|
|
398
|
+
if (rec.sentCols === cols && rec.sentRows === rows) return;
|
|
399
|
+
if (send({ t: 'resize', id: rec.sessionId, cols, rows })) { rec.sentCols = cols; rec.sentRows = rows; }
|
|
400
|
+
}
|
|
401
|
+
|
|
299
402
|
/** Write a status line into the terminal itself — once per distinct message, dimmed. */
|
|
300
403
|
function note(rec, line, force) {
|
|
301
404
|
if (!rec.term) return;
|
|
@@ -321,6 +424,10 @@ export function refitVisible() {
|
|
|
321
424
|
if (!rec.host.isConnected) continue;
|
|
322
425
|
if (!rec.host.offsetWidth || !rec.host.offsetHeight) continue;
|
|
323
426
|
try { rec.fit.fit(); } catch { /* a terminal mid-teardown */ }
|
|
427
|
+
// …and state the resulting grid to the PTY. `fit()` only fires xterm's `onResize` when the
|
|
428
|
+
// grid MOVES, so a pane that was measured before it had a session would otherwise never tell
|
|
429
|
+
// the shell how wide it is. See the `attached` handler.
|
|
430
|
+
syncSize(rec);
|
|
324
431
|
}
|
|
325
432
|
};
|
|
326
433
|
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
|
|
@@ -459,9 +566,7 @@ function buildReal(tab) {
|
|
|
459
566
|
kind: 'real', tab, host, term, fit, sessionId: null, attachedId: null, noted: null, ro: null,
|
|
460
567
|
};
|
|
461
568
|
term.onData((d) => { if (rec.sessionId) send({ t: 'input', id: rec.sessionId, data: d }); });
|
|
462
|
-
term.onResize((
|
|
463
|
-
if (rec.sessionId) send({ t: 'resize', id: rec.sessionId, cols, rows });
|
|
464
|
-
});
|
|
569
|
+
term.onResize(() => syncSize(rec));
|
|
465
570
|
if (typeof ResizeObserver === 'function') {
|
|
466
571
|
rec.ro = new ResizeObserver(() => refitVisible());
|
|
467
572
|
try { rec.ro.observe(host); } catch { /* ignore */ }
|
|
@@ -534,6 +639,17 @@ export function sendToTerm(tab, data) {
|
|
|
534
639
|
return send({ t: 'input', id: rec.sessionId, data });
|
|
535
640
|
}
|
|
536
641
|
|
|
642
|
+
/**
|
|
643
|
+
* The SERVER's session id this tab is bound to, or null. The scratchpad reads it: a pad belongs to
|
|
644
|
+
* the session, and the session's identity is this id — not the tab id (which survives a session
|
|
645
|
+
* being replaced) and not the session name (which repeats across repositories).
|
|
646
|
+
*/
|
|
647
|
+
export function sessionIdOf(tab) {
|
|
648
|
+
const rec = panes.get(tab && (tab.id || tab));
|
|
649
|
+
if (rec && rec.kind === 'real' && rec.sessionId) return rec.sessionId;
|
|
650
|
+
return (tab && tab.sessionId) || null;
|
|
651
|
+
}
|
|
652
|
+
|
|
537
653
|
/** Focus the terminal of a tab, if it is live. */
|
|
538
654
|
export function focusTerm(tab) {
|
|
539
655
|
const rec = panes.get(tab && (tab.id || tab));
|
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 startPolicy = require('./lib/start-policy'); // ONE stated rule for where a session starts (repo-first R1/R3)
|
|
74
75
|
const treeShare = require('./lib/tree-share'); // who shares a working tree, and who is DECLARED to be driving it
|
|
75
76
|
const tmuxControl = require('./lib/tmux-control'); // tmux -CC protocol: the pane's raw stream, so xterm.js keeps its scrollback
|
|
76
77
|
const relatedDocs = require('./lib/related-docs'); // which documents a change puts out of step (R5/R6)
|
|
@@ -1306,6 +1307,10 @@ function persist(s) {
|
|
|
1306
1307
|
try {
|
|
1307
1308
|
fs.writeFileSync(path.join(SESS_DIR, s.id + '.json'), JSON.stringify(meta));
|
|
1308
1309
|
fs.writeFileSync(path.join(SESS_DIR, s.id + '.scrollback'), s.buf);
|
|
1310
|
+
// What is on disk now matches `s.buf`. The periodic writer reads this to skip a session whose
|
|
1311
|
+
// buffer has not moved — see the interval below. Cleared only on a write that SUCCEEDED, so a
|
|
1312
|
+
// disk blip leaves the session dirty and it is retried on the next tick.
|
|
1313
|
+
s.bufDirty = false;
|
|
1309
1314
|
} catch { /* best-effort; never crash a live session on a disk blip */ }
|
|
1310
1315
|
}
|
|
1311
1316
|
function unpersist(id) {
|
|
@@ -1641,6 +1646,7 @@ function trimBuf(buf, limit) {
|
|
|
1641
1646
|
function appendBuf(s, data) {
|
|
1642
1647
|
s.buf += data;
|
|
1643
1648
|
if (s.buf.length > SCROLLBACK) s.buf = trimBuf(s.buf, SCROLLBACK);
|
|
1649
|
+
s.bufDirty = true; // the periodic writer skips a session that is not dirty
|
|
1644
1650
|
// Parser 2's whole input: the moment output last arrived. `quiet` is derived from this, never
|
|
1645
1651
|
// from reading the bytes — which is why it is the only one of the five that cannot misread.
|
|
1646
1652
|
s.lastOutputAt = Date.now();
|
|
@@ -1808,6 +1814,7 @@ function seedFromPaneHistory(s, name) {
|
|
|
1808
1814
|
if (!text) return false;
|
|
1809
1815
|
s.buf = trimBuf(text + '\r\n', SCROLLBACK); // 5000 lines of history can overrun the ring
|
|
1810
1816
|
s.parsed = null;
|
|
1817
|
+
s.bufDirty = true; // the ring moved; the file on disk is stale
|
|
1811
1818
|
return true;
|
|
1812
1819
|
}
|
|
1813
1820
|
|
|
@@ -2112,6 +2119,86 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
|
|
|
2112
2119
|
return { ok: true, session: s, notice: treeShare.joinNotices(notice, shared) };
|
|
2113
2120
|
}
|
|
2114
2121
|
|
|
2122
|
+
/* ── workspace-repo-first · start work in one click (PRD R1 · R3) ───────────────────────────────
|
|
2123
|
+
* The composer asked six questions to open a terminal, and the one question the PM actually has —
|
|
2124
|
+
* "which initiative am I working on?" — was not among them. It asked for a BRANCH instead, plus an
|
|
2125
|
+
* isolation checkbox whose default came from liveSessionCount(). These three functions replace that
|
|
2126
|
+
* with: pick a repository, optionally pick an initiative, press one control.
|
|
2127
|
+
*
|
|
2128
|
+
* The policy itself is lib/start-policy.js — pure, so the git-touching paths are testable without
|
|
2129
|
+
* a repository. This layer only supplies it with facts and carries out what it decides.
|
|
2130
|
+
*
|
|
2131
|
+
* R3 is why `agent` appears NOWHERE below except as a value passed through to the spawn: the
|
|
2132
|
+
* destination is derived from the repository and the initiative, so `shell` and `claude` cannot
|
|
2133
|
+
* land in different directories for the same selection. Previously both resolved through
|
|
2134
|
+
* `state.wsId` — whichever folder the client happened to have selected — which is exactly how they
|
|
2135
|
+
* came apart.
|
|
2136
|
+
*/
|
|
2137
|
+
function treesOfRepo(repoKey) {
|
|
2138
|
+
const out = [];
|
|
2139
|
+
for (const w of workspaces.values()) {
|
|
2140
|
+
const ident = repoIdentity(w.root);
|
|
2141
|
+
if (String(ident.key) !== String(repoKey)) continue;
|
|
2142
|
+
out.push({ id: w.id, root: w.root, name: w.name, repo_key: ident.key, branch: ident.branch });
|
|
2143
|
+
}
|
|
2144
|
+
return out;
|
|
2145
|
+
}
|
|
2146
|
+
// The repo key for a request that named a workspace rather than a repository. The client sends
|
|
2147
|
+
// whichever it has; a stale ws id must not silently retarget another repo, so an unknown id is an
|
|
2148
|
+
// error rather than a fallback to "some repository".
|
|
2149
|
+
function repoKeyFor({ repoKey, ws }) {
|
|
2150
|
+
if (repoKey) return String(repoKey);
|
|
2151
|
+
const w = ws && workspaces.get(ws);
|
|
2152
|
+
return w ? String(repoIdentity(w.root).key) : null;
|
|
2153
|
+
}
|
|
2154
|
+
// What the PM is about to get, BEFORE they press anything (R1: the destination is named first).
|
|
2155
|
+
// Read-only: it reads git and resolves the policy, and starts nothing.
|
|
2156
|
+
function startPreview({ initiative, repoKey, ws }) {
|
|
2157
|
+
const key = repoKeyFor({ repoKey, ws });
|
|
2158
|
+
if (!key) return { ok: false, error: 'no repository selected' };
|
|
2159
|
+
const trees = treesOfRepo(key);
|
|
2160
|
+
const main = startPolicy.mainTreeOf(trees);
|
|
2161
|
+
// branchInfo is shelled per call and only when an initiative is named — the no-initiative plan
|
|
2162
|
+
// needs no branch list, and this runs on every selection change.
|
|
2163
|
+
let branches = null;
|
|
2164
|
+
if (initiative && main) {
|
|
2165
|
+
try { const bi = gitInfo.branchInfo(main.root); if (bi.ok) branches = bi.branches; } catch {}
|
|
2166
|
+
}
|
|
2167
|
+
const plan = startPolicy.resolveStart({ initiative, trees, branches });
|
|
2168
|
+
if (!plan.ok) return plan;
|
|
2169
|
+
// The literal directory, so the sentence in the UI is checkable rather than a promise.
|
|
2170
|
+
let dir = null;
|
|
2171
|
+
if (plan.plan === 'new-worktree' && main) { try { dir = gitInfo.worktreePath(main.root, plan.branch); } catch {} }
|
|
2172
|
+
else { const t = trees.find((x) => x.id === plan.wsId); dir = t ? t.root : null; }
|
|
2173
|
+
return { ok: true, plan: plan.plan, why: plan.why, dir, branch: plan.branch, wsId: plan.wsId };
|
|
2174
|
+
}
|
|
2175
|
+
// One click. Resolves the policy, then hands the SAME seam spawnWithBranch already exposes the
|
|
2176
|
+
// branch/isolate decision it made — so the inherited hard floor (a failed git operation spawns
|
|
2177
|
+
// nothing) is enforced in exactly one place and is not re-implemented here.
|
|
2178
|
+
function startWork({ initiative, repoKey, ws, agent, name, profile }) {
|
|
2179
|
+
const key = repoKeyFor({ repoKey, ws });
|
|
2180
|
+
if (!key) return { ok: false, error: 'no repository selected' };
|
|
2181
|
+
const trees = treesOfRepo(key);
|
|
2182
|
+
const main = startPolicy.mainTreeOf(trees);
|
|
2183
|
+
let branches = null;
|
|
2184
|
+
if (initiative && main) {
|
|
2185
|
+
try { const bi = gitInfo.branchInfo(main.root); if (bi.ok) branches = bi.branches; } catch {}
|
|
2186
|
+
}
|
|
2187
|
+
const plan = startPolicy.resolveStart({ initiative, trees, branches });
|
|
2188
|
+
if (!plan.ok) return plan;
|
|
2189
|
+
const r = spawnWithBranch({
|
|
2190
|
+
name: name || initiative || agent || 'shell',
|
|
2191
|
+
agent: agent || 'shell',
|
|
2192
|
+
ws: plan.wsId,
|
|
2193
|
+
branch: plan.branch,
|
|
2194
|
+
createBranch: plan.createBranch,
|
|
2195
|
+
isolate: plan.isolate,
|
|
2196
|
+
profile,
|
|
2197
|
+
});
|
|
2198
|
+
if (!r.ok) return r;
|
|
2199
|
+
return { ok: true, session: r.session, notice: r.notice || plan.why, plan: plan.plan };
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2115
2202
|
// ── Worktree housekeeping: the app that creates the folders shows and clears them ──────────────
|
|
2116
2203
|
// Isolation buys "your other sessions keep running" at the price of a directory per branch. That
|
|
2117
2204
|
// price is only fair if the PM can see the folders and reclaim them without leaving the workspace
|
|
@@ -2255,8 +2342,17 @@ function renameSession(id, name) {
|
|
|
2255
2342
|
return true;
|
|
2256
2343
|
}
|
|
2257
2344
|
|
|
2258
|
-
//
|
|
2259
|
-
|
|
2345
|
+
// Periodic scrollback persistence so a crash loses at most a few seconds of history — but ONLY for
|
|
2346
|
+
// sessions whose buffer actually moved. `persist()` is two SYNCHRONOUS writes, the second of them
|
|
2347
|
+
// the whole ring buffer (up to 200 KB), and this ran unconditionally for every live session every
|
|
2348
|
+
// five seconds. A workspace with six idle panes open was therefore doing over a megabyte of blocking
|
|
2349
|
+
// disk I/O, on a timer, forever — rewriting bytes identical to the ones already on disk, on the same
|
|
2350
|
+
// thread that carries every keystroke to a PTY and every byte back to the browser. That is a stall
|
|
2351
|
+
// the PM feels as the terminal going unresponsive for no reason they can see.
|
|
2352
|
+
//
|
|
2353
|
+
// `bufDirty` is set by appendBuf (the only writer of `s.buf`) and cleared by persist(), so an idle
|
|
2354
|
+
// session now costs one map lookup per tick and a busy one is written exactly as often as before.
|
|
2355
|
+
setInterval(() => { for (const s of sessions.values()) if (s.pty && s.bufDirty) persist(s); }, 5000).unref?.();
|
|
2260
2356
|
|
|
2261
2357
|
// ── File-mode state, per workspace. ───────────────────────────────────────────────────
|
|
2262
2358
|
// A repo that carries PMOS state OWNS its records. The kit (D54) keeps them under `pmos/state/`
|
|
@@ -3504,6 +3600,16 @@ async function handleRequest(req, res) {
|
|
|
3504
3600
|
if (!w) return json(res, { ok: false, error: 'no workspace' });
|
|
3505
3601
|
return json(res, gitInfo.branchInfo(w.root));
|
|
3506
3602
|
}
|
|
3603
|
+
if (p === '/api/start/preview') {
|
|
3604
|
+
// repo-first R1 — what one click will do, said BEFORE the click. Read-only: it resolves the
|
|
3605
|
+
// policy and names the literal destination directory, and starts nothing. This exists so the
|
|
3606
|
+
// rule is checkable by the PM rather than inferred after a folder appears.
|
|
3607
|
+
return json(res, startPreview({
|
|
3608
|
+
initiative: url.searchParams.get('initiative') || '',
|
|
3609
|
+
repoKey: url.searchParams.get('repo') || '',
|
|
3610
|
+
ws: url.searchParams.get('ws') || '',
|
|
3611
|
+
}));
|
|
3612
|
+
}
|
|
3507
3613
|
if (p === '/api/git/worktrees') {
|
|
3508
3614
|
// Every working tree attached to this workspace's repo, each with the reasons it can or cannot
|
|
3509
3615
|
// be removed. A worktree the workspace made is a folder the PM has to live with, so the app
|
|
@@ -4159,7 +4265,7 @@ wss.on('connection', (ws) => {
|
|
|
4159
4265
|
ws.on('message', (raw) => {
|
|
4160
4266
|
let m; try { m = JSON.parse(raw); } catch { return; }
|
|
4161
4267
|
// Gate the shell-spawning / PTY-feeding messages through the buckets before they do any work.
|
|
4162
|
-
if ((m.t === 'new' || m.t === 'respawn') && !rateLimit.take(ws._rlSpawn)) {
|
|
4268
|
+
if ((m.t === 'new' || m.t === 'start' || m.t === 'respawn') && !rateLimit.take(ws._rlSpawn)) {
|
|
4163
4269
|
ws.send(JSON.stringify({ t: 'error', error: 'rate limited: too many session spawns — slow down' }));
|
|
4164
4270
|
return;
|
|
4165
4271
|
}
|
|
@@ -4183,6 +4289,15 @@ wss.on('connection', (ws) => {
|
|
|
4183
4289
|
// non-repo) — said out loud, never swallowed
|
|
4184
4290
|
if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
|
|
4185
4291
|
ws.send(JSON.stringify({ t: 'created', id: r.session.id }));
|
|
4292
|
+
} else if (m.t === 'start') {
|
|
4293
|
+
// repo-first R1 — the one-click path. It carries an INITIATIVE (or none) and a repository,
|
|
4294
|
+
// never a branch and never an isolate flag: those are the policy's to decide, and a client
|
|
4295
|
+
// that could still send them would be a second place the rule lives.
|
|
4296
|
+
const r = startWork({ initiative: m.initiative, repoKey: m.repoKey, ws: m.ws,
|
|
4297
|
+
agent: m.agent, name: m.name, profile: m.profile });
|
|
4298
|
+
if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^profile /.test(r.error || '') ? '' : 'start: ') + r.error })); return; }
|
|
4299
|
+
if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
|
|
4300
|
+
ws.send(JSON.stringify({ t: 'created', id: r.session.id }));
|
|
4186
4301
|
} else if (m.t === 'respawn') {
|
|
4187
4302
|
const old = sessions.get(m.id); if (!old || old.pty) return;
|
|
4188
4303
|
// `respawn` is UNCHANGED on the wire (profiles R9): the profile is read off the record the
|