@gaia-ai/addon-gaia-ui 0.11.2 → 0.11.3

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.
@@ -1,5 +1,17 @@
1
1
  import { spawn as nodeSpawn } from 'node:child_process';
2
- import { signalVerifiedProcess } from '@gaia-ai/core';
2
+ import { machineContextPath, readMachineContext, signalVerifiedProcess, } from '@gaia-ai/core';
3
+ /**
4
+ * This machine's host_key, from the same MachineContext the CLI reads.
5
+ *
6
+ * Exported so the Conductors screens (GAIA-450 B10) can key their view-only
7
+ * marking and their `actions()` gate on IDENTITY — this row's `host_key`
8
+ * against this machine's own — rather than on `isLocal`'s filesystem check,
9
+ * which two machines whose checkouts share a path can both satisfy.
10
+ */
11
+ export async function defaultThisHostKey() {
12
+ const ctx = await readMachineContext(machineContextPath());
13
+ return ctx.machine_id ?? '';
14
+ }
3
15
  /**
4
16
  * Start and stop a conductor from the cockpit.
5
17
  *
@@ -15,8 +27,27 @@ import { signalVerifiedProcess } from '@gaia-ai/core';
15
27
  export function createConductorProcessHost(deps = {}) {
16
28
  const spawn = deps.spawn ?? defaultSpawn;
17
29
  const signal = deps.signal ?? signalVerifiedProcess;
30
+ const thisHostKey = deps.thisHostKey ?? defaultThisHostKey;
18
31
  return {
19
- async start({ workspaceRoot, conductorName }) {
32
+ thisHostKey,
33
+ async start({ workspaceRoot, conductorName, hostKey }) {
34
+ // GAIA-450 review B5: the only gate this used to have was `isLocal` — a
35
+ // filesystem check on `workspaceRoot`, which answers "does this path
36
+ // exist on THIS disk", not "is this THIS machine's conductor". Two
37
+ // machines whose checkouts share a path (the ordinary case for one
38
+ // operator with several) make that a false positive: pressing `s` on a
39
+ // ROW naming another machine's conductor silently started this
40
+ // machine's OWN one instead, composed from `workspaceRoot` alone with no
41
+ // identity at all. `stop` never had this hole — it already runs through
42
+ // `signalVerifiedProcess`'s `host_key` check downstream — so `start`
43
+ // gets the same verification here, the one place both actions now share.
44
+ const expected = await thisHostKey();
45
+ if (!hostKey || hostKey !== expected) {
46
+ return {
47
+ ok: false,
48
+ message: `conductor's host_key (${hostKey ?? '(none)'}) is not this machine's (${expected}) — refusing to start a conductor on another host`,
49
+ };
50
+ }
20
51
  try {
21
52
  // Awaited, so "starting …" is claimed only once the fork has really
22
53
  // happened — and so an `'error'` event becomes a reported failure
@@ -49,8 +80,13 @@ export function createConductorProcessHost(deps = {}) {
49
80
  };
50
81
  }
51
82
  },
52
- async stop({ pid, workspaceRoot }) {
53
- const outcome = signal({ pid, workspaceRoot, signal: 'SIGTERM' });
83
+ async stop({ pid, hostKey }) {
84
+ const outcome = signal({
85
+ pid,
86
+ hostKey,
87
+ thisHostKey: await thisHostKey(),
88
+ signal: 'SIGTERM',
89
+ });
54
90
  return outcome.ok
55
91
  ? {
56
92
  ok: true,
@@ -31,6 +31,14 @@ export declare class ConductorsScreen extends AsyncScreen<ConductorsData> {
31
31
  private selectedIndex;
32
32
  private logFor;
33
33
  private readonly busyIds;
34
+ /**
35
+ * GAIA-450 review B6 — every `act()` outcome (success, refusal, thrown
36
+ * failure) lands here so the footer states it instead of staying silent.
37
+ * Cleared on selection change so a stale outcome does not linger under a
38
+ * different row; falls back to {@link viewOnlyNotice} so a row this screen
39
+ * cannot act on is marked BEFORE a key is pressed, not only after.
40
+ */
41
+ private status;
34
42
  topTab(): {
35
43
  readonly level: "root";
36
44
  readonly tab: "conductors";
@@ -42,11 +50,46 @@ export declare class ConductorsScreen extends AsyncScreen<ConductorsData> {
42
50
  protected renderReady(data: ConductorsData, context: ScreenContext): VNode;
43
51
  protected didLoad(data: ConductorsData): void;
44
52
  protected chrome(_context: ScreenContext): FullScreenChrome;
53
+ /**
54
+ * GAIA-450 review B9 — `s`/`x` are advertised only when the SELECTED row can
55
+ * actually be acted on: `mine` is the identity gate (B10), not `isLocal`'s
56
+ * filesystem check, so a foreign conductor whose workspace happens to exist
57
+ * on this disk (GAIA-281 Finding 2) is correctly excluded too.
58
+ *
59
+ * GAIA-450 review B12 — `Enter :log` joins the same gate. A row that is not
60
+ * ours has no readable log on this machine (see {@link logFor}), so
61
+ * advertising the key made a promise the footer could not keep.
62
+ */
45
63
  protected actions(): import("../../../Component/footer-actions/index.js").ActionItem[];
64
+ /**
65
+ * GAIA-450 review B6/B10 — a row this screen cannot act on states so,
66
+ * before any key is pressed: `!mine` is the identity gate `act()` uses too.
67
+ * The row's own MARKS cell already carries `elsewhere`; this is the
68
+ * footer's copy of the same fact for the row currently selected.
69
+ */
70
+ private viewOnlyNotice;
71
+ protected notice(): string | undefined;
46
72
  select(index: number, context: ScreenContext): boolean;
73
+ /**
74
+ * GAIA-450 review B12 — gated here, not in `handleInput`: the kernel's own
75
+ * fallback (`tui-kernel.ts` — an unhandled `enter` calls `active()?.activate?.()`
76
+ * unconditionally) and a mouse click on the selected row
77
+ * (`Kernel/regions.ts`'s `rows.activate(index)`) both reach this method
78
+ * directly, bypassing `handleInput` entirely. Refusing here is therefore the
79
+ * one place that actually withholds the log on a row that is not ours,
80
+ * whichever path invoked it.
81
+ */
47
82
  activate(index: number, context: ScreenContext): boolean;
48
83
  handleInput(input: string, context: ScreenContext): boolean;
49
84
  reset(): void;
85
+ /**
86
+ * GAIA-450 review B6 — every outcome is now stated in the footer: the two
87
+ * refusals this screen can decide on its own (foreign row, already-online),
88
+ * the `{ok, message}` `host.start()`/`host.stop()` returns (previously
89
+ * discarded at the call site — the host-mismatch refusal `start` gained in
90
+ * the prior rework was composed only to be dropped here), and a thrown
91
+ * failure.
92
+ */
50
93
  private act;
51
94
  }
52
95
  export {};
@@ -1,4 +1,5 @@
1
1
  import { existsSync } from 'node:fs';
2
+ import { configHomeOf } from '@gaia-ai/core';
2
3
  import { actionItem } from '../../../Component/footer-actions/index.js';
3
4
  import { columnNode, textNode } from '../../../lib/vnode.js';
4
5
  import { AsyncScreen } from '../../../Screen/async-screen.js';
@@ -12,18 +13,30 @@ import { createConductorProcessHost } from '../Data/process.js';
12
13
  * A conductor on another machine has a workspace this machine cannot read, so
13
14
  * the block states that instead of showing an empty tail — the same rule the
14
15
  * row's `elsewhere` mark states for its actions.
16
+ *
17
+ * GAIA-450 review B12: keyed on `mine`, not `isLocal`. `isLocal` is a
18
+ * filesystem existence check on `workspace_root`, so a FOREIGN conductor that
19
+ * happens to share this machine's directory layout (GAIA-281 Finding 2 — two
20
+ * `gaia_conductor` records naming one checkout under different machines) still
21
+ * has `isLocal === true`. Keying the log on it tailed THIS machine's own
22
+ * `log.txt` under the foreign conductor's heading, while the footer
23
+ * simultaneously read "view-only". `mine` is the identity gate `actions()` and
24
+ * `act()` already use; the log now agrees with them.
15
25
  */
16
- const logFor = (row) => row.isLocal
17
- ? {
18
- heading: `log — ${row.workspaceRoot}/log.txt`,
19
- lines: tailConductorLog(`${row.workspaceRoot}/log.txt`, {
20
- maxLines: 40,
21
- }).lines,
22
- }
23
- : {
24
- heading: `log — ${row.label} runs on another machine; no log here`,
25
- lines: [],
26
- };
26
+ const logFor = (row) => {
27
+ // GAIA-450 D4: workspace_root is the checkout root; the log lives under its
28
+ // own .gaia dir, not directly at the root.
29
+ const logPath = `${configHomeOf(row.workspaceRoot)}/log.txt`;
30
+ return row.mine
31
+ ? {
32
+ heading: `log — ${logPath}`,
33
+ lines: tailConductorLog(logPath, { maxLines: 40 }).lines,
34
+ }
35
+ : {
36
+ heading: `log — ${row.label} runs on another machine; no log here`,
37
+ lines: [],
38
+ };
39
+ };
27
40
  /**
28
41
  * Conductors list — a normal {@link AsyncScreen}.
29
42
  *
@@ -42,6 +55,14 @@ export class ConductorsScreen extends AsyncScreen {
42
55
  selectedIndex = 0;
43
56
  logFor = null;
44
57
  busyIds = new Set();
58
+ /**
59
+ * GAIA-450 review B6 — every `act()` outcome (success, refusal, thrown
60
+ * failure) lands here so the footer states it instead of staying silent.
61
+ * Cleared on selection change so a stale outcome does not linger under a
62
+ * different row; falls back to {@link viewOnlyNotice} so a row this screen
63
+ * cannot act on is marked BEFORE a key is pressed, not only after.
64
+ */
65
+ status = '';
45
66
  topTab() {
46
67
  return { level: 'root', tab: 'conductors' };
47
68
  }
@@ -55,13 +76,19 @@ export class ConductorsScreen extends AsyncScreen {
55
76
  if (!context.services)
56
77
  throw new Error('Conductors requires services');
57
78
  const client = context.services.client;
58
- const [projectNames, rows] = await Promise.all([
79
+ const host = context.services.conductorProcess ?? createConductorProcessHost();
80
+ // GAIA-450 B10: `mine` needs THIS machine's own host_key resolved before a
81
+ // row can be projected, so it is read alongside the project-name lookup
82
+ // rather than after — the same seam `start`/`stop` already verify against.
83
+ const [projectNames, thisHostKey] = await Promise.all([
59
84
  listProjectNames(client),
60
- listConductorRows(client, {
61
- now: context.nowSeconds?.() ?? Math.floor(Date.now() / 1000),
62
- exists: existsSync,
63
- }),
85
+ host.thisHostKey(),
64
86
  ]);
87
+ const rows = await listConductorRows(client, {
88
+ now: context.nowSeconds?.() ?? Math.floor(Date.now() / 1000),
89
+ exists: existsSync,
90
+ thisHostKey,
91
+ });
65
92
  return { rows, projectNames };
66
93
  }
67
94
  renderLoading() {
@@ -95,24 +122,64 @@ export class ConductorsScreen extends AsyncScreen {
95
122
  meta: `${rows.length} conductor(s)`,
96
123
  };
97
124
  }
125
+ /**
126
+ * GAIA-450 review B9 — `s`/`x` are advertised only when the SELECTED row can
127
+ * actually be acted on: `mine` is the identity gate (B10), not `isLocal`'s
128
+ * filesystem check, so a foreign conductor whose workspace happens to exist
129
+ * on this disk (GAIA-281 Finding 2) is correctly excluded too.
130
+ *
131
+ * GAIA-450 review B12 — `Enter :log` joins the same gate. A row that is not
132
+ * ours has no readable log on this machine (see {@link logFor}), so
133
+ * advertising the key made a promise the footer could not keep.
134
+ */
98
135
  actions() {
136
+ const canAct = this.rows[this.selectedIndex]?.mine ?? false;
99
137
  return [
100
- actionItem('conductors-start', 's', 'start'),
101
- actionItem('conductors-stop', 'x', 'stop'),
102
- actionItem('conductors-enter', 'Enter', 'log'),
138
+ ...(canAct
139
+ ? [
140
+ actionItem('conductors-start', 's', 'start'),
141
+ actionItem('conductors-stop', 'x', 'stop'),
142
+ actionItem('conductors-enter', 'Enter', 'log'),
143
+ ]
144
+ : []),
103
145
  actionItem('conductors-tab', 'Tab', 'tabs'),
104
146
  ];
105
147
  }
148
+ /**
149
+ * GAIA-450 review B6/B10 — a row this screen cannot act on states so,
150
+ * before any key is pressed: `!mine` is the identity gate `act()` uses too.
151
+ * The row's own MARKS cell already carries `elsewhere`; this is the
152
+ * footer's copy of the same fact for the row currently selected.
153
+ */
154
+ viewOnlyNotice() {
155
+ const row = this.rows[this.selectedIndex];
156
+ return row && !row.mine
157
+ ? `${row.label} runs on another machine — view-only`
158
+ : undefined;
159
+ }
160
+ notice() {
161
+ return this.status || this.viewOnlyNotice();
162
+ }
106
163
  select(index, context) {
107
164
  if (index < 0 || index >= this.rows.length)
108
165
  return false;
109
166
  this.selectedIndex = index;
167
+ this.status = '';
110
168
  context.requestDraw();
111
169
  return true;
112
170
  }
171
+ /**
172
+ * GAIA-450 review B12 — gated here, not in `handleInput`: the kernel's own
173
+ * fallback (`tui-kernel.ts` — an unhandled `enter` calls `active()?.activate?.()`
174
+ * unconditionally) and a mouse click on the selected row
175
+ * (`Kernel/regions.ts`'s `rows.activate(index)`) both reach this method
176
+ * directly, bypassing `handleInput` entirely. Refusing here is therefore the
177
+ * one place that actually withholds the log on a row that is not ours,
178
+ * whichever path invoked it.
179
+ */
113
180
  activate(index, context) {
114
181
  const row = this.rows[index];
115
- if (!row)
182
+ if (!row?.mine)
116
183
  return false;
117
184
  this.logFor = this.logFor === row.id ? null : row.id;
118
185
  context.requestDraw();
@@ -137,35 +204,49 @@ export class ConductorsScreen extends AsyncScreen {
137
204
  this.selectedIndex = 0;
138
205
  this.logFor = null;
139
206
  this.busyIds.clear();
207
+ this.status = '';
140
208
  super.reset();
141
209
  }
210
+ /**
211
+ * GAIA-450 review B6 — every outcome is now stated in the footer: the two
212
+ * refusals this screen can decide on its own (foreign row, already-online),
213
+ * the `{ok, message}` `host.start()`/`host.stop()` returns (previously
214
+ * discarded at the call site — the host-mismatch refusal `start` gained in
215
+ * the prior rework was composed only to be dropped here), and a thrown
216
+ * failure.
217
+ */
142
218
  async act(action, context) {
143
219
  const row = this.rows[this.selectedIndex];
144
220
  if (!row || this.busyIds.has(row.id))
145
221
  return;
146
- if (!row.isLocal)
222
+ if (!row.mine) {
223
+ this.status = `${row.label} runs on another machine — view-only`;
224
+ context.requestDraw();
147
225
  return;
148
- if (action === 'start' && row.derivedStatus === 'running')
226
+ }
227
+ if (action === 'start' && row.derivedStatus === 'online') {
228
+ this.status = `${row.label} is already online`;
229
+ context.requestDraw();
149
230
  return;
231
+ }
150
232
  this.busyIds.add(row.id);
151
233
  context.requestDraw();
152
234
  const host = context.services?.conductorProcess ?? createConductorProcessHost();
153
235
  try {
154
- if (action === 'start') {
155
- await host.start({
236
+ const result = action === 'start'
237
+ ? await host.start({
156
238
  workspaceRoot: row.workspaceRoot,
157
239
  conductorName: row.conductorName,
158
- });
159
- }
160
- else {
161
- await host.stop({
240
+ hostKey: row.hostKey,
241
+ })
242
+ : await host.stop({
162
243
  pid: row.lastPid,
163
- workspaceRoot: row.workspaceRoot,
244
+ hostKey: row.hostKey,
164
245
  });
165
- }
246
+ this.status = result.message;
166
247
  }
167
- catch {
168
- // Outcome lives in the reloaded row state, not a footer notice.
248
+ catch (err) {
249
+ this.status = `${action} failed: ${err instanceof Error ? err.message : String(err)}`;
169
250
  }
170
251
  finally {
171
252
  this.busyIds.delete(row.id);
@@ -0,0 +1,87 @@
1
+ import type { VNode } from '@opentui/core';
2
+ import { AsyncScreen } from '../../../Screen/async-screen.js';
3
+ import type { FullScreenChrome } from '../../../Screen/full-screen.js';
4
+ import type { ScreenContext } from '../../../Screen/screen.js';
5
+ import { type ConductorRow } from '../Data/conductors.js';
6
+ /**
7
+ * The project-scoped Conductors tab (GAIA-450 D13) — the conductors bound to
8
+ * THIS project, beside Tickets, Ticket-Agents and Runs. Same interaction as
9
+ * the root-level Conductors screen (start/stop keys, the log toggle, through
10
+ * the same {@link createConductorProcessHost}) over the same row shape, minus
11
+ * the PROJECT column every row would otherwise repeat.
12
+ *
13
+ * Refresh is the shared mechanism (GAIA-376 `ui-refresh` file + `R`), not a
14
+ * screen-private timer, matching every other list on this level.
15
+ */
16
+ export declare class ProjectConductorsScreen extends AsyncScreen<readonly ConductorRow[]> {
17
+ static readonly route: {
18
+ id: string;
19
+ path: string;
20
+ parent: string;
21
+ tab: {
22
+ id: string;
23
+ label: string;
24
+ scope: string;
25
+ order: number;
26
+ };
27
+ };
28
+ readonly id: string;
29
+ readonly frameId = "project-conductors";
30
+ private selectedIndex;
31
+ private logFor;
32
+ private readonly busyIds;
33
+ /**
34
+ * GAIA-450 review B6 — every `act()` outcome (success, refusal, thrown
35
+ * failure) lands here so the footer states it instead of staying silent.
36
+ * Cleared on selection change so a stale outcome does not linger under a
37
+ * different row; falls back to {@link viewOnlyNotice} so a row this screen
38
+ * cannot act on is marked BEFORE a key is pressed, not only after.
39
+ */
40
+ private status;
41
+ topTab(): {
42
+ readonly level: "project";
43
+ readonly tab: "conductors";
44
+ };
45
+ reload(context: ScreenContext): void;
46
+ get rows(): readonly ConductorRow[];
47
+ protected load(context: ScreenContext): Promise<readonly ConductorRow[]>;
48
+ protected renderLoading(): string[];
49
+ protected renderReady(rows: readonly ConductorRow[], context: ScreenContext): VNode;
50
+ protected didLoad(rows: readonly ConductorRow[]): void;
51
+ protected chrome(context: ScreenContext): FullScreenChrome;
52
+ /**
53
+ * GAIA-450 review B9 — `s`/`x` are advertised only when the SELECTED row can
54
+ * actually be acted on: `mine` is the identity gate (B10), not `isLocal`'s
55
+ * filesystem check.
56
+ *
57
+ * GAIA-450 review B12 — `Enter :log` joins the same gate; see the root
58
+ * screen's copy of this comment.
59
+ */
60
+ protected actions(): import("../../../Component/footer-actions/index.js").ActionItem[];
61
+ /**
62
+ * GAIA-450 review B6/B10 — a row this screen cannot act on states so,
63
+ * before any key is pressed: `!mine` is the identity gate `act()` uses too.
64
+ * The row's own MARKS cell already carries `elsewhere`; this is the
65
+ * footer's copy of the same fact for the row currently selected.
66
+ */
67
+ private viewOnlyNotice;
68
+ protected notice(): string | undefined;
69
+ select(index: number, context: ScreenContext): boolean;
70
+ /**
71
+ * GAIA-450 review B12 — gated here, not in `handleInput`; see the root
72
+ * screen's copy of this comment for why (the kernel's unhandled-`enter`
73
+ * fallback and a mouse click both call this directly).
74
+ */
75
+ activate(index: number, context: ScreenContext): boolean;
76
+ handleInput(input: string, context: ScreenContext): boolean;
77
+ reset(): void;
78
+ /**
79
+ * GAIA-450 review B6 — every outcome is now stated in the footer: the two
80
+ * refusals this screen can decide on its own (foreign row, already-online),
81
+ * the `{ok, message}` `host.start()`/`host.stop()` returns (previously
82
+ * discarded at the call site — the host-mismatch refusal `start` gained in
83
+ * the prior rework was composed only to be dropped here), and a thrown
84
+ * failure.
85
+ */
86
+ private act;
87
+ }
@@ -0,0 +1,245 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { configHomeOf } from '@gaia-ai/core';
3
+ import { actionItem } from '../../../Component/footer-actions/index.js';
4
+ import { columnNode, textNode } from '../../../lib/vnode.js';
5
+ import { AsyncScreen } from '../../../Screen/async-screen.js';
6
+ import { listRowIds, listTableTree } from '../../collection.js';
7
+ import { listConductorRows, PROJECT_CONDUCTOR_TABLE, } from '../Data/conductors.js';
8
+ import { tailConductorLog } from '../Data/log-tail.js';
9
+ import { createConductorProcessHost } from '../Data/process.js';
10
+ /**
11
+ * The open row's log block: where it is, and its tail.
12
+ *
13
+ * Identical to the root Conductors screen's own — see its comment for why a
14
+ * non-local row shows a stated reason rather than an empty tail. Not shared
15
+ * as an import: a two-line function duplicated once is cheaper than a shared
16
+ * module two screens would otherwise have to agree on evolving together.
17
+ *
18
+ * GAIA-450 review B12: keyed on `mine`, not `isLocal` — see the root screen's
19
+ * copy of this comment for the GAIA-281 Finding 2 shape this closes.
20
+ */
21
+ const logFor = (row) => {
22
+ const logPath = `${configHomeOf(row.workspaceRoot)}/log.txt`;
23
+ return row.mine
24
+ ? {
25
+ heading: `log — ${logPath}`,
26
+ lines: tailConductorLog(logPath, { maxLines: 40 }).lines,
27
+ }
28
+ : {
29
+ heading: `log — ${row.label} runs on another machine; no log here`,
30
+ lines: [],
31
+ };
32
+ };
33
+ /**
34
+ * The project-scoped Conductors tab (GAIA-450 D13) — the conductors bound to
35
+ * THIS project, beside Tickets, Ticket-Agents and Runs. Same interaction as
36
+ * the root-level Conductors screen (start/stop keys, the log toggle, through
37
+ * the same {@link createConductorProcessHost}) over the same row shape, minus
38
+ * the PROJECT column every row would otherwise repeat.
39
+ *
40
+ * Refresh is the shared mechanism (GAIA-376 `ui-refresh` file + `R`), not a
41
+ * screen-private timer, matching every other list on this level.
42
+ */
43
+ export class ProjectConductorsScreen extends AsyncScreen {
44
+ static route = {
45
+ id: 'project.conductors',
46
+ path: '/projects/:projectId/conductors',
47
+ parent: 'project.dashboard',
48
+ tab: { id: 'conductors', label: 'Conductors', scope: 'project', order: 50 },
49
+ };
50
+ id = ProjectConductorsScreen.route.id;
51
+ frameId = 'project-conductors';
52
+ selectedIndex = 0;
53
+ logFor = null;
54
+ busyIds = new Set();
55
+ /**
56
+ * GAIA-450 review B6 — every `act()` outcome (success, refusal, thrown
57
+ * failure) lands here so the footer states it instead of staying silent.
58
+ * Cleared on selection change so a stale outcome does not linger under a
59
+ * different row; falls back to {@link viewOnlyNotice} so a row this screen
60
+ * cannot act on is marked BEFORE a key is pressed, not only after.
61
+ */
62
+ status = '';
63
+ topTab() {
64
+ return { level: 'project', tab: 'conductors' };
65
+ }
66
+ reload(context) {
67
+ this.retry(context);
68
+ }
69
+ get rows() {
70
+ return this.readyValue ?? [];
71
+ }
72
+ async load(context) {
73
+ if (!context.services || !context.project) {
74
+ throw new Error('Project conductors requires project context');
75
+ }
76
+ const host = context.services.conductorProcess ?? createConductorProcessHost();
77
+ // GAIA-450 B10: `mine` needs THIS machine's own host_key, the same seam
78
+ // `start`/`stop` already verify against.
79
+ const thisHostKey = await host.thisHostKey();
80
+ return listConductorRows(context.services.client, {
81
+ now: context.nowSeconds?.() ?? Math.floor(Date.now() / 1000),
82
+ exists: existsSync,
83
+ thisHostKey,
84
+ }, context.project.id);
85
+ }
86
+ renderLoading() {
87
+ return ['Loading conductors…'];
88
+ }
89
+ renderReady(rows, context) {
90
+ const open = rows.find((row) => row.id === this.logFor);
91
+ const log = open ? logFor(open) : undefined;
92
+ return columnNode('project-conductor-list', listTableTree(PROJECT_CONDUCTOR_TABLE, rows, {
93
+ nowSeconds: context.nowSeconds?.() ?? Math.floor(Date.now() / 1000),
94
+ busyIds: this.busyIds,
95
+ projectNames: new Map(),
96
+ }, { selectedIndex: this.selectedIndex }), ...(log
97
+ ? [
98
+ textNode({ id: 'project-conductor-log-gap', content: '' }),
99
+ textNode({
100
+ id: 'project-conductor-log-heading',
101
+ content: log.heading,
102
+ }),
103
+ ...log.lines.map((line, index) => textNode({ id: `project-conductor-log-${index}`, content: line })),
104
+ ]
105
+ : []));
106
+ }
107
+ didLoad(rows) {
108
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, rows.length - 1));
109
+ }
110
+ chrome(context) {
111
+ const rows = this.rows;
112
+ return {
113
+ selectable: rows.length,
114
+ selectedIndex: this.selectedIndex,
115
+ rowIds: listRowIds(PROJECT_CONDUCTOR_TABLE, rows),
116
+ title: `${context.project?.name ?? context.project?.slug ?? ''} · conductors`,
117
+ meta: `${rows.length} conductor(s)`,
118
+ };
119
+ }
120
+ /**
121
+ * GAIA-450 review B9 — `s`/`x` are advertised only when the SELECTED row can
122
+ * actually be acted on: `mine` is the identity gate (B10), not `isLocal`'s
123
+ * filesystem check.
124
+ *
125
+ * GAIA-450 review B12 — `Enter :log` joins the same gate; see the root
126
+ * screen's copy of this comment.
127
+ */
128
+ actions() {
129
+ const canAct = this.rows[this.selectedIndex]?.mine ?? false;
130
+ return [
131
+ ...(canAct
132
+ ? [
133
+ actionItem('project-conductors-start', 's', 'start'),
134
+ actionItem('project-conductors-stop', 'x', 'stop'),
135
+ actionItem('project-conductors-enter', 'Enter', 'log'),
136
+ ]
137
+ : []),
138
+ actionItem('project-conductors-tab', 'Tab', 'tabs'),
139
+ ];
140
+ }
141
+ /**
142
+ * GAIA-450 review B6/B10 — a row this screen cannot act on states so,
143
+ * before any key is pressed: `!mine` is the identity gate `act()` uses too.
144
+ * The row's own MARKS cell already carries `elsewhere`; this is the
145
+ * footer's copy of the same fact for the row currently selected.
146
+ */
147
+ viewOnlyNotice() {
148
+ const row = this.rows[this.selectedIndex];
149
+ return row && !row.mine
150
+ ? `${row.label} runs on another machine — view-only`
151
+ : undefined;
152
+ }
153
+ notice() {
154
+ return this.status || this.viewOnlyNotice();
155
+ }
156
+ select(index, context) {
157
+ if (index < 0 || index >= this.rows.length)
158
+ return false;
159
+ this.selectedIndex = index;
160
+ this.status = '';
161
+ context.requestDraw();
162
+ return true;
163
+ }
164
+ /**
165
+ * GAIA-450 review B12 — gated here, not in `handleInput`; see the root
166
+ * screen's copy of this comment for why (the kernel's unhandled-`enter`
167
+ * fallback and a mouse click both call this directly).
168
+ */
169
+ activate(index, context) {
170
+ const row = this.rows[index];
171
+ if (!row?.mine)
172
+ return false;
173
+ this.logFor = this.logFor === row.id ? null : row.id;
174
+ context.requestDraw();
175
+ return true;
176
+ }
177
+ handleInput(input, context) {
178
+ if (super.handleInput(input, context))
179
+ return true;
180
+ if (input === 's' || input === 'x') {
181
+ void this.act(input === 's' ? 'start' : 'stop', context);
182
+ return true;
183
+ }
184
+ if (input === 'enter')
185
+ return this.activate(this.selectedIndex, context);
186
+ if (input === 'j' || input === 'down')
187
+ return this.select(Math.min(this.selectedIndex + 1, this.rows.length - 1), context);
188
+ if (input === 'k' || input === 'up')
189
+ return this.select(Math.max(this.selectedIndex - 1, 0), context);
190
+ return false;
191
+ }
192
+ reset() {
193
+ this.selectedIndex = 0;
194
+ this.logFor = null;
195
+ this.busyIds.clear();
196
+ this.status = '';
197
+ super.reset();
198
+ }
199
+ /**
200
+ * GAIA-450 review B6 — every outcome is now stated in the footer: the two
201
+ * refusals this screen can decide on its own (foreign row, already-online),
202
+ * the `{ok, message}` `host.start()`/`host.stop()` returns (previously
203
+ * discarded at the call site — the host-mismatch refusal `start` gained in
204
+ * the prior rework was composed only to be dropped here), and a thrown
205
+ * failure.
206
+ */
207
+ async act(action, context) {
208
+ const row = this.rows[this.selectedIndex];
209
+ if (!row || this.busyIds.has(row.id))
210
+ return;
211
+ if (!row.mine) {
212
+ this.status = `${row.label} runs on another machine — view-only`;
213
+ context.requestDraw();
214
+ return;
215
+ }
216
+ if (action === 'start' && row.derivedStatus === 'online') {
217
+ this.status = `${row.label} is already online`;
218
+ context.requestDraw();
219
+ return;
220
+ }
221
+ this.busyIds.add(row.id);
222
+ context.requestDraw();
223
+ const host = context.services?.conductorProcess ?? createConductorProcessHost();
224
+ try {
225
+ const result = action === 'start'
226
+ ? await host.start({
227
+ workspaceRoot: row.workspaceRoot,
228
+ conductorName: row.conductorName,
229
+ hostKey: row.hostKey,
230
+ })
231
+ : await host.stop({
232
+ pid: row.lastPid,
233
+ hostKey: row.hostKey,
234
+ });
235
+ this.status = result.message;
236
+ }
237
+ catch (err) {
238
+ this.status = `${action} failed: ${err instanceof Error ? err.message : String(err)}`;
239
+ }
240
+ finally {
241
+ this.busyIds.delete(row.id);
242
+ this.retry(context);
243
+ }
244
+ }
245
+ }