@pellux/goodvibes-tui 1.23.0 → 1.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +3 -3
  5. package/src/cli/bundle-command.ts +4 -0
  6. package/src/cli/help.ts +5 -0
  7. package/src/cli/management-utils.ts +4 -0
  8. package/src/cluster/commands.ts +382 -0
  9. package/src/cluster/remote-daemon-target.ts +225 -0
  10. package/src/cluster/render.ts +268 -0
  11. package/src/core/conversation-line-cache.ts +11 -6
  12. package/src/core/conversation-render-context.ts +122 -0
  13. package/src/core/conversation-rendering.ts +86 -89
  14. package/src/daemon/cli.ts +73 -2
  15. package/src/daemon/handlers/inbox/cursor-store.ts +12 -1
  16. package/src/daemon/handlers/inbox/index.ts +63 -3
  17. package/src/daemon/handlers/inbox/poller.ts +66 -10
  18. package/src/daemon/handlers/index.ts +9 -0
  19. package/src/input/command-args-hint.ts +2 -0
  20. package/src/input/commands/cluster-runtime.ts +53 -0
  21. package/src/input/commands.ts +2 -0
  22. package/src/input/settings-modal-data.ts +6 -0
  23. package/src/main.ts +3 -0
  24. package/src/renderer/conversation-surface.ts +23 -6
  25. package/src/renderer/conversation-tree.ts +142 -93
  26. package/src/renderer/tool-call.ts +13 -12
  27. package/src/runtime/agent-graph-composition.ts +78 -0
  28. package/src/runtime/bootstrap.ts +6 -1
  29. package/src/runtime/cluster-composition.ts +124 -0
  30. package/src/runtime/cluster-group-composition.ts +284 -0
  31. package/src/runtime/daemon-handler-composition.ts +80 -0
  32. package/src/runtime/disposal-wiring.ts +83 -0
  33. package/src/runtime/durability-services.ts +7 -3
  34. package/src/runtime/embedded-service-factories.ts +19 -1
  35. package/src/runtime/process-lifecycle.ts +9 -1
  36. package/src/runtime/remote-execution-composition.ts +62 -0
  37. package/src/runtime/services.ts +73 -76
  38. package/src/verification/verification-ledger.ts +38 -0
  39. package/src/version.ts +1 -1
@@ -0,0 +1,53 @@
1
+ /**
2
+ * cluster-runtime.ts — `/cluster` in the TUI.
3
+ *
4
+ * The same operations as the `goodvibes-daemon cluster` subcommands, reaching
5
+ * the same daemon verbs through the same caller. There is no cluster logic in
6
+ * this file and there must never be any: if `/cluster status` and
7
+ * `cluster status` could disagree, one of them would be lying to somebody.
8
+ *
9
+ * The one honest difference is `join` without arguments. The CLI can stop and
10
+ * ask which group and for the join key; a slash command has nowhere to ask, so
11
+ * it requires `--group` and `--key` and says so — which is exactly what the
12
+ * shared caller already reports for any non-interactive invocation.
13
+ */
14
+ import { resolveDaemonHomeDir } from '@pellux/goodvibes-sdk/platform/workspace';
15
+ import { runClusterCommand, CLUSTER_SUBCOMMANDS } from '../../cluster/commands.ts';
16
+ import type { CommandRegistry } from '../command-registry.ts';
17
+
18
+ /** Subcommand → argument hint, surfaced as the operator types. */
19
+ export const CLUSTER_SUBCOMMAND_ARG_HINTS: Record<string, string> = {
20
+ status: '',
21
+ create: '[--name "<group name>"] [--passphrase "<phrase>"]',
22
+ join: '--group <id> --key <join key>',
23
+ key: '',
24
+ nodes: '',
25
+ forget: '<machine>',
26
+ rotate: '[--now]',
27
+ leave: '',
28
+ rename: '"<new name>"',
29
+ groups: '',
30
+ };
31
+
32
+ export function registerClusterRuntimeCommands(registry: CommandRegistry): void {
33
+ registry.register({
34
+ name: 'cluster',
35
+ description: 'Share inbound channel work with your other goodvibes machines on this network',
36
+ usage: `[${CLUSTER_SUBCOMMANDS.join('|')}] — status is the default; `
37
+ + 'add --host/--port/--token to reach a daemon on another machine',
38
+ argsHint: `[${CLUSTER_SUBCOMMANDS.join('|')}]`,
39
+ async handler(args, ctx) {
40
+ const argv = args.length === 0 ? ['status'] : args;
41
+ const result = await runClusterCommand({
42
+ argv,
43
+ configManager: ctx.platform.configManager,
44
+ daemonHomeDir: resolveDaemonHomeDir(),
45
+ // A transcript is not a terminal for clipboard purposes: writing an
46
+ // OSC 52 escape into the rendered conversation would put a control
47
+ // sequence in the scrollback rather than on anyone's clipboard.
48
+ isTerminal: false,
49
+ });
50
+ ctx.print(result.lines.join('\n'));
51
+ },
52
+ });
53
+ }
@@ -56,6 +56,7 @@ import { registerServicesRuntimeCommands } from './commands/services-runtime.ts'
56
56
  import { registerTasksRuntimeCommands } from './commands/tasks-runtime.ts';
57
57
  import { registerLocalProviderRuntimeCommands } from './commands/local-provider-runtime.ts';
58
58
  import { registerHealthRuntimeCommands } from './commands/health-runtime.ts';
59
+ import { registerClusterRuntimeCommands } from './commands/cluster-runtime.ts';
59
60
  import { registerSettingsSyncRuntimeCommands } from './commands/settings-sync-runtime.ts';
60
61
  import { registerWorktreeRuntimeCommands } from './commands/worktree-runtime.ts';
61
62
  import { registerAcpRuntimeCommands } from './commands/acp-runtime.ts';
@@ -139,6 +140,7 @@ export const BUILTIN_COMMAND_GROUPS: readonly BuiltinCommandGroup[] = [
139
140
  { category: 'Tasks', register: registerTasksRuntimeCommands },
140
141
  { category: 'Local Providers', register: registerLocalProviderRuntimeCommands },
141
142
  { category: 'Health', register: registerHealthRuntimeCommands },
143
+ { category: 'Cluster', register: registerClusterRuntimeCommands },
142
144
  { category: 'Settings Sync', register: registerSettingsSyncRuntimeCommands },
143
145
  { category: 'Worktrees', register: registerWorktreeRuntimeCommands },
144
146
  { category: 'Fleet', register: registerAcpRuntimeCommands },
@@ -132,6 +132,12 @@ export function buildSettingGroups(
132
132
  // dropped from the workspace entirely — reachable only by editing the
133
133
  // config file by hand.
134
134
  if (rawCat === 'push' && groups.has('notifications')) groups.get('notifications')!.push(entry);
135
+ // cluster.* decides which node on this network reads the inbox, and it is
136
+ // configured in the same terms the network category already speaks: a
137
+ // group address, a port, a shared phrase, a peer list. Given no category of
138
+ // its own it would match nothing and drop out of the workspace entirely —
139
+ // reachable only by hand-editing a settings file.
140
+ if (rawCat === 'cluster' && groups.has('network')) groups.get('network')!.push(entry);
135
141
  }
136
142
 
137
143
  // Synthetic display.themeMode enum (auto|dark|light) — TUI-local, see theme-mode-config.ts.
package/src/main.ts CHANGED
@@ -221,6 +221,9 @@ async function main() {
221
221
  const powerChipSource = createPowerChipSource({ powerManager: ctx.services.powerManager, render: () => render(), isExternalDaemon,
222
222
  onPowerEvent: (cb) => uiServices.events.ops.on('OPS_POWER_STATE_CHANGED', cb as never), fetchDaemonPowerState: () => fetchDaemonPowerState({ configManager, homeDirectory, isExternalDaemon }) });
223
223
  unsubs.push(powerChipSource.stop, installKeepAwakeRemoteForward({ configManager, homeDirectory, isExternalDaemon }));
224
+ // The interactive process composes a full runtime graph too, and that graph
225
+ // starts pollers. Drained with the rest of the teardown registry on exit.
226
+ unsubs.push(() => { ctx.services.dispose(); });
224
227
 
225
228
  const lifecycle = installProcessLifecycle({
226
229
  stdin,
@@ -2,6 +2,7 @@ import { type Line, createEmptyLine, createStyledCell } from '../types/grid.ts';
2
2
  import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
3
3
  import { LAYOUT } from './layout.ts';
4
4
  import { GLYPHS } from './ui-primitives.ts';
5
+ import { treeBranchCol, treeContentCol } from './conversation-tree.ts';
5
6
 
6
7
  export interface ConversationSurfacePalette {
7
8
  readonly accent: string;
@@ -104,7 +105,12 @@ export function renderConversationFragment(
104
105
  palette: ConversationFragmentPalette,
105
106
  indentCols = 0,
106
107
  ): Line[] {
107
- const margin = LAYOUT.USER_BOX_MARGIN + Math.max(0, indentCols);
108
+ // A tree fragment is a continuation of the row above it, so it starts at that
109
+ // row's content column — which puts its ` ▸ ` prefix glyph in exactly the same
110
+ // column as the parent row's own ` ▸ N lines ` badge. A flush fragment (a user
111
+ // message ghost box) keeps the box margin it has always used.
112
+ const indent = Math.max(0, indentCols);
113
+ const margin = indent > 0 ? treeContentCol(indent) : LAYOUT.USER_BOX_MARGIN;
108
114
  const prefixWidth = getDisplayWidth(palette.prefix);
109
115
  // The indent is charged to the content budget rather than allowed to push the
110
116
  // fragment past the right edge — a narrow terminal shrinks the preview text
@@ -211,15 +217,26 @@ export function renderConversationStatusLine(
211
217
  ): Line {
212
218
  const line = createEmptyLine(width);
213
219
  const indent = Math.max(0, options.indentCols ?? 0);
214
- const markerCol = LAYOUT.LEFT_MARGIN - 1 + indent;
215
- const startCol = LAYOUT.LEFT_MARGIN + 1 + indent;
220
+ // Both columns come from conversation-tree.ts so a flush row and a branch row
221
+ // sit on one grid: the marker where its depth's glyph belongs, the segment run
222
+ // two columns further right.
223
+ const markerCol = treeBranchCol(indent);
224
+ const startCol = treeContentCol(indent);
216
225
  const endCol = Math.max(startCol, width - LAYOUT.RIGHT_MARGIN);
217
- if (markerCol >= 0 && markerCol < width) {
218
- line[markerCol] = createStyledCell(options.marker ?? '▌', {
226
+ const marker = options.marker ?? '▌';
227
+ const markerWidth = getDisplayWidth(marker);
228
+ if (markerCol >= 0 && markerCol < width && markerWidth > 0) {
229
+ const markerStyle = {
219
230
  fg: options.markerFg ?? '#64748b',
220
231
  bg: options.markerBg ?? options.bodyBg ?? '',
221
232
  bold: true,
222
- });
233
+ };
234
+ line[markerCol] = createStyledCell(marker, markerStyle);
235
+ // A double-width marker claims its trailing cell, so the segment run that
236
+ // follows still starts where the grid says it does.
237
+ if (markerWidth > 1 && markerCol + 1 < width) {
238
+ line[markerCol + 1] = createStyledCell('', markerStyle);
239
+ }
223
240
  }
224
241
  let col = startCol;
225
242
  for (const segment of segments) {
@@ -4,60 +4,81 @@
4
4
  * The transcript renders an assistant turn as a small tree: the turn header at
5
5
  * depth 0, each tool call it issues as a depth-1 branch, and each call's result
6
6
  * as a depth-2 branch under that call. This module owns the *only* arithmetic
7
- * that decides where those branch glyphs and their content land, so every row
8
- * type (event lines, tool-call rows, collapsed fragments) agrees on a single
9
- * column grid instead of each re-deriving it.
10
- *
11
- * Geometry, relative to the existing flush layout (LAYOUT.LEFT_MARGIN = 4):
12
- *
13
- * depth 0 ` ● assistant …` marker col 3, content col 5
14
- * depth 1 `✓ ├ config.get …` connector col 5, content col 7
15
- * depth 2 `✓ │ └ ▸ 627 lines …` connector col 7, content col 9
16
- *
17
- * The connector sits where the parent's content began, and content starts two
18
- * columns after it — the same marker→content relationship a depth-0 row has.
19
- * Status lives in a fixed gutter at column 0, OUTSIDE the indent, so every ✓
20
- * and ✗ in the transcript lines up in one column regardless of nesting depth.
21
- *
22
- * Every level steps by exactly TREE_STEP_COLS, and indent, gutter and content
23
- * columns are functions of depth and width ALONE — never of sibling status.
24
- * That is what makes a `└`→`├` connector flip cost exactly one cell: the
25
- * column it occupies exists either way, so no text moves.
26
- *
27
- * Narrow terminals: indenting unconditionally would eat the content budget and
28
- * silently truncate the informative end of a row — the failure class the
29
- * markdown-table column bug came from. treeIndentCols() therefore clamps the
30
- * indent to whatever still leaves TREE_MIN_CONTENT_COLS of content, and drops
31
- * to 0 (flush, no branch glyph) when even one level does not fit. Losing the
32
- * visual nesting on a 30-column terminal is acceptable; losing characters is
33
- * not.
7
+ * that decides where those branch glyphs, status markers and row text land, so
8
+ * every row type (event lines, tool-call rows, collapsed fragments) agrees on a
9
+ * single column grid instead of each re-deriving it.
10
+ *
11
+ * ## The grid
12
+ *
13
+ * One function generates every column, from LAYOUT.LEFT_MARGIN and the row's
14
+ * depth. With LEFT_MARGIN = 4 and TREE_STEP_COLS = 2 that is:
15
+ *
16
+ * depth 0 ` ● assistant …` marker col 3, text col 6
17
+ * depth 1 ` ✓ ├ read foo.ts` marker col 3, connector col 5, text col 8
18
+ * depth 2 ` │ └ ▸ 31 lines` connector col 7, text col 10
19
+ * depth 3 ` │ │ └ …` connector col 9, text col 12
20
+ *
21
+ * A level's connector sits where its parent's content began, and the row's
22
+ * first printable character sits two columns further right — the same
23
+ * marker→text relationship a depth-0 row has. So the step between levels is
24
+ * exactly TREE_STEP_COLS at every depth, for every row type.
25
+ *
26
+ * ## The status column IS the bullet column
27
+ *
28
+ * `treeStatusCol()` is defined as `treeBranchCol(0)` — literally the column the
29
+ * `●` of `● assistant` occupies. A tool row's ✓ / ✗ / ⊘ / ◌ therefore lands in
30
+ * the same column as the bullet of the turn that issued it, and reads as that
31
+ * bullet's column continuing down the turn rather than as a separate rail off
32
+ * to the left. Deriving it from treeBranchCol rather than restating a number is
33
+ * what keeps the two from drifting: move the bullet and the markers follow.
34
+ *
35
+ * The column is free at every depth ≥ 1 by construction — a branch row's own
36
+ * marker moves right with its indent, and depth 0 never contributes a `│`
37
+ * gutter (a top-level row has no ancestor to continue), so nothing else can
38
+ * claim it.
39
+ *
40
+ * ## Rails are continuous
41
+ *
42
+ * `drawTreeRails` paints EVERY line a row emits, not just its first: the
43
+ * connector on line 0, then `│` down the row's own column for as long as the
44
+ * row has a sibling below it, plus `│` in each open ancestor's gutter on every
45
+ * line. That is what closes the gaps a first-line-only connector leaves under
46
+ * multi-line rows (collapsed fragment boxes, wrapped text, expanded bodies).
47
+ *
48
+ * Rails never overwrite text: apart from the connector itself — which
49
+ * deliberately replaces the marker cell of the row it labels — a rail is drawn
50
+ * only into a cell that is blank and unstyled. A row whose own content reaches
51
+ * into a rail column keeps its content and loses the rail there.
52
+ *
53
+ * ## Narrow terminals
54
+ *
55
+ * Indenting unconditionally would eat the content budget and silently truncate
56
+ * the informative end of a row — the failure class the markdown-table column
57
+ * bug came from. treeIndentCols() therefore clamps the indent to whatever still
58
+ * leaves TREE_MIN_CONTENT_COLS of content, and drops to 0 (flush, no branch
59
+ * glyph) when even one level does not fit. Losing the visual nesting on a
60
+ * 30-column terminal is acceptable; losing characters is not.
34
61
  */
35
62
 
36
63
  import { LAYOUT } from './layout.ts';
37
64
  import type { Line } from '../types/grid.ts';
38
65
  import { createStyledCell } from '../types/grid.ts';
66
+ import { getDisplayWidth } from '../utils/terminal-width.ts';
39
67
 
40
68
  /** Columns added per nesting level. Fixed — never content-dependent. */
41
69
  const TREE_STEP_COLS = 2;
42
70
 
43
- /**
44
- * Width of the reserved status column that every row carries, at every depth.
45
- *
46
- * The column exists whether or not the row currently has a marker: a completed
47
- * row shows ✓, a failed row its failure glyph, an in-flight or plain row a
48
- * blank of the same width. Because the space is always reserved, a marker
49
- * appearing or resolving changes only that cell's contents and can never shift
50
- * the label beside it. Combined with fixed per-depth indentation this gives the
51
- * whole tree one rule: columns are fixed, only their contents change.
52
- */
53
- const TREE_STATUS_COLS = 2;
54
-
55
71
  /**
56
72
  * Content columns a branch row is guaranteed to keep. Below this the indent is
57
73
  * reduced (and finally dropped) rather than squeezing the row's text.
58
74
  */
59
75
  const TREE_MIN_CONTENT_COLS = 16;
60
76
 
77
+ /** Vertical rail drawn in an ancestor's gutter and down an open row's column. */
78
+ const TREE_RAIL = '│';
79
+
80
+ /** The connector a row draws when a sibling follows it below. */
81
+ const TREE_CONNECTOR_CONTINUES = '├';
61
82
 
62
83
  /**
63
84
  * Effective indent, in columns, for a branch at `depth` rendered at `width`.
@@ -69,57 +90,66 @@ const TREE_MIN_CONTENT_COLS = 16;
69
90
  */
70
91
  export function treeIndentCols(depth: number, width: number): number {
71
92
  if (depth <= 0) return 0;
72
- // The status gutter is charged first and never given back — see TREE_GUTTER_COL.
73
- const budget = width - LAYOUT.LEFT_MARGIN - LAYOUT.RIGHT_MARGIN - TREE_STATUS_COLS - TREE_MIN_CONTENT_COLS;
93
+ const budget = width - LAYOUT.LEFT_MARGIN - LAYOUT.RIGHT_MARGIN - TREE_MIN_CONTENT_COLS;
74
94
  if (budget < TREE_STEP_COLS) return 0;
75
95
  const affordableLevels = Math.floor(budget / TREE_STEP_COLS);
76
96
  return Math.min(depth, affordableLevels) * TREE_STEP_COLS;
77
97
  }
78
98
 
79
99
  /**
80
- * Write the status gutter for a row: `glyph` when it has one, an untouched
81
- * blank of the same width when it does not. Success and failure differ by
82
- * GLYPH, not only by colour, so the distinction survives a monochrome terminal
83
- * and colour-blind viewers.
100
+ * Column the row's leading glyph occupies for a given effective indent: the
101
+ * `●` of a depth-0 row, the `├`/`└` of a branch row, and the `│` an ancestor
102
+ * carries down through its descendants' rows.
84
103
  */
85
- export function writeTreeStatusGutter(
86
- line: Line,
87
- glyph: string | null,
88
- fg: string,
89
- width: number,
90
- ): void {
91
- if (!glyph || TREE_GUTTER_COL >= width) return;
92
- line[TREE_GUTTER_COL] = createStyledCell(glyph, { fg, bold: true });
104
+ export function treeBranchCol(indentCols: number): number {
105
+ return LAYOUT.LEFT_MARGIN - 1 + indentCols;
93
106
  }
94
107
 
95
108
  /**
96
- * Column the connector (`├`/`└`) occupies for a given effective indent, and
97
- * the column an ancestor's `│` gutter occupies at that same indent.
109
+ * Column at which a row's segment run begins. Segment runs carry one leading
110
+ * blank by convention (` assistant `, ` ▸ 31 lines `, the fragment's ` ▸ `
111
+ * prefix), so this is the gap column and treeTextCol is where glyphs land.
98
112
  */
99
- export function treeBranchCol(indentCols: number): number {
100
- return LAYOUT.LEFT_MARGIN - 1 + indentCols;
113
+ export function treeContentCol(indentCols: number): number {
114
+ return treeBranchCol(indentCols) + 2;
101
115
  }
102
116
 
103
117
  /**
104
- * The status gutter: a fixed column at the far LEFT of every row, before any
105
- * indentation, carrying ✓ / ✕ / blank.
106
- *
107
- * Deliberately outside the indent so it does not scale with depth. With the
108
- * marker placed after the connector instead, status would sit at a different
109
- * horizontal position on every row depending on nesting, so scanning for what
110
- * completed means tracking a ragged edge — and that degrades precisely as
111
- * trees get deeper. Here every marker in the transcript lines up in one
112
- * column no matter how deep its row sits, for the cost of one fixed column.
113
- *
114
- * It is never clamped: narrow terminals clamp the INDENT (see treeIndentCols)
115
- * and leave the gutter intact, because losing nesting depth is recoverable by
116
- * widening the terminal while losing which rows succeeded is not.
118
+ * Column of a row's first PRINTABLE character. Every row type must land its
119
+ * text here — that is the invariant which makes each level step by exactly
120
+ * TREE_STEP_COLS regardless of whether the row came from an event line, a
121
+ * tool-call row or a collapsed fragment.
117
122
  */
118
- const TREE_GUTTER_COL = 0;
123
+ export function treeTextCol(indentCols: number): number {
124
+ return treeContentCol(indentCols) + 1;
125
+ }
119
126
 
120
- /** Column where a branch row's text starts, immediately after its connector. */
121
- export function treeContentCol(indentCols: number): number {
122
- return treeBranchCol(indentCols) + 2;
127
+ /**
128
+ * Write a branch row's status marker into the shared bullet column.
129
+ *
130
+ * Success and failure differ by GLYPH, not only by colour, so the distinction
131
+ * survives a monochrome terminal and colour-blind viewers. A double-width glyph
132
+ * claims its trailing cell too, so the column math stays honest for emoji-class
133
+ * markers instead of leaving a stale half-character behind.
134
+ */
135
+ export function writeTreeStatusMarker(
136
+ line: Line,
137
+ glyph: string | null,
138
+ fg: string,
139
+ width: number,
140
+ ): void {
141
+ if (!glyph) return;
142
+ // THE column: treeBranchCol(0) is where a depth-0 row draws its `●`, so a
143
+ // branch row's marker lands in the bullet column of the turn it belongs to.
144
+ // Derived, never restated, so the two cannot drift apart.
145
+ const col = treeBranchCol(0);
146
+ if (col < 0 || col >= width) return;
147
+ const glyphWidth = getDisplayWidth(glyph);
148
+ if (glyphWidth <= 0) return;
149
+ line[col] = createStyledCell(glyph, { fg, bold: true });
150
+ if (glyphWidth > 1 && col + 1 < width) {
151
+ line[col + 1] = createStyledCell('', { fg, bold: true });
152
+ }
123
153
  }
124
154
 
125
155
  /**
@@ -145,35 +175,54 @@ function treeGutterCols(
145
175
  return cols;
146
176
  }
147
177
 
178
+ /** True when a rail may claim this cell without destroying rendered content. */
179
+ function railCellIsFree(line: Line, col: number): boolean {
180
+ const cell = line[col];
181
+ if (!cell) return false;
182
+ return cell.char === ' ' && !cell.bg;
183
+ }
184
+
185
+ function writeRail(line: Line, col: number, glyph: string, fg: string, width: number, force: boolean): void {
186
+ if (col < 0 || col >= width) return;
187
+ if (!force && !railCellIsFree(line, col)) return;
188
+ line[col] = createStyledCell(glyph, { fg, dim: true });
189
+ }
190
+
148
191
  /**
149
- * Draw a branch row's connector and the `│` gutters of its still-open
150
- * ancestors onto an already-rendered line, in place.
151
- *
152
- * Connectors are recomputed from live structure on every rebuild (see
153
- * conversation-turn-structure.ts), never cached, so a row that stops being
154
- * last flips `└`→`├` here with nothing stale to invalidate. Only this cell
155
- * changes: indent, status gutter and content column are functions of depth and
156
- * width alone, so a sibling arriving repaints one connector cell and moves no
157
- * text.
192
+ * Draw a row's connector and every rail that must pass through it, across ALL
193
+ * the lines the row emitted.
194
+ *
195
+ * Line 0 gets the connector in the row's own column (deliberately replacing
196
+ * whatever marker the row drew there — the connector IS that row's marker).
197
+ * Subsequent lines get `│` in that column while the row still has a sibling
198
+ * below it, so the rail runs unbroken from one `├` to the next. Every line
199
+ * additionally gets `│` in each open ancestor's gutter.
200
+ *
201
+ * Connectors and gutters are recomputed from live structure on every rebuild
202
+ * (see conversation-turn-structure.ts), never cached, so a row that stops being
203
+ * last flips `└`→`├` here with nothing stale to invalidate.
158
204
  */
159
- export function drawBranchConnector(
160
- line: Line,
205
+ export function drawTreeRails(
206
+ lines: readonly Line[],
161
207
  depth: number,
162
208
  connector: string | undefined,
163
209
  openAncestorDepths: readonly number[],
164
210
  width: number,
165
211
  fg: string,
166
212
  ): void {
167
- if (depth <= 0 || !connector) return;
213
+ if (depth <= 0 || !connector || lines.length === 0) return;
168
214
  const indent = treeIndentCols(depth, width);
169
215
  if (indent <= 0) return;
170
- for (const gutterCol of treeGutterCols(openAncestorDepths, depth, width)) {
171
- if (gutterCol >= 0 && gutterCol < width) {
172
- line[gutterCol] = createStyledCell('\u2502', { fg, dim: true });
216
+ const ownCol = treeBranchCol(indent);
217
+ const gutterCols = treeGutterCols(openAncestorDepths, depth, width);
218
+ const continuesBelow = connector === TREE_CONNECTOR_CONTINUES;
219
+
220
+ for (let i = 0; i < lines.length; i++) {
221
+ const line = lines[i]!;
222
+ for (const gutterCol of gutterCols) {
223
+ writeRail(line, gutterCol, TREE_RAIL, fg, width, false);
173
224
  }
174
- }
175
- const col = treeBranchCol(indent);
176
- if (col >= 0 && col < width) {
177
- line[col] = createStyledCell(connector, { fg, dim: true });
225
+ if (i === 0) writeRail(line, ownCol, connector, fg, width, true);
226
+ else if (continuesBelow) writeRail(line, ownCol, TREE_RAIL, fg, width, false);
178
227
  }
179
228
  }
@@ -6,7 +6,7 @@ import { stripDangerousAnsi } from './ansi-sanitize.ts';
6
6
  import { formatElapsed } from '../utils/format-elapsed.ts';
7
7
  import { GLYPHS } from './ui-primitives.ts';
8
8
  import { activeUiTones } from './theme.ts';
9
- import { treeBranchCol, treeContentCol, writeTreeStatusGutter } from './conversation-tree.ts';
9
+ import { treeContentCol, treeTextCol, writeTreeStatusMarker } from './conversation-tree.ts';
10
10
 
11
11
  const TOOL_NAME_MIN_WIDTH = 8;
12
12
  const TOOL_NAME_MAX_WIDTH = 20;
@@ -188,10 +188,11 @@ export function renderToolCallBlock(
188
188
  const line = createEmptyLine(width);
189
189
  const t = activeUiTones();
190
190
  const indent = Math.max(0, tree?.indentCols ?? 0);
191
- // In tree mode the status icon moves to the branch's content column, so the
192
- // ✓ lines up with the content of every other row at the same depth and the
193
- // branch glyph sits where the parent's content began.
194
- const margin = indent > 0 ? treeContentCol(indent) : LAYOUT.LEFT_MARGIN;
191
+ const inTree = indent > 0;
192
+ // In tree mode the row's columns come from the shared grid: the branch glyph
193
+ // where the parent's content began, the status marker in the bullet column,
194
+ // and the row's text at this depth's text column.
195
+ const margin = inTree ? treeContentCol(indent) : LAYOUT.LEFT_MARGIN;
195
196
  const rightMargin = LAYOUT.RIGHT_MARGIN;
196
197
  const contentEnd = width - rightMargin;
197
198
 
@@ -226,18 +227,18 @@ export function renderToolCallBlock(
226
227
  const rightStart = rightText
227
228
  ? Math.max(margin + 2, contentEnd - rightWidth)
228
229
  : contentEnd;
229
- const leftStart = margin;
230
+ const leftStart = inTree ? treeTextCol(indent) : margin;
230
231
  const leftEndExclusive = rightText
231
232
  ? Math.max(leftStart, rightStart - 1)
232
233
  : contentEnd;
233
234
  let col: number = leftStart;
234
235
 
235
- // In tree mode the status glyph goes in the fixed left gutter (column 0),
236
- // before the indent, so every marker in the transcript aligns in one column
237
- // no matter how deep its row sits. The row's own content then starts at the
238
- // branch's content column with no inline icon slot.
239
- if (indent > 0) {
240
- writeTreeStatusGutter(line, icon, iconColor, width);
236
+ // In tree mode the status glyph goes in the bullet column — the same column
237
+ // as the `●` of the `● assistant` header this row hangs under — so a turn's
238
+ // markers read as that bullet's column continuing down the turn. The row's
239
+ // own text then starts at its depth's text column with no inline icon slot.
240
+ if (inTree) {
241
+ writeTreeStatusMarker(line, icon, iconColor, width);
241
242
  } else {
242
243
  if (col < leftEndExclusive) {
243
244
  line[col] = createStyledCell(icon, { fg: iconColor, bold: status === 'done' || status === 'error' || status === 'cancelled' });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * agent-graph-composition.ts — the graph that runs agents.
3
+ *
4
+ * Six collaborators that are only meaningful as a set, so they are built as
5
+ * one: a message bus, the archetype loader, the orchestrator that executes a
6
+ * run, the manager that owns the records, the context-accounting holder, and
7
+ * the WRFC controller. Every one of them holds a reference to at least one
8
+ * other, and two of the links are circular — the orchestrator writes
9
+ * conversation snapshots back through the manager, and the manager drives the
10
+ * WRFC controller which was built from the manager. Assembled anywhere but in
11
+ * one place, a half-wired graph looks correct and silently drops either the
12
+ * snapshot bridge or the review loop.
13
+ *
14
+ * It is also the graph whose runs `cancelHostedAgentRuns` cancels at disposal:
15
+ * the manager returned here is the one the runtime hands to the SDK's
16
+ * `cancelAllAgentRuns`.
17
+ */
18
+ import { join } from 'node:path';
19
+ import { AgentMessageBus, AgentOrchestrator, ArchetypeLoader, WrfcController } from '@pellux/goodvibes-sdk/platform/agents';
20
+ import { AgentManager, ContextAccountingHolder } from '@pellux/goodvibes-sdk/platform/tools';
21
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
22
+ import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
23
+ import type { RuntimeEventBus } from '@/runtime/index.ts';
24
+
25
+ export interface AgentGraph {
26
+ readonly agentMessageBus: AgentMessageBus;
27
+ readonly archetypeLoader: ArchetypeLoader;
28
+ readonly agentOrchestrator: AgentOrchestrator;
29
+ readonly agentManager: AgentManager;
30
+ readonly contextAccountingHolder: ContextAccountingHolder;
31
+ readonly wrfcController: WrfcController;
32
+ }
33
+
34
+ /** Build the agent-execution graph, fully wired in both directions. */
35
+ export function createAgentGraph(options: {
36
+ readonly runtimeBus: RuntimeEventBus;
37
+ readonly workingDirectory: string;
38
+ readonly configManager: ConfigManager;
39
+ readonly providerRegistry: ProviderRegistry;
40
+ }): AgentGraph {
41
+ const agentMessageBus = new AgentMessageBus();
42
+ agentMessageBus.setRuntimeBus(options.runtimeBus);
43
+ const archetypeLoader = new ArchetypeLoader(join(options.workingDirectory, '.goodvibes', 'agents'));
44
+ const agentOrchestrator = new AgentOrchestrator({
45
+ messageBus: agentMessageBus,
46
+ });
47
+ agentOrchestrator.setRuntimeBus(options.runtimeBus);
48
+ const agentManager = new AgentManager({
49
+ archetypeLoader,
50
+ messageBus: agentMessageBus,
51
+ executor: agentOrchestrator,
52
+ configManager: options.configManager,
53
+ // The live registry lets a bare model id in a spawn() override resolve
54
+ // through the shared resolver instead of being rejected as unqualified.
55
+ providerRegistry: options.providerRegistry,
56
+ });
57
+ const contextAccountingHolder = new ContextAccountingHolder();
58
+ // Conversation-snapshot bridge (mirrors the SDK's own createRuntimeServices).
59
+ agentOrchestrator.setConversationSink({
60
+ register: (agentId, source) => agentManager.registerConversationSource(agentId, source),
61
+ release: (agentId) => agentManager.releaseConversationSource(agentId),
62
+ });
63
+ agentManager.setRuntimeBus(options.runtimeBus);
64
+ const wrfcController = new WrfcController(options.runtimeBus, agentMessageBus, {
65
+ agentManager,
66
+ configManager: options.configManager,
67
+ projectRoot: options.workingDirectory,
68
+ });
69
+ agentManager.setWrfcController(wrfcController);
70
+ return {
71
+ agentMessageBus,
72
+ archetypeLoader,
73
+ agentOrchestrator,
74
+ agentManager,
75
+ contextAccountingHolder,
76
+ wrfcController,
77
+ };
78
+ }
@@ -483,7 +483,8 @@ export async function bootstrapRuntime(
483
483
 
484
484
  // Embedded DaemonServer/HttpListener factories (incl. the facade auto-update
485
485
  // suppression) — see embedded-service-factories.ts.
486
- const createExternalServiceFactories = createEmbeddedServiceFactories;
486
+ const createExternalServiceFactories = (token: string): ExternalServiceFactories =>
487
+ createEmbeddedServiceFactories(token, services.clusterCoordinator, services.clusterGroup.verbs);
487
488
 
488
489
  let externalServices: ExternalServicesHandle = {
489
490
  daemonServer: null, httpListener: null,
@@ -595,6 +596,10 @@ export async function bootstrapRuntime(
595
596
  createExternalServiceFactories(companionTokenRecord.token),
596
597
  );
597
598
  externalServices = await externalServicesPromise;
599
+ // Join the coordination group even with no embedded daemon: the inbox
600
+ // poller is gated on leadership and nothing else would start it when
601
+ // daemon.enabled is false. Idempotent with the DaemonServer's own call.
602
+ await services.startCluster();
598
603
  controlPlaneRecentEventsRef.value = (limit) => externalServices.listRecentControlPlaneEvents(limit);
599
604
  syncSessionSpineToHostStatus(externalServices.daemonStatus, companionTokenRecord.token);
600
605
  await renderDaemonAttachNotices(companionTokenRecord.token);