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.
@@ -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
- repo: slug ? slugProduct()[slug] : null, sess: sess.name, state: sess.st, profile: sess.profile || 'claude-work',
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,