@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.
- package/CHANGELOG.md +22 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +3 -3
- package/src/cli/bundle-command.ts +4 -0
- package/src/cli/help.ts +5 -0
- package/src/cli/management-utils.ts +4 -0
- package/src/cluster/commands.ts +382 -0
- package/src/cluster/remote-daemon-target.ts +225 -0
- package/src/cluster/render.ts +268 -0
- package/src/core/conversation-line-cache.ts +11 -6
- package/src/core/conversation-render-context.ts +122 -0
- package/src/core/conversation-rendering.ts +86 -89
- package/src/daemon/cli.ts +73 -2
- package/src/daemon/handlers/inbox/cursor-store.ts +12 -1
- package/src/daemon/handlers/inbox/index.ts +63 -3
- package/src/daemon/handlers/inbox/poller.ts +66 -10
- package/src/daemon/handlers/index.ts +9 -0
- package/src/input/command-args-hint.ts +2 -0
- package/src/input/commands/cluster-runtime.ts +53 -0
- package/src/input/commands.ts +2 -0
- package/src/input/settings-modal-data.ts +6 -0
- package/src/main.ts +3 -0
- package/src/renderer/conversation-surface.ts +23 -6
- package/src/renderer/conversation-tree.ts +142 -93
- package/src/renderer/tool-call.ts +13 -12
- package/src/runtime/agent-graph-composition.ts +78 -0
- package/src/runtime/bootstrap.ts +6 -1
- package/src/runtime/cluster-composition.ts +124 -0
- package/src/runtime/cluster-group-composition.ts +284 -0
- package/src/runtime/daemon-handler-composition.ts +80 -0
- package/src/runtime/disposal-wiring.ts +83 -0
- package/src/runtime/durability-services.ts +7 -3
- package/src/runtime/embedded-service-factories.ts +19 -1
- package/src/runtime/process-lifecycle.ts +9 -1
- package/src/runtime/remote-execution-composition.ts +62 -0
- package/src/runtime/services.ts +73 -76
- package/src/verification/verification-ledger.ts +38 -0
- 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
|
+
}
|
package/src/input/commands.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
|
8
|
-
* type (event lines, tool-call rows, collapsed fragments) agrees on a
|
|
9
|
-
* column grid instead of each re-deriving it.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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
|
|
86
|
-
|
|
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
|
|
97
|
-
*
|
|
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
|
|
100
|
-
return
|
|
113
|
+
export function treeContentCol(indentCols: number): number {
|
|
114
|
+
return treeBranchCol(indentCols) + 2;
|
|
101
115
|
}
|
|
102
116
|
|
|
103
117
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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
|
-
|
|
123
|
+
export function treeTextCol(indentCols: number): number {
|
|
124
|
+
return treeContentCol(indentCols) + 1;
|
|
125
|
+
}
|
|
119
126
|
|
|
120
|
-
/**
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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
|
|
160
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
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
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
if (
|
|
240
|
-
|
|
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
|
+
}
|
package/src/runtime/bootstrap.ts
CHANGED
|
@@ -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 =
|
|
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);
|