create-pmos 0.26.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/README.md +17 -7
- package/workspace/bin/pmos.js +5 -1
- package/workspace/doctor.js +5 -1
- package/workspace/lib/skill-store.js +180 -6
- package/workspace/lib/state-root.js +587 -0
- package/workspace/package.json +2 -2
- package/workspace/public/pm/data.js +63 -0
- package/workspace/public/pm/panel-initiatives.js +8 -1
- package/workspace/public/pm/panel-newsess.js +507 -0
- package/workspace/public/pm/panel-repos.js +31 -0
- package/workspace/public/pm/panel-tabs.js +34 -2
- package/workspace/public/pm/state.js +40 -1
- package/workspace/public/pm/tab-term.js +277 -16
- package/workspace/server.js +174 -20
- package/workspace/test-run.js +84 -0
|
@@ -795,6 +795,11 @@ function schedulePoll() {
|
|
|
795
795
|
export async function loadRepos() {
|
|
796
796
|
if (FIXTURE) return [];
|
|
797
797
|
const out = await getJson('/api/workspaces');
|
|
798
|
+
// The server's one answer to "is the runtime state settled?", published straight onto state so
|
|
799
|
+
// panel 1 can say so. Under an unmigrated conflict the lists below are short by everything the
|
|
800
|
+
// per-user state directory does not hold, and a list that is short without saying so is the
|
|
801
|
+
// silent failure this key exists to end. `{ok:true}` publishes too — the banner reads `ok`.
|
|
802
|
+
const health = out && out.state_health ? out.state_health : null;
|
|
798
803
|
const rows = foldWorkspaces(out && out.workspaces);
|
|
799
804
|
productOrder = rows.map((r) => r.name);
|
|
800
805
|
for (const r of rows) products.set(r.name, Object.assign(products.get(r.name) || {}, r));
|
|
@@ -808,6 +813,7 @@ export async function loadRepos() {
|
|
|
808
813
|
// actually on screen. A restored selection from the PM's last session always wins.
|
|
809
814
|
const sel = state.sel || (p ? firstSlugOf(p.name) : null);
|
|
810
815
|
setState({
|
|
816
|
+
stateHealth: health,
|
|
811
817
|
wsId: p ? p.ws : null,
|
|
812
818
|
// NOT initialised to a value here. What is resolvable is the server's derivation from the PM's
|
|
813
819
|
// agent profiles, it arrives with the first `/api/initiative` payload, and seeding a `[]` in the
|
|
@@ -942,6 +948,63 @@ export async function listFolder(dir) {
|
|
|
942
948
|
return getJson('/api/fs/list', dir ? { path: dir } : null);
|
|
943
949
|
}
|
|
944
950
|
|
|
951
|
+
/* ── what the session pre-flight asks the server ───────────────────────────────────────────────
|
|
952
|
+
* Four read-only endpoints that have been served all along with no client reading them. They are
|
|
953
|
+
* the questions the retired composer asked before a session started, and losing the client half is
|
|
954
|
+
* what left `+ session` spawning on defaults nobody chose.
|
|
955
|
+
*
|
|
956
|
+
* Every one declines in FIXTURE mode, and that is load-bearing rather than tidy: the fixture render
|
|
957
|
+
* must make no network call at all — it is what lets no-network.test.js and the design comparator
|
|
958
|
+
* trust a fixture run. The form degrades to "no repository was read" and the fixture's own profile
|
|
959
|
+
* list, which is a true statement about fixture mode rather than a blank.
|
|
960
|
+
*/
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Every branch of a workspace's repository, plus which one is current and which are already
|
|
964
|
+
* checked out in another working tree. Returns the server's `gitInfo.branchInfo` verbatim, so a
|
|
965
|
+
* non-repo answers `{ok:false, code, error}` and NEVER a fabricated branch — the difference
|
|
966
|
+
* between "this folder has no git" and "git failed" is the picker's to show, so it must survive
|
|
967
|
+
* the trip.
|
|
968
|
+
*/
|
|
969
|
+
export async function loadBranches(ws) {
|
|
970
|
+
if (FIXTURE) return { ok: false, error: 'fixture mode reads no repository' };
|
|
971
|
+
if (!ws) return { ok: false, error: 'no repository selected' };
|
|
972
|
+
return getJson('/api/git/branches', { ws });
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* The Claude profile descriptors, as `{ok, absent, dir, profiles, broken, prefs, error}`.
|
|
977
|
+
*
|
|
978
|
+
* All six fields matter to the caller and none may be flattened here: `absent` ("the directory does
|
|
979
|
+
* not exist") is a different sentence from a successful read that found none; `broken` names a
|
|
980
|
+
* descriptor that must be shown and NOT offered, while every good one beside it is still offered;
|
|
981
|
+
* and `prefs` is what preselects a chip, replacing a hardcoded 'claude-work' that named a file this
|
|
982
|
+
* client had never opened.
|
|
983
|
+
*
|
|
984
|
+
* `env` values are absent by construction on the server side (profileWire) — D08's discipline
|
|
985
|
+
* covers anything the browser receives, and an overlay is where a credential lives.
|
|
986
|
+
*/
|
|
987
|
+
export async function loadProfiles() {
|
|
988
|
+
if (FIXTURE) return { ok: false, error: 'fixture mode reads no profiles' };
|
|
989
|
+
return getJson('/api/profiles');
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** Remember the profile this agent should preselect next time. The server refuses a preference for
|
|
993
|
+
* a descriptor it cannot read, so a stored id always resolves to a chip that is on screen. */
|
|
994
|
+
export async function preferProfile(agent, profile) {
|
|
995
|
+
if (FIXTURE) return { ok: false, error: 'fixture mode remembers nothing' };
|
|
996
|
+
return postJson('/api/profiles/prefer', { agent, profile });
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/** Every working tree of this workspace's repo. The pre-flight reads it for ONE purpose: a branch
|
|
1000
|
+
* checked out elsewhere is a branch git will refuse, and the PM should read that in the picker
|
|
1001
|
+
* rather than as an error after the click. */
|
|
1002
|
+
export async function loadWorktrees(ws) {
|
|
1003
|
+
if (FIXTURE) return { ok: false, error: 'fixture mode reads no repository' };
|
|
1004
|
+
if (!ws) return { ok: false, error: 'no repository selected' };
|
|
1005
|
+
return getJson('/api/git/worktrees', { ws });
|
|
1006
|
+
}
|
|
1007
|
+
|
|
945
1008
|
/**
|
|
946
1009
|
* panel-tabs.js — `startSession(slug, sess)`. Deliberately NOT a spawn.
|
|
947
1010
|
*
|
|
@@ -264,7 +264,14 @@ export function openTerm(slug, sess) {
|
|
|
264
264
|
const key = slug ? 'term:' + slug + ':' + sess.name : 'term:sess:' + ((sess && sess.id) || sess.name);
|
|
265
265
|
openTab({
|
|
266
266
|
id: key, kind: 'term', title: sess.name, slug: slug || null,
|
|
267
|
-
|
|
267
|
+
/* `profile` is the session's OWN, or null — never the literal 'claude-work'. It used to default
|
|
268
|
+
* to that string, which was a claim about a descriptor this client had never read: nothing in
|
|
269
|
+
* data.js reads /api/profiles, so the name was invented here and then filed on the session. It
|
|
270
|
+
* also became load-bearing the moment the tab's profile started travelling to the server on a
|
|
271
|
+
* spawn, because `resolveProfileForSpawn` REFUSES a profile it cannot find — a fabricated
|
|
272
|
+
* default would have turned "no profile chosen" into "session refused". Every display reader
|
|
273
|
+
* (fixture.js:853, modes.js:658/808) already supplies its own fallback for null. */
|
|
274
|
+
repo: slug ? slugProduct()[slug] : null, sess: sess.name, state: sess.st, profile: sess.profile || null,
|
|
268
275
|
/* The SERVER's session id, when the row that was clicked carries one. This is the exact join:
|
|
269
276
|
* with it, the terminal attaches to precisely the PTY the PM pointed at, and a reload attaches
|
|
270
277
|
* to the same one again. Without it the tab falls back to matching by NAME, and names repeat
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/* pm/panel-newsess.js — the session pre-flight: the questions asked BEFORE a PTY exists.
|
|
2
|
+
*
|
|
3
|
+
* ── What this is, and what it is not ────────────────────────────────────────────────────────
|
|
4
|
+
* It is NOT a resurrection of `app.js`'s composer. That file's imperative `$()`/`innerHTML` style
|
|
5
|
+
* is retired; this is `el()`/`setState()` and design tokens like every other four-panel module.
|
|
6
|
+
* What is ported is the composer's INTENT — the five questions it asked and, more importantly, the
|
|
7
|
+
* three places it told the truth about what it could not read.
|
|
8
|
+
*
|
|
9
|
+
* It is also NOT the primary way to start a session. `workspace-repo-first`'s accepted PRD is
|
|
10
|
+
* explicit: one click is primary and the composer is *"demoted, not deleted"*. `+ session` keeps
|
|
11
|
+
* its one-click behaviour and its co-tenancy chooser; this is the `⋯ options` path beside it, for
|
|
12
|
+
* the cases the one-click rule does not cover — a branch off a tag, a colleague's branch, an
|
|
13
|
+
* existing worktree, a specific profile for this session only.
|
|
14
|
+
*
|
|
15
|
+
* ── Why it renders in panel 4 and not panel 3 ───────────────────────────────────────────────
|
|
16
|
+
* It occupies the same slot as `startPromptBlock` (panel-tabs.js): above the panes, below the tab
|
|
17
|
+
* bar. Two reasons, and the second is the load-bearing one:
|
|
18
|
+
* 1. It is about the click that was just made in that bar, so it belongs beside it.
|
|
19
|
+
* 2. Every open pane hosts a live xterm, and a terminal that is re-parented by an unrelated
|
|
20
|
+
* render silently loses its grid. Rendering here leaves every pane exactly where it was;
|
|
21
|
+
* taking over the panes row would move them.
|
|
22
|
+
*
|
|
23
|
+
* ── The one property everything else exists to serve ────────────────────────────────────────
|
|
24
|
+
* A REFUSAL STARTS NOTHING AND SHOWS NOTHING. `onStart` awaits `requestSpawn`, and only a resolved
|
|
25
|
+
* `{ok:true}` writes an `extraSess` row or opens a tab. On `{ok:false}` the form stays open with
|
|
26
|
+
* the server's own sentence in it — no tab, no pane, no xterm, no row. The defect this replaces
|
|
27
|
+
* did the opposite: the click built the tab, the pane's own render asked for the PTY, and the
|
|
28
|
+
* refusal was written as a dimmed line INSIDE a terminal belonging to a session that was never
|
|
29
|
+
* started.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { el } from './dom.js';
|
|
33
|
+
import { state, setState, FIXTURE } from './state.js';
|
|
34
|
+
import * as FX from './fixture.js';
|
|
35
|
+
import { openTerm as openTermTab } from './panel-initiatives.js';
|
|
36
|
+
|
|
37
|
+
/* Both are another module's file, loaded lazily for the reason panel-tabs.js gives: a static import
|
|
38
|
+
* of a file that fails to load takes this whole module down with it, and a panel that cannot draw
|
|
39
|
+
* is a worse failure than a form that cannot start a session yet. */
|
|
40
|
+
let dataMod = null;
|
|
41
|
+
import('./data.js').then((m) => { dataMod = m; setState({}); }).catch(() => { dataMod = null; });
|
|
42
|
+
let termMod = null;
|
|
43
|
+
import('./tab-term.js').then((m) => { termMod = m; }).catch(() => { termMod = null; });
|
|
44
|
+
|
|
45
|
+
/** One interpolated value in its own span — the convention every panel module renders values with. */
|
|
46
|
+
const iv = (v) => el('span', null, v === null || v === undefined ? '' : String(v));
|
|
47
|
+
|
|
48
|
+
/* ── styles: design tokens only, no raw hex (workspace-ci's token-hygiene check) ────────────── */
|
|
49
|
+
|
|
50
|
+
const WRAP = 'flex:none;max-height:56vh;overflow-y:auto;display:flex;flex-direction:column;gap:9px;'
|
|
51
|
+
+ 'padding:12px 14px;border-bottom:1px solid var(--border);background:var(--card)';
|
|
52
|
+
const TITLE = 'font-size:12.5px;color:var(--foreground)';
|
|
53
|
+
const SUB = 'font-size:11.5px;color:var(--muted-foreground);line-height:1.5';
|
|
54
|
+
const LABEL = 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;'
|
|
55
|
+
+ 'color:var(--muted-foreground)';
|
|
56
|
+
const ROW = 'display:flex;gap:6px;align-items:center;flex-wrap:wrap';
|
|
57
|
+
const INPUT = 'font-family:var(--mono);font-size:11.5px;padding:5px 8px;min-height:30px;'
|
|
58
|
+
+ 'background:var(--secondary);border:1px solid var(--input);color:var(--foreground);min-width:200px';
|
|
59
|
+
const CHIP = 'font-family:var(--mono);font-size:10.5px;padding:4px 9px;min-height:26px;'
|
|
60
|
+
+ 'background:var(--secondary);border:1px solid var(--border);color:var(--foreground)';
|
|
61
|
+
const CHIP_ON = 'font-family:var(--mono);font-size:10.5px;padding:4px 9px;min-height:26px;'
|
|
62
|
+
+ 'background:var(--accent);border:1px solid var(--ring);color:var(--foreground)';
|
|
63
|
+
const CHIP_DEAD = 'font-family:var(--mono);font-size:10.5px;padding:4px 9px;min-height:26px;'
|
|
64
|
+
+ 'background:var(--secondary);border:1px solid var(--border);color:var(--muted-foreground);opacity:.55';
|
|
65
|
+
const CHIP_BAD = 'font-family:var(--mono);font-size:10.5px;padding:4px 9px;min-height:26px;'
|
|
66
|
+
+ 'background:var(--secondary);border:1px solid var(--destructive);color:var(--destructive);opacity:.75';
|
|
67
|
+
const HOVER = 'border-color:var(--ring)';
|
|
68
|
+
/* `--brand` is the token layer's one accent; there is no `--primary` in tokens.css and inventing a
|
|
69
|
+
* name would be a token that resolves to nothing in both themes. */
|
|
70
|
+
const PRIMARY = 'font-family:var(--mono);font-size:11px;padding:6px 14px;min-height:32px;'
|
|
71
|
+
+ 'background:var(--brand-wash);border:1px solid var(--brand);color:var(--foreground)';
|
|
72
|
+
const PRIMARY_OFF = 'font-family:var(--mono);font-size:11px;padding:6px 14px;min-height:32px;'
|
|
73
|
+
+ 'background:var(--secondary);border:1px solid var(--border);color:var(--muted-foreground)';
|
|
74
|
+
const ERR = 'font-size:11.5px;line-height:1.5;color:var(--destructive);'
|
|
75
|
+
+ 'border:1px solid var(--destructive);padding:7px 9px;overflow-wrap:anywhere';
|
|
76
|
+
const WARN = 'font-size:11px;line-height:1.5;color:var(--warning);overflow-wrap:anywhere';
|
|
77
|
+
const MUTED_MONO = 'font-family:var(--mono);font-size:10.5px;color:var(--muted-foreground);overflow-wrap:anywhere';
|
|
78
|
+
|
|
79
|
+
/* ── open / close ──────────────────────────────────────────────────────────────────────────── */
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Open the pre-flight for `slug`. NOTHING is started and nothing is written to `extraSess` — that
|
|
83
|
+
* is the entire difference from `newSession()`, which files the row and opens the tab up front.
|
|
84
|
+
*
|
|
85
|
+
* The name is seeded rather than imposed: `<slug> <n>` beats `agent <n>` because the tab bar shows
|
|
86
|
+
* it and "agent 3" says nothing about which of three initiatives it is on. `n` counts past every
|
|
87
|
+
* name the window can see, the same way `nextSessionName` does, so re-opening the form after a
|
|
88
|
+
* reload does not propose a name that is already taken.
|
|
89
|
+
*/
|
|
90
|
+
export function openNewSession(slug, seed) {
|
|
91
|
+
if (!slug) return;
|
|
92
|
+
const s = seed || {};
|
|
93
|
+
setState({
|
|
94
|
+
newSess: { slug },
|
|
95
|
+
startPrompt: null, endConfirm: null,
|
|
96
|
+
newSessName: s.name || proposeName(slug),
|
|
97
|
+
newSessAgent: s.agent || 'shell',
|
|
98
|
+
newSessBranch: '', newSessBranchNew: '', newSessBranchMode: false,
|
|
99
|
+
newSessIsolate: null, newSessError: null, newSessBusy: false,
|
|
100
|
+
// Cleared on the way IN as well as on the way out: a form that opened still showing the profile
|
|
101
|
+
// a previous, abandoned question had chosen would be proposing an answer nobody gave.
|
|
102
|
+
preflightProfile: s.profile === undefined ? null : s.profile,
|
|
103
|
+
});
|
|
104
|
+
refreshReads();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Close it, discarding what was typed. A cancelled question leaves no residue — and `preflightProfile`
|
|
109
|
+
* is part of "no residue", not an exception to it.
|
|
110
|
+
*
|
|
111
|
+
* It used to survive the close, in a slot `+ session` also read, so a profile picked here and then
|
|
112
|
+
* cancelled was filed onto the next one-click start and sent to the server as a spawn instruction.
|
|
113
|
+
* The server refuses a descriptor it cannot resolve, so the click opened a terminal and wrote a
|
|
114
|
+
* refusal into it for a session that was never started. Clearing it here is half the repair; the
|
|
115
|
+
* other half is that `+ session` no longer reads this at all (panel-tabs.js).
|
|
116
|
+
*/
|
|
117
|
+
export function closeNewSession() {
|
|
118
|
+
setState({ newSess: null, newSessError: null, newSessBusy: false, preflightProfile: null });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const isNewSessionOpen = () => !!state.newSess;
|
|
122
|
+
|
|
123
|
+
/** The next free `<slug> <n>`, counted against every name this window can see. */
|
|
124
|
+
function proposeName(slug) {
|
|
125
|
+
const re = new RegExp('^' + slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ' (\\d+)$');
|
|
126
|
+
let max = 0;
|
|
127
|
+
const bump = (name) => { const m = re.exec(String(name || '')); if (m) max = Math.max(max, Number(m[1])); };
|
|
128
|
+
for (const x of (state.sessions || [])) bump(x && x.name);
|
|
129
|
+
for (const k in (state.extraSess || {})) for (const x of (state.extraSess[k] || [])) bump(x && x.name);
|
|
130
|
+
for (const t of (state.tabs || [])) if (t && t.kind === 'term') bump(t.sess);
|
|
131
|
+
return slug + ' ' + (max + 1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* ── the two server reads ──────────────────────────────────────────────────────────────────── */
|
|
135
|
+
|
|
136
|
+
/* Fired when the form opens, and again when the server says a branch moved. Both reads are
|
|
137
|
+
* independent and each writes only its own key, so a repository with no git still gets its profile
|
|
138
|
+
* chips and a machine with no profile directory still gets its branch picker. */
|
|
139
|
+
function refreshReads() {
|
|
140
|
+
if (FIXTURE || !dataMod) return;
|
|
141
|
+
const ws = state.wsId || null;
|
|
142
|
+
if (typeof dataMod.loadBranches === 'function') {
|
|
143
|
+
dataMod.loadBranches(ws).then((r) => setState({ newSessBranches: r || null })).catch(() => {});
|
|
144
|
+
}
|
|
145
|
+
if (typeof dataMod.loadProfiles === 'function') {
|
|
146
|
+
dataMod.loadProfiles().then((r) => {
|
|
147
|
+
setState({ profiles: r || null });
|
|
148
|
+
// Preselect from the server's remembered preference — never from a hardcoded name. A
|
|
149
|
+
// preference naming a descriptor that no longer reads preselects NOTHING rather than
|
|
150
|
+
// substituting another, which is the same rule the server applies to the preference itself.
|
|
151
|
+
const pref = r && r.prefs ? r.prefs[state.newSessAgent] : null;
|
|
152
|
+
const live = (r && Array.isArray(r.profiles) ? r.profiles : []).map((p) => p && p.id);
|
|
153
|
+
if (pref && live.includes(pref) && !state.preflightProfile) setState({ preflightProfile: pref });
|
|
154
|
+
}).catch(() => {});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* The `{t:'branch'}` broadcast lands in `state.branchInfo` (tab-term.js). When it names the
|
|
159
|
+
* workspace this form is asking about, the picker's own read is stale and is taken again — this is
|
|
160
|
+
* what stopped the retired composer's picker going stale after a checkout made in a terminal. */
|
|
161
|
+
let lastBranchAt = 0;
|
|
162
|
+
function absorbBranchBroadcast() {
|
|
163
|
+
const b = state.branchInfo;
|
|
164
|
+
if (!b || !state.newSess) return;
|
|
165
|
+
if (b.at === lastBranchAt) return;
|
|
166
|
+
lastBranchAt = b.at;
|
|
167
|
+
if (b.ws && state.wsId && b.ws !== state.wsId) return;
|
|
168
|
+
// The broadcast carries a full branchInfo payload, so it is usable directly; the re-read is for
|
|
169
|
+
// the fields a broadcast may not carry (worktree occupancy).
|
|
170
|
+
setState({ newSessBranches: { ok: true, ...b } });
|
|
171
|
+
refreshReads();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ── the branch picker ─────────────────────────────────────────────────────────────────────── */
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The picker, or an honest line saying why there is none. Three outcomes, and they are three
|
|
178
|
+
* different sentences on purpose — a folder with no git is not the same event as git failing, and
|
|
179
|
+
* neither is the same as "the read has not come back yet".
|
|
180
|
+
*/
|
|
181
|
+
function branchSection() {
|
|
182
|
+
const info = state.newSessBranches;
|
|
183
|
+
const out = [];
|
|
184
|
+
out.push(el('div', { style: LABEL }, 'branch'));
|
|
185
|
+
|
|
186
|
+
if (FIXTURE) {
|
|
187
|
+
out.push(el('div', { style: SUB }, 'fixture mode reads no repository — a session started here applies no branch'));
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
if (!info) {
|
|
191
|
+
out.push(el('div', { style: SUB }, 'reading the repository…'));
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
if (info.ok === false) {
|
|
195
|
+
/* `code` distinguishes the two. A folder that is not a repository is a normal, calm fact and
|
|
196
|
+
* the control is simply absent. A git error is NOT calm: it is said in warn colour with git's
|
|
197
|
+
* own words, because "no branch control" would otherwise read as "no git here" and the PM would
|
|
198
|
+
* never learn their repository is unreadable. */
|
|
199
|
+
const notRepo = info.code === 'not-a-repo' || /not a git repository/i.test(info.error || '');
|
|
200
|
+
out.push(notRepo
|
|
201
|
+
? el('div', { style: SUB }, 'this repository is not a git working tree — a session starts here with no branch applied')
|
|
202
|
+
: el('div', { style: WARN }, iv('the branch list could not be read — ' + (info.error || 'git gave no reason'))));
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const current = info.branch || null;
|
|
207
|
+
const branches = Array.isArray(info.branches) ? info.branches : [];
|
|
208
|
+
/* A branch checked out in ANOTHER working tree is a branch git will refuse to check out here.
|
|
209
|
+
* Offering it and letting the server say no would be technically honest and practically rude:
|
|
210
|
+
* the PM would meet the refusal after the click. It is shown, named with where it is, and
|
|
211
|
+
* disabled. */
|
|
212
|
+
const elsewhere = new Map();
|
|
213
|
+
for (const w of (Array.isArray(info.worktrees) ? info.worktrees : [])) {
|
|
214
|
+
if (!w || !w.branch || w.branch === current) continue;
|
|
215
|
+
if (!elsewhere.has(w.branch)) elsewhere.set(w.branch, w.dir || 'another working tree');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const pick = (v) => setState({ newSessBranch: v, newSessBranchMode: false, newSessError: null });
|
|
219
|
+
const chips = [
|
|
220
|
+
// ALWAYS first and ALWAYS the default: starting a session must never require a git decision.
|
|
221
|
+
el('button', {
|
|
222
|
+
onClick: () => pick(''),
|
|
223
|
+
style: state.newSessBranch === '' && !state.newSessBranchMode ? CHIP_ON : CHIP, hover: HOVER,
|
|
224
|
+
}, iv('stay on current' + (current ? ' (' + current + ')' : ''))),
|
|
225
|
+
];
|
|
226
|
+
for (const b of branches) {
|
|
227
|
+
if (b === current) continue;
|
|
228
|
+
const taken = elsewhere.get(b);
|
|
229
|
+
chips.push(taken
|
|
230
|
+
? el('button', {
|
|
231
|
+
disabled: true, title: 'git will not check this out twice — it is already in ' + taken,
|
|
232
|
+
style: CHIP_DEAD,
|
|
233
|
+
}, iv(b + ' — in ' + shortPath(taken)))
|
|
234
|
+
: el('button', {
|
|
235
|
+
onClick: () => pick(b),
|
|
236
|
+
style: state.newSessBranch === b && !state.newSessBranchMode ? CHIP_ON : CHIP, hover: HOVER,
|
|
237
|
+
}, iv(b)));
|
|
238
|
+
}
|
|
239
|
+
chips.push(el('button', {
|
|
240
|
+
onClick: () => setState({ newSessBranchMode: true, newSessBranch: '', newSessError: null }),
|
|
241
|
+
style: state.newSessBranchMode ? CHIP_ON : CHIP, hover: HOVER,
|
|
242
|
+
}, '+ new branch…'));
|
|
243
|
+
|
|
244
|
+
out.push(el('div', { style: ROW }, chips));
|
|
245
|
+
|
|
246
|
+
if (state.newSessBranchMode) {
|
|
247
|
+
out.push(el('div', { style: ROW },
|
|
248
|
+
el('input', {
|
|
249
|
+
value: state.newSessBranchNew, placeholder: 'initiative/my-branch', style: INPUT,
|
|
250
|
+
// Written straight onto the state object WITHOUT a render: a repaint between keystrokes
|
|
251
|
+
// rebuilds the input and takes the caret with it. Same contract as panel-detail's newField.
|
|
252
|
+
onInput: (e) => { state.newSessBranchNew = e.target.value; },
|
|
253
|
+
onChange: () => setState({}),
|
|
254
|
+
}),
|
|
255
|
+
));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Isolation is only a question once a branch is actually being changed. Asked about a session
|
|
259
|
+
// that is staying put, it would be a checkbox with no meaning.
|
|
260
|
+
if (state.newSessBranchMode || state.newSessBranch) {
|
|
261
|
+
const on = isolateNow();
|
|
262
|
+
out.push(el('div', { style: ROW },
|
|
263
|
+
el('button', {
|
|
264
|
+
onClick: () => setState({ newSessIsolate: !on }),
|
|
265
|
+
style: on ? CHIP_ON : CHIP, hover: HOVER,
|
|
266
|
+
}, iv((on ? '☑' : '☐') + ' its own folder (worktree)')),
|
|
267
|
+
el('span', { style: SUB }, iv(on
|
|
268
|
+
? 'the branch is checked out in a new folder — sessions already running are untouched'
|
|
269
|
+
: 'this repository is switched to that branch — every session already in it moves too')),
|
|
270
|
+
));
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const shortPath = (p) => { const s = String(p || ''); const i = s.lastIndexOf('/'); return i < 0 ? s : s.slice(i + 1); };
|
|
276
|
+
|
|
277
|
+
/** What the isolate checkbox currently reads — the PM's choice if they made one, else the server's
|
|
278
|
+
* own rule (protect other sessions when there are other sessions to protect), computed here so the
|
|
279
|
+
* box shows what will actually be sent rather than an unchecked default that means something else. */
|
|
280
|
+
function isolateNow() {
|
|
281
|
+
if (state.newSessIsolate !== null && state.newSessIsolate !== undefined) return state.newSessIsolate;
|
|
282
|
+
const ws = state.wsId;
|
|
283
|
+
const live = (state.sessions || []).filter((s) => s && s.ws === ws && s.state !== 'exited');
|
|
284
|
+
return live.length > 0;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* ── the profile chips ─────────────────────────────────────────────────────────────────────── */
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Ported from `renderProfileChips` (app.js:1655) for its three honesty branches, which are the
|
|
291
|
+
* whole reason this is not just a list:
|
|
292
|
+
* · a FAILED read offers only `no profile`, and says why in the server's own words;
|
|
293
|
+
* · a BROKEN descriptor is named, marked, and NOT offered — while every good chip beside it
|
|
294
|
+
* still is, because one bad file must not turn the surface into a stated absence;
|
|
295
|
+
* · `absent` ("the directory does not exist") reads differently from a read that found none.
|
|
296
|
+
*/
|
|
297
|
+
function profileSection() {
|
|
298
|
+
const out = [el('div', { style: LABEL }, 'profile')];
|
|
299
|
+
const p = state.profiles;
|
|
300
|
+
|
|
301
|
+
const chipFor = (id, label) => el('button', {
|
|
302
|
+
onClick: () => setState({ preflightProfile: id, newSessError: null }),
|
|
303
|
+
style: state.preflightProfile === id ? CHIP_ON : CHIP, hover: HOVER,
|
|
304
|
+
}, iv(label || id));
|
|
305
|
+
|
|
306
|
+
// `no profile` is a REAL choice, not the absence of one: it is sent as `profile: null`, which the
|
|
307
|
+
// server distinguishes from a client that forgot the field.
|
|
308
|
+
const none = chipFor(null, 'no profile');
|
|
309
|
+
|
|
310
|
+
if (FIXTURE) {
|
|
311
|
+
const list = (FX.PROFILES || []).map((x) => x && x.id).filter(Boolean);
|
|
312
|
+
out.push(el('div', { style: ROW }, none, list.map((id) => chipFor(id))));
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
if (!p) { out.push(el('div', { style: SUB }, 'reading the profile descriptors…')); return out; }
|
|
316
|
+
|
|
317
|
+
if (p.ok === false || p.error) {
|
|
318
|
+
out.push(el('div', { style: ROW }, none));
|
|
319
|
+
out.push(el('div', { style: WARN },
|
|
320
|
+
iv('the profile descriptors could not be read — ' + (p.error || 'no reason given')
|
|
321
|
+
+ '. Only “no profile” is offered; PMOS will not name a descriptor it did not read.')));
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const good = Array.isArray(p.profiles) ? p.profiles : [];
|
|
326
|
+
const broken = Array.isArray(p.broken) ? p.broken : [];
|
|
327
|
+
out.push(el('div', { style: ROW },
|
|
328
|
+
none,
|
|
329
|
+
good.map((x) => chipFor(x.id, x.id)),
|
|
330
|
+
// Shown and withheld in the same breath. An unusable descriptor that simply vanished would
|
|
331
|
+
// leave the PM wondering where their profile went.
|
|
332
|
+
broken.map((b) => el('button', { disabled: true, style: CHIP_BAD, title: b.error },
|
|
333
|
+
iv((b.file || '?').replace(/\.json$/, '') + ' — unusable'))),
|
|
334
|
+
));
|
|
335
|
+
|
|
336
|
+
if (p.absent) {
|
|
337
|
+
out.push(el('div', { style: SUB }, iv('no profile directory at ' + (p.dir || '?')
|
|
338
|
+
+ ' — nothing was read, so nothing is offered but “no profile”')));
|
|
339
|
+
} else if (!good.length && !broken.length) {
|
|
340
|
+
out.push(el('div', { style: SUB }, iv((p.dir || 'the profile directory') + ' was read and holds no descriptors')));
|
|
341
|
+
}
|
|
342
|
+
if (broken.length) {
|
|
343
|
+
out.push(el('div', { style: WARN },
|
|
344
|
+
iv(broken.length + ' descriptor' + (broken.length === 1 ? '' : 's') + ' could not be parsed and '
|
|
345
|
+
+ (broken.length === 1 ? 'is' : 'are') + ' not offered: '
|
|
346
|
+
+ broken.map((b) => b.file + ' (' + b.error + ')').join(' · '))));
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/* ── starting ──────────────────────────────────────────────────────────────────────────────── */
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* THE INVERSION. Ask first; open only on an answer that said yes.
|
|
355
|
+
*
|
|
356
|
+
* Every early exit and every failure path here leaves the surface exactly as it found it. There is
|
|
357
|
+
* deliberately no `finally` that opens a tab, no `catch` that assumes success, and no best-effort
|
|
358
|
+
* write to `extraSess` before the await — the previous implementation's `try { … } catch {}` around
|
|
359
|
+
* the real start, with the tab already open, is the shape being removed.
|
|
360
|
+
*/
|
|
361
|
+
async function onStart() {
|
|
362
|
+
if (state.newSessBusy) return; // one request per press; the button also disables
|
|
363
|
+
const open = state.newSess;
|
|
364
|
+
if (!open) return;
|
|
365
|
+
const slug = open.slug;
|
|
366
|
+
const name = String(state.newSessName || '').trim() || proposeName(slug);
|
|
367
|
+
const createBranch = !!state.newSessBranchMode;
|
|
368
|
+
const branch = createBranch ? String(state.newSessBranchNew || '').trim() : (state.newSessBranch || null);
|
|
369
|
+
|
|
370
|
+
if (createBranch && !branch) {
|
|
371
|
+
setState({ newSessError: 'name the new branch, or choose “stay on current”' });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/* FIXTURE MODE FILES THE ROW AND OPENS THE TAB WITH NO REQUEST AT ALL.
|
|
376
|
+
*
|
|
377
|
+
* That is not a shortcut around R1, it is R1's premise: in fixture mode there is no server, so
|
|
378
|
+
* "did the server accept?" is not a question that exists to be answered honestly. The offline
|
|
379
|
+
* demo path must make no network call — it is what lets no-network.test.js and the design
|
|
380
|
+
* comparator trust a fixture run — and a form that could never complete there would be a dead
|
|
381
|
+
* control in the only mode the comparator ever sees. The refusal contract is a REAL-MODE
|
|
382
|
+
* contract and is asserted in real mode (pm-term-test.js).
|
|
383
|
+
*/
|
|
384
|
+
if (FIXTURE) {
|
|
385
|
+
const sess = { name, st: 'working', label: 'working', profile: state.preflightProfile || null,
|
|
386
|
+
branch: branch || null };
|
|
387
|
+
setState((s) => ({
|
|
388
|
+
// The question is over, so its answer is discarded with it — see closeNewSession.
|
|
389
|
+
newSess: null, newSessBusy: false, newSessError: null, preflightProfile: null,
|
|
390
|
+
extraSess: { ...s.extraSess, [slug]: [...(s.extraSess[slug] || []), sess] },
|
|
391
|
+
}), () => openTermTab(slug, sess));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (!termMod || typeof termMod.requestSpawn !== 'function') {
|
|
396
|
+
setState({ newSessError: 'the terminal module has not loaded — nothing was started' });
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
setState({ newSessBusy: true, newSessError: null });
|
|
401
|
+
const res = await termMod.requestSpawn({
|
|
402
|
+
name,
|
|
403
|
+
agent: state.newSessAgent || 'shell',
|
|
404
|
+
ws: state.wsId || null,
|
|
405
|
+
branch: branch || null,
|
|
406
|
+
createBranch,
|
|
407
|
+
// Sent explicitly, ALWAYS, and only when a branch is in play — the server defaults an omitted
|
|
408
|
+
// value from liveSessionCount, and its own comment asks the client to state what the PM saw.
|
|
409
|
+
isolate: branch ? isolateNow() : null,
|
|
410
|
+
profile: state.preflightProfile === undefined ? null : state.preflightProfile,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
if (!res || !res.ok) {
|
|
414
|
+
/* THE REFUSAL PATH. The form stays open, the server's sentence is shown in it, and NOTHING was
|
|
415
|
+
* created: no tab, no pane, no xterm, no extraSess row. This is the assertion the rubric makes
|
|
416
|
+
* a veto. */
|
|
417
|
+
setState({ newSessBusy: false, newSessError: (res && res.error) || 'the workspace server refused, without saying why' });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/* Accepted. Only now does anything appear. The session record carries the SERVER's id, so the tab
|
|
422
|
+
* opened from it attaches to precisely this PTY — `bind()` returns at its first line and never
|
|
423
|
+
* reaches the fallback spawn, so one press cannot produce two sessions. */
|
|
424
|
+
const sess = {
|
|
425
|
+
name, st: 'working', label: 'working',
|
|
426
|
+
profile: state.preflightProfile || null,
|
|
427
|
+
branch: branch || null,
|
|
428
|
+
id: res.id,
|
|
429
|
+
};
|
|
430
|
+
setState((s) => ({
|
|
431
|
+
// `sess` above already carries the profile; the slot is cleared with the question it answered,
|
|
432
|
+
// so the next `+ session` cannot inherit it. See closeNewSession.
|
|
433
|
+
newSess: null, newSessBusy: false, newSessError: null, preflightProfile: null,
|
|
434
|
+
extraSess: { ...s.extraSess, [slug]: [...(s.extraSess[slug] || []), sess] },
|
|
435
|
+
}), () => {
|
|
436
|
+
openTermTab(slug, sess);
|
|
437
|
+
// The server's non-fatal truth about what it just did — a worktree's path, "not a git
|
|
438
|
+
// repository, so no branch was applied". It travelled with the acceptance and is written into
|
|
439
|
+
// the pane it is about, rather than being dropped because the request succeeded.
|
|
440
|
+
if (res.notice && termMod && typeof termMod.noteOnTab === 'function') {
|
|
441
|
+
termMod.noteOnTab('term:' + slug + ':' + name, 'pmos: ' + res.notice);
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
if (dataMod && typeof dataMod.startSession === 'function') {
|
|
445
|
+
try { dataMod.startSession(slug, sess); } catch { /* the poll is a refresh, not the start */ }
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/* ── render ────────────────────────────────────────────────────────────────────────────────── */
|
|
450
|
+
|
|
451
|
+
/** The pre-flight block, or null when it is closed. Called from panel-tabs.js's render. */
|
|
452
|
+
export function newSessionBlock() {
|
|
453
|
+
if (!state.newSess) return null;
|
|
454
|
+
absorbBranchBroadcast();
|
|
455
|
+
const slug = state.newSess.slug;
|
|
456
|
+
const busy = !!state.newSessBusy;
|
|
457
|
+
|
|
458
|
+
return el('div', { style: WRAP },
|
|
459
|
+
el('div', { style: 'display:flex;gap:8px;align-items:baseline;flex-wrap:wrap' },
|
|
460
|
+
el('span', { style: TITLE }, 'start a session — more options'),
|
|
461
|
+
el('span', { style: MUTED_MONO }, iv(slug)),
|
|
462
|
+
),
|
|
463
|
+
el('div', { style: SUB },
|
|
464
|
+
'What this session runs, and where. + session does all of this in one click on the rule; '
|
|
465
|
+
+ 'this is for the times the rule is not what you want.'),
|
|
466
|
+
|
|
467
|
+
el('div', { style: LABEL }, 'name'),
|
|
468
|
+
el('div', { style: ROW },
|
|
469
|
+
el('input', {
|
|
470
|
+
value: state.newSessName, placeholder: slug + ' 1', style: INPUT, disabled: busy,
|
|
471
|
+
onInput: (e) => { state.newSessName = e.target.value; },
|
|
472
|
+
onChange: () => setState({}),
|
|
473
|
+
}),
|
|
474
|
+
),
|
|
475
|
+
|
|
476
|
+
el('div', { style: LABEL }, 'agent'),
|
|
477
|
+
el('div', { style: ROW },
|
|
478
|
+
['shell', 'claude'].map((a) => el('button', {
|
|
479
|
+
onClick: () => setState({ newSessAgent: a, newSessError: null }),
|
|
480
|
+
style: state.newSessAgent === a ? CHIP_ON : CHIP, hover: HOVER,
|
|
481
|
+
}, iv(a))),
|
|
482
|
+
el('span', { style: SUB }, 'a CLI that is not on PATH is refused by the server, and says so'),
|
|
483
|
+
),
|
|
484
|
+
|
|
485
|
+
branchSection(),
|
|
486
|
+
profileSection(),
|
|
487
|
+
|
|
488
|
+
// The refusal, where the question was asked. It is rendered ABOVE the button so the PM reads it
|
|
489
|
+
// before pressing again, and it survives until something changes.
|
|
490
|
+
state.newSessError ? el('div', { style: ERR }, iv(state.newSessError)) : null,
|
|
491
|
+
|
|
492
|
+
el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding-top:2px' },
|
|
493
|
+
el('button', {
|
|
494
|
+
onClick: () => { if (!busy) onStart(); },
|
|
495
|
+
disabled: busy, style: busy ? PRIMARY_OFF : PRIMARY, hover: busy ? null : HOVER,
|
|
496
|
+
}, busy ? 'starting…' : 'start session'),
|
|
497
|
+
el('button', { onClick: () => closeNewSession(), style: CHIP, hover: HOVER, disabled: busy }, 'cancel'),
|
|
498
|
+
),
|
|
499
|
+
|
|
500
|
+
el('div', { style: SUB },
|
|
501
|
+
'Nothing appears until the server says it started. If it refuses — an unreadable profile, a '
|
|
502
|
+
+ 'branch git will not check out — you will read the reason here and no session, tab or '
|
|
503
|
+
+ 'terminal will have been created.'),
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export default newSessionBlock;
|
|
@@ -245,6 +245,36 @@ export function onAddProduct() { setDetail({ kind: 'addprod' }); }
|
|
|
245
245
|
* Fill panel 1. `host` IS the prototype's open-panel container (app.js owns its style), so this
|
|
246
246
|
* appends the panel's three parts as direct children and never wraps them.
|
|
247
247
|
*/
|
|
248
|
+
/* ── AN UNSETTLED STATE ROOT, ON SCREEN ──────────────────────────────────────────────────────
|
|
249
|
+
* `state_health` arrives on /api/workspaces (and on /api/skills, /api/schedules and every
|
|
250
|
+
* /api/console payload). When it is `{ok:false}` the workspace could not move the PM's runtime
|
|
251
|
+
* state out of the checkout, and anything the per-user directory does not hold would otherwise
|
|
252
|
+
* render as a confident empty list — which is how the PM came to see zero skills and zero
|
|
253
|
+
* schedules while forty-five scheduled runs stopped firing.
|
|
254
|
+
*
|
|
255
|
+
* IT BRANCHES ON `ok`, NEVER ON `kind`. The server discriminates the reason — `unmigrated` (two
|
|
256
|
+
* populated directories), `failed` (the filesystem refused the move, with the errno and the cause
|
|
257
|
+
* in words), `unreadable` (one of the two directories could not be listed, so whether it holds
|
|
258
|
+
* state is unknown), `unusable-root` (the state root could not be created at all), `unknown` (an
|
|
259
|
+
* outcome that build did not recognise) — and puts the whole explanation in `error`. Reading only
|
|
260
|
+
* `ok` and `error` here is what makes a kind added later visible on this surface with no client
|
|
261
|
+
* change, which is the failure mode that produced this banner's second round: a `kind` the client
|
|
262
|
+
* had never heard of drawing nothing at all. Two kinds have since been added with no edit here,
|
|
263
|
+
* and state-isolation's F14/U19 assert each of them on a real screen.
|
|
264
|
+
*
|
|
265
|
+
* So it is drawn here, at the top of panel 1, in `--destructive`, on every paint until it is
|
|
266
|
+
* resolved. It is deliberately NOT dismissible: the condition is still true after the click.
|
|
267
|
+
* Nothing renders when the state is settled — including in fixture mode, which has no data layer
|
|
268
|
+
* and therefore never sets the key, so the comparator sees the prototype's markup unchanged. */
|
|
269
|
+
function stateHealthBanner() {
|
|
270
|
+
const h = state.stateHealth;
|
|
271
|
+
if (!h || h.ok !== false) return null;
|
|
272
|
+
return el('div', {
|
|
273
|
+
'data-state-health': String(h.kind || 'unknown'),
|
|
274
|
+
style: 'padding:9px 14px;border-bottom:1px solid var(--border);font-size:11px;line-height:1.5;color:var(--destructive)',
|
|
275
|
+
}, ip(h.error || 'the workspace runtime state is not settled — it could not be moved out of the checkout'));
|
|
276
|
+
}
|
|
277
|
+
|
|
248
278
|
export function renderRepos(host) {
|
|
249
279
|
const tuples = repoTuples();
|
|
250
280
|
const product = activeProduct(tuples);
|
|
@@ -262,6 +292,7 @@ export function renderRepos(host) {
|
|
|
262
292
|
);
|
|
263
293
|
|
|
264
294
|
const scroll = el('div', { style: 'flex:1;overflow-y:auto;min-height:0' },
|
|
295
|
+
stateHealthBanner(),
|
|
265
296
|
tuples.map((t) => repoButton(t, product)),
|
|
266
297
|
el('button', {
|
|
267
298
|
onClick: onAddProduct,
|