@pellux/goodvibes-tui 1.23.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +14 -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 +71 -2
  15. package/src/daemon/handlers/inbox/index.ts +63 -3
  16. package/src/daemon/handlers/inbox/poller.ts +54 -8
  17. package/src/input/command-args-hint.ts +2 -0
  18. package/src/input/commands/cluster-runtime.ts +53 -0
  19. package/src/input/commands.ts +2 -0
  20. package/src/input/settings-modal-data.ts +6 -0
  21. package/src/main.ts +3 -0
  22. package/src/renderer/conversation-surface.ts +23 -6
  23. package/src/renderer/conversation-tree.ts +142 -93
  24. package/src/renderer/tool-call.ts +13 -12
  25. package/src/runtime/bootstrap.ts +6 -1
  26. package/src/runtime/cluster-composition.ts +124 -0
  27. package/src/runtime/cluster-group-composition.ts +284 -0
  28. package/src/runtime/daemon-handler-composition.ts +80 -0
  29. package/src/runtime/disposal-wiring.ts +66 -0
  30. package/src/runtime/durability-services.ts +7 -3
  31. package/src/runtime/embedded-service-factories.ts +19 -1
  32. package/src/runtime/process-lifecycle.ts +9 -1
  33. package/src/runtime/remote-execution-composition.ts +62 -0
  34. package/src/runtime/services.ts +48 -48
  35. package/src/verification/verification-ledger.ts +38 -0
  36. package/src/version.ts +1 -1
@@ -15,8 +15,13 @@ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
15
15
  import { renderConversationCollapsedFragment, renderConversationEventLine } from '../renderer/conversation-surface.ts';
16
16
  import { GLYPHS } from '../renderer/ui-primitives.ts';
17
17
  import type { BlockMeta } from './conversation-types.ts';
18
+ import {
19
+ collectToolCallOutcomes,
20
+ isTurnCollapsed,
21
+ type ConversationRenderContext,
22
+ } from './conversation-render-context.ts';
18
23
  import { renderCompactionContinuationMessage } from './conversation-compaction-render.ts';
19
- import { drawBranchConnector, treeIndentCols, writeTreeStatusGutter } from '../renderer/conversation-tree.ts';
24
+ import { drawTreeRails, treeIndentCols, treeTextCol } from '../renderer/conversation-tree.ts';
20
25
  import {
21
26
  MAX_NEST_DEPTH,
22
27
  buildRenderPlan,
@@ -52,76 +57,22 @@ const NAVIGABLE_KINDS: ReadonlySet<SystemMessageKind> = new Set(['system', 'wrfc
52
57
 
53
58
  type Message = ConversationMessageSnapshot;
54
59
 
55
- /**
56
- * Collect the ids of tool calls that have a matching tool-result message in the
57
- * given slice — i.e. the tools that actually ran. Used to decide whether an
58
- * assistant tool call still shows as pending (awaiting approval) or done.
59
- * (item 2c.)
60
- */
61
- export function collectCompletedToolCallIds(messages: readonly Message[]): Set<string> {
62
- const ids = new Set<string>();
63
- for (const message of messages) {
64
- if (message.role === 'tool' && message.callId) ids.add(message.callId);
65
- }
66
- return ids;
67
- }
60
+ // The render context and its pure derivations live in a type-only leaf module
61
+ // (conversation-render-context.ts) so per-row render modules can depend on the
62
+ // SHAPE of a render without depending on this drawing module. Re-exported here
63
+ // because this file remains the transcript renderer's entry point.
64
+ export {
65
+ collectCompletedToolCallIds,
66
+ collectToolCallOutcomes,
67
+ isTurnCollapsed,
68
+ type ConversationRenderContext,
69
+ type ToolCallOutcome,
70
+ } from './conversation-render-context.ts';
68
71
 
69
72
  function summarizeCallId(callId: string, maxLength = 24): string {
70
73
  return callId.length <= maxLength ? callId : `${callId.slice(0, maxLength - 1)}…`;
71
74
  }
72
75
 
73
- export interface ConversationRenderContext {
74
- readonly history: {
75
- addLine: (line: Line) => void;
76
- addLines: (lines: Line[]) => void;
77
- getLineCount: () => number;
78
- };
79
- readonly blockRegistry: BlockMeta[];
80
- readonly collapseState: Map<string, boolean>;
81
- readonly errorLineRegistry: number[];
82
- /** Maps message index → SystemMessageKind for typed system messages. */
83
- readonly messageKindRegistry: ReadonlyMap<number, SystemMessageKind>;
84
- readonly configManager: ConfigManager | null;
85
- readonly splashOptions: SplashOptions;
86
- /**
87
- * Tool-call ids that have a corresponding tool-result message (i.e. the tool
88
- * actually ran). An assistant tool call whose id is NOT in this set is still
89
- * awaiting a decision (e.g. an approval prompt) and renders with a pending
90
- * glyph instead of the completed ✓. When undefined (single-message callers
91
- * without sibling context), every tool call renders as done — the prior
92
- * behaviour. (item 2c.)
93
- */
94
- readonly completedToolCallIds?: ReadonlySet<string>;
95
- /**
96
- * Which assistant messages share one `● assistant` header, and what that
97
- * header must say (see conversation-turn-structure.ts). Keyed by absolute
98
- * index for every assistant message in a run AND every tool-result message
99
- * the run's calls produced, so a collapsed turn can hide its results too.
100
- * A message absent from this map (or an undefined map) renders standalone,
101
- * exactly as it did before turn merging.
102
- */
103
- readonly assistantTurns?: ReadonlyMap<number, AssistantTurnMembership>;
104
- /**
105
- * Live snapshot reader for a spawned agent's own conversation, used to
106
- * splice that agent's rows in beneath the call that spawned it (see
107
- * conversation-turn-structure.ts). Undefined disables nesting entirely and
108
- * the transcript renders exactly as it would without subagents.
109
- */
110
- readonly resolveAgentSnapshot?: (agentId: string) => readonly Message[] | null;
111
- }
112
-
113
- /**
114
- * Whether a turn's branches are hidden. Turns default to EXPANDED (unlike
115
- * every other collapsible block here, which defaults to collapsed): a turn
116
- * collapsed by default would hide the activity the transcript exists to show.
117
- */
118
- export function isTurnCollapsed(
119
- turn: AssistantTurnMembership | undefined,
120
- collapseState: ReadonlyMap<string, boolean>,
121
- ): boolean {
122
- return turn !== undefined && (collapseState.get(turn.turnKey) ?? false);
123
- }
124
-
125
76
  export function renderConversationUserMessage(
126
77
  context: ConversationRenderContext,
127
78
  message: Extract<Message, { role: 'user' }>,
@@ -424,12 +375,23 @@ export function renderConversationToolCallNode(
424
375
  const turn = context.assistantTurns?.get(node.absIdx);
425
376
  if (isTurnCollapsed(turn, context.collapseState)) return;
426
377
 
378
+ // The call row carries the call's OUTCOME, not merely whether it finished:
379
+ // the result row below it no longer repeats a marker, so this glyph is the
380
+ // only place a failed or cancelled tool says so in the status column.
381
+ const outcome = call.id !== undefined ? context.toolCallOutcomes?.get(call.id) : undefined;
427
382
  const ran = context.completedToolCallIds === undefined
428
383
  || (call.id !== undefined && context.completedToolCallIds.has(call.id));
384
+ const status: 'done' | 'error' | 'cancelled' | 'pending' = outcome === 'error'
385
+ ? 'error'
386
+ : outcome === 'cancelled'
387
+ ? 'cancelled'
388
+ : outcome === 'ok'
389
+ ? 'done'
390
+ : ran ? 'done' : 'pending';
429
391
  const indent = treeIndentCols(node.depth, width);
430
392
  const lines = renderToolCallBlock(
431
393
  call,
432
- ran ? 'done' : 'pending',
394
+ status,
433
395
  undefined,
434
396
  width,
435
397
  undefined,
@@ -438,15 +400,12 @@ export function renderConversationToolCallNode(
438
400
  undefined,
439
401
  { indentCols: indent, omitToolName: turn?.sharedToolLabel !== undefined },
440
402
  );
441
- const line = lines[0];
442
- if (line) drawBranchConnector(line, node.depth, node.connector, node.openAncestorDepths, width, T.toolAccent);
443
- context.history.addLines(lines);
444
403
 
445
404
  // Recursion stopped here — say so rather than silently showing a subtree as
446
405
  // if it were a leaf.
447
406
  if (node.truncated) {
448
407
  const noteIndent = treeIndentCols(node.depth + 1, width);
449
- context.history.addLine(renderConversationEventLine(width, {
408
+ lines.push(renderConversationEventLine(width, {
450
409
  marker: GLYPHS.navigation.collapsed,
451
410
  markerFg: '244',
452
411
  label: '',
@@ -460,6 +419,28 @@ export function renderConversationToolCallNode(
460
419
  dim: true,
461
420
  }], noteIndent));
462
421
  }
422
+
423
+ // Rails last, over every line the row emitted, so the vertical run down to
424
+ // the next sibling has no gap in it.
425
+ drawTreeRails(lines, node.depth, node.connector, node.openAncestorDepths, width, T.toolAccent);
426
+ context.history.addLines(lines);
427
+ }
428
+
429
+ /**
430
+ * Move an already-rendered line `cols` columns to the right on a fresh
431
+ * full-width line. Used to place a tree row's expanded body under its own
432
+ * indent without re-rendering it at a different width (which would change the
433
+ * line count the row's badge already committed to).
434
+ */
435
+ function shiftLineRight(line: Line, cols: number, width: number): Line {
436
+ if (cols <= 0) return line;
437
+ const shifted = createEmptyLine(width);
438
+ for (let i = 0; i < line.length; i++) {
439
+ const target = i + cols;
440
+ if (target >= width) break;
441
+ shifted[target] = line[i]!;
442
+ }
443
+ return shifted;
463
444
  }
464
445
 
465
446
  export function renderConversationToolMessage(
@@ -485,12 +466,25 @@ export function renderConversationToolMessage(
485
466
  // Parsed once, ahead of the collapse check, so it's available for the
486
467
  // block-registry meta merge below regardless of collapsed/expanded state.
487
468
  const diffParse = isDiff ? parseDiffForApply(message.content) : undefined;
469
+ const inTree = depth > 0 && indent > 0;
488
470
  // The EXPANDED render, computed unconditionally (even while collapsed) so
489
471
  // the header's "N lines" badge always names what Tab would actually reveal
490
472
  // — a raw JSON blob that pretty-prints to 50 lines must say 50, not 1, even
491
473
  // while it's still folded. Reused below as the actual body when expanded,
492
474
  // so this is never rendered twice.
493
- const expandedLines = renderExpandedToolResultLines(message.content, width);
475
+ //
476
+ // In the tree it is rendered at the width it will actually OCCUPY (the row's
477
+ // width less its indent) and then shifted into place, so the badge still
478
+ // counts exactly the lines expansion produces. Rendering it full-width and
479
+ // displaying it indented would desynchronise the two — the
480
+ // markdown-table-drops-columns bug class — and rendering it full-width and
481
+ // displaying it flush punches the row's rails out for the whole body.
482
+ // The body is a continuation of its row, so it starts at the row's own text
483
+ // column; the shift is that column expressed relative to the flush left
484
+ // margin the body renders with internally.
485
+ const bodyShift = inTree ? Math.max(0, treeTextCol(indent) - LAYOUT.LEFT_MARGIN) : 0;
486
+ const bodyWidth = Math.max(LAYOUT.LEFT_MARGIN + LAYOUT.RIGHT_MARGIN + 8, width - bodyShift);
487
+ const expandedLines = renderExpandedToolResultLines(message.content, bodyWidth);
494
488
  const lineCount = expandedLines.length;
495
489
 
496
490
  // Human one-line summary for tool results (write/read/exec/edit): shown as the
@@ -528,7 +522,6 @@ export function renderConversationToolMessage(
528
522
  // parent call row already shows — is exactly the boilerplate this layout
529
523
  // removes. The row leads with its size badge instead. `diff` and `cancelled`
530
524
  // keep their labels: those carry information the parent row does not.
531
- const inTree = depth > 0 && indent > 0;
532
525
  const label = isCancelled ? 'cancelled' : (blockType === 'diff' ? 'diff' : (inTree ? '' : 'tool result'));
533
526
  const nameSegments = inTree
534
527
  ? []
@@ -546,19 +539,12 @@ export function renderConversationToolMessage(
546
539
  ...nameSegments,
547
540
  { text: ` ${isCollapsed ? GLYPHS.navigation.collapsed : GLYPHS.navigation.expanded} ${lineCount} line${lineCount === 1 ? '' : 's'} `, fg: '244', dim: true },
548
541
  ], indent);
549
- if (node && inTree) {
550
- drawBranchConnector(headerLine, node.depth, node.connector, node.openAncestorDepths, width, T.toolAccent);
551
- // A settled result means its call finished: ✓ in the shared gutter, or the
552
- // blocked glyph when the user cancelled it. Distinguished by GLYPH, not by
553
- // colour alone.
554
- writeTreeStatusGutter(
555
- headerLine,
556
- isCancelled ? GLYPHS.status.blocked : TOOL_STATUS.SUCCESS_ICON,
557
- isCancelled ? warnTone : activeUiTones().chrome.good,
558
- width,
559
- );
560
- }
561
- context.history.addLine(headerLine);
542
+ // Every line this row emits, gathered before anything is committed, so the
543
+ // rails can be drawn across all of them in one pass (see drawTreeRails). The
544
+ // result row deliberately carries NO status marker of its own: the call row
545
+ // directly above it already states the outcome in the shared bullet column,
546
+ // and a second marker one row down only doubles it.
547
+ const rows: Line[] = [headerLine];
562
548
 
563
549
  if (isCollapsed) {
564
550
  const collapseSuffixReserve = 30;
@@ -584,7 +570,7 @@ export function renderConversationToolMessage(
584
570
  dim: true,
585
571
  indentCols: indent,
586
572
  });
587
- context.history.addLines(rendered);
573
+ for (const line of rendered) rows.push(line);
588
574
  } else {
589
575
  // Diff or plain result — either way this is exactly `expandedLines`,
590
576
  // already computed above (and already what the header's line count is
@@ -595,8 +581,16 @@ export function renderConversationToolMessage(
595
581
  // reduced width would desynchronise that count from what expansion
596
582
  // actually reveals — the markdown-table-drops-columns bug class. The body
597
583
  // is already visually bound to its row by the header directly above it.
598
- context.history.addLines(expandedLines);
584
+ // In the tree it was rendered at `bodyWidth` above and is shifted into the
585
+ // row's indent here, which is what leaves the rail columns to its left free
586
+ // for the rails to run through.
587
+ for (const line of expandedLines) rows.push(shiftLineRight(line, bodyShift, width));
588
+ }
589
+
590
+ if (node && inTree) {
591
+ drawTreeRails(rows, node.depth, node.connector, node.openAncestorDepths, width, T.toolAccent);
599
592
  }
593
+ context.history.addLines(rows);
600
594
 
601
595
  const renderedLineCount = context.history.getLineCount() - startLine;
602
596
  let meta: BlockMeta = {
@@ -635,7 +629,10 @@ export function appendConversationMessages(
635
629
  // unless the caller already supplied the set. (item 2c.)
636
630
  const renderContext: ConversationRenderContext = context.completedToolCallIds !== undefined
637
631
  ? context
638
- : { ...context, completedToolCallIds: collectCompletedToolCallIds(messages) };
632
+ : (() => {
633
+ const toolCallOutcomes = collectToolCallOutcomes(messages);
634
+ return { ...context, toolCallOutcomes, completedToolCallIds: new Set(toolCallOutcomes.keys()) };
635
+ })();
639
636
  const assistantTurns = renderContext.assistantTurns
640
637
  ?? computeAssistantTurns(messages, msgIndexOffset);
641
638
  const turnContext: ConversationRenderContext = renderContext.assistantTurns !== undefined
package/src/daemon/cli.ts CHANGED
@@ -14,7 +14,7 @@ import { createRuntimeStore } from '../runtime/store/index.ts';
14
14
  import { createRuntimeServices } from '../runtime/services.ts';
15
15
  import { DaemonServer, HttpListener } from '@pellux/goodvibes-sdk/platform/daemon';
16
16
  import { createHostPowerSeam } from '@pellux/goodvibes-sdk/platform/power';
17
- import { logger } from '@pellux/goodvibes-sdk/platform/utils';
17
+ import { flushActivityLogSync, logger } from '@pellux/goodvibes-sdk/platform/utils';
18
18
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
19
19
  import {
20
20
  getOrCreateCompanionToken,
@@ -35,6 +35,7 @@ import {
35
35
  } from '@pellux/goodvibes-sdk/platform/discovery';
36
36
  import { createSafeHostServeFactory } from './safe-serve.ts';
37
37
  import { isDaemonServiceSubcommand, resolveInstalledDaemonBinary, runDaemonServiceCli } from './service-commands.ts';
38
+ import { runClusterCommand } from '../cluster/commands.ts';
38
39
  import { resolveConfiguredServiceName } from '../runtime/legacy-daemon-migration.ts';
39
40
  import { runDaemonConfigMigration } from '../config/run-daemon-config-migration.ts';
40
41
  import { reconcileRedundantLegacyUnit } from '../runtime/legacy-daemon-reconcile.ts';
@@ -103,6 +104,39 @@ function readDaemonCliTokens(env: NodeJS.ProcessEnv): DaemonCliTokens {
103
104
  }
104
105
 
105
106
  async function main(): Promise<void> {
107
+ // `cluster …` is intercepted before the daemon's own flag parser runs, and
108
+ // before any runtime is composed.
109
+ //
110
+ // Before the parser, because the subcommand has its own flag vocabulary
111
+ // (--group, --key, --host, --port, --token) that the daemon parser would
112
+ // reject as unknown. Before the runtime, because this command talks to a
113
+ // daemon that is ALREADY RUNNING — composing a second runtime here would
114
+ // build a competing set of state on a machine that already has one.
115
+ //
116
+ // See remote-daemon-target.ts for the --host/--port/--token convention that
117
+ // every later remote-capable subcommand should follow.
118
+ const rawArgs = process.argv.slice(2);
119
+ if (rawArgs[0] === 'cluster') {
120
+ const ownership = resolveDaemonCliOwnership();
121
+ const clusterConfig = new ConfigManager({
122
+ workingDir: ownership.workingDirectory,
123
+ homeDir: ownership.homeDirectory,
124
+ surfaceRoot: 'tui',
125
+ });
126
+ const result = await runClusterCommand({
127
+ argv: rawArgs.slice(1),
128
+ configManager: clusterConfig,
129
+ daemonHomeDir: ownership.daemonHomeDirectory,
130
+ });
131
+ if (result.rawOutput) process.stdout.write(`\u001b${result.rawOutput}`);
132
+ for (const line of result.lines) {
133
+ // eslint-disable-next-line no-console
134
+ if (result.exitCode === 0) console.log(line);
135
+ else console.error(line);
136
+ }
137
+ process.exit(result.exitCode);
138
+ }
139
+
106
140
  // Parse CLI flags first so --daemon-home and --working-dir env vars are set
107
141
  // before resolveDaemonCliOwnership() reads them.
108
142
  const cli = parseGoodVibesCli(process.argv.slice(2), 'goodvibes-daemon');
@@ -327,6 +361,15 @@ async function main(): Promise<void> {
327
361
  // undefined for a non-binary install, so a dev run stays host-managed and
328
362
  // never swaps the interpreter. See lifecycle.ts.
329
363
  updateArtifact: resolveDaemonUpdateArtifact({ version: VERSION }),
364
+ // The SAME coordinator this repository's inbox poller registered with (see
365
+ // runtime/cluster-composition.ts). The facade must reuse it rather than
366
+ // compose its own: two coordinators in one process are two nodes in the
367
+ // election, and whichever lost would silence consumers the other owns.
368
+ clusterCoordinator: runtimeServices.clusterCoordinator,
369
+ // The `cluster` verbs, served on /api/cluster/*. The CLI subcommands, the
370
+ // TUI's /cluster command and any web UI all call these, so a command run
371
+ // against a REMOTE daemon behaves exactly like one run on that machine.
372
+ clusterGroupVerbs: runtimeServices.clusterGroup.verbs,
330
373
  });
331
374
  const listener = new HttpListener({
332
375
  hookDispatcher: runtimeServices.hookDispatcher,
@@ -357,6 +400,11 @@ async function main(): Promise<void> {
357
400
  daemon.enable({ daemon: true }, effectiveDaemonToken);
358
401
  listener.enable({ httpListener: true }, effectiveHttpToken);
359
402
 
403
+ // Before the daemon: the group layer owns the socket the leader election
404
+ // coordinates over, and it announces this machine's return to the group so a
405
+ // box that has been off for months re-keys itself with no operator action.
406
+ await runtimeServices.startCluster();
407
+
360
408
  await Promise.all([
361
409
  daemon.start(),
362
410
  config.get('danger.httpListener') ? listener.start() : Promise.resolve(),
@@ -375,12 +423,29 @@ async function main(): Promise<void> {
375
423
  const timeout = new Promise<'timeout'>((resolve) =>
376
424
  setTimeout(() => resolve('timeout'), SHUTDOWN_DEADLINE_MS)
377
425
  );
378
- const stop = Promise.allSettled([listener.stop(), daemon.stop()]).then(() => 'done' as const);
426
+ // daemon.stop() drives the ordered inbound teardown: it stops every gated
427
+ // consumer and only then broadcasts the resignation, so another node on
428
+ // this network takes over in about a second instead of waiting out the
429
+ // crash timeout. Handler surfaces (inbox store, catalog handlers) are
430
+ // released afterwards — before this, a SIGTERM left the inbox cursor store
431
+ // open and relied on process exit to clear the poll timers.
432
+ const stop = Promise.allSettled([listener.stop(), daemon.stop()])
433
+ .then(() => runtimeServices.daemonHandlers.unregister())
434
+ // This process built the runtime graph and handed it to DaemonServer, so
435
+ // by the SDK's ownership rule the facade leaves it alone — nothing else
436
+ // stops these pollers. Without this the config watch, fleet tick, memory
437
+ // governor, watcher registry and six more kept ticking until process exit.
438
+ .then(() => { runtimeServices.dispose(); })
439
+ .then(() => 'done' as const);
379
440
  const result = await Promise.race([stop, timeout]);
380
441
  if (result === 'timeout') {
381
442
  logger.warn('shutdown deadline exceeded — forcing exit');
443
+ // A forced exit is exactly the case where the log matters most and is
444
+ // least likely to have drained on its own.
445
+ flushActivityLogSync();
382
446
  process.exit(1);
383
447
  }
448
+ flushActivityLogSync();
384
449
  process.exit(0);
385
450
  };
386
451
 
@@ -474,5 +539,9 @@ void main().catch(async (error) => {
474
539
  logger.error('goodvibes daemon host failed', {
475
540
  error: summarizeError(error),
476
541
  });
542
+ // The daemon never came up. Without this the reason is buffered in a process
543
+ // that is about to stop existing, and the failure reads as a daemon that
544
+ // never logged anything at all.
545
+ flushActivityLogSync();
477
546
  process.exit(1);
478
547
  });
@@ -75,6 +75,14 @@ export interface InboxListOutput {
75
75
  cursor?: string;
76
76
  }
77
77
 
78
+ /** Start/stop control over the poll loops, handed to a leadership gate. */
79
+ export interface InboxPollingControl {
80
+ /** Seed one poll and arm the loops. Resolves once polling has begun. */
81
+ start(): Promise<void>;
82
+ /** Disarm the loops. Resolves once no further poll can run. */
83
+ stop(): Promise<void>;
84
+ }
85
+
78
86
  export interface RegisterInboxOptions {
79
87
  /** Override the cursor-store filename (tests). */
80
88
  storeFileName?: string;
@@ -82,6 +90,26 @@ export interface RegisterInboxOptions {
82
90
  skipInitialPoll?: boolean;
83
91
  /** Register the built-in slack/discord/email adapters (default true). */
84
92
  registerBuiltins?: boolean;
93
+ /**
94
+ * Hand polling to a leadership gate instead of starting it here.
95
+ *
96
+ * When supplied, register() prepares the store and the handler but does NOT
97
+ * poll: the callback receives start/stop control and something else decides
98
+ * when this node is the one that should be fetching. When absent the loops
99
+ * start immediately, which is the behaviour every existing caller and test
100
+ * relies on.
101
+ *
102
+ * The READ path is never gated. `channels.inbox.list` serves the persisted
103
+ * feed on every node — a node that is not fetching still answers questions
104
+ * about what has already arrived.
105
+ *
106
+ * Called once PER PROVIDER, with that provider's id and a control that
107
+ * starts and stops only its loop. Each inbox account is its own surface in
108
+ * the LAN election, so the machine reading the work Slack account need not
109
+ * be the machine reading the mailbox — and handing one account over must not
110
+ * take the others down with it.
111
+ */
112
+ gatePolling?: (providerId: string, control: InboxPollingControl) => void;
85
113
  }
86
114
 
87
115
  function registerBuiltinAdapters(): void {
@@ -176,11 +204,14 @@ export function registerInboxMethods(
176
204
  });
177
205
  const poller = new InboundPoller({ adapters, store, logger: ctx.logger });
178
206
 
179
- // Async bootstrap: init store, seed one poll, start loops. Failures are
180
- // logged but never thrown out of register() — the handler still serves the
181
- // (possibly empty) persisted feed.
207
+ const gated = options.gatePolling !== undefined;
208
+
209
+ // Async bootstrap: init store, and (ungated) seed one poll and start loops.
210
+ // Failures are logged but never thrown out of register() — the handler still
211
+ // serves the (possibly empty) persisted feed.
182
212
  const ready: Promise<void> = (async () => {
183
213
  await store.init();
214
+ if (gated) return;
184
215
  if (!options.skipInitialPoll) {
185
216
  await poller.pollOnce();
186
217
  }
@@ -191,6 +222,35 @@ export function registerInboxMethods(
191
222
  });
192
223
  });
193
224
 
225
+ const gate = options.gatePolling;
226
+ if (gate) {
227
+ // One gate per provider, not one for the poller. Leadership is decided per
228
+ // inbox account, so each account's loop has to be startable and stoppable
229
+ // on its own — otherwise handing one account to another machine would stop
230
+ // fetching for every account this node reads.
231
+ for (const providerId of poller.providerIds()) {
232
+ gate(providerId, {
233
+ start: async () => {
234
+ // The store must be ready before the first fetch, or the seed poll
235
+ // would write cursors into an uninitialised store.
236
+ await ready;
237
+ if (!options.skipInitialPoll) {
238
+ await poller.pollProviderOnce(providerId);
239
+ }
240
+ poller.startProvider(providerId);
241
+ },
242
+ // Synchronous underneath: it clears that provider's interval, so once
243
+ // it returns no further poll of that account can be scheduled.
244
+ // Declared async because the gate contract promises "resolves when
245
+ // consumption has ceased", and a future adapter with an in-flight
246
+ // request would need to await it.
247
+ stop: async () => {
248
+ poller.stopProvider(providerId);
249
+ },
250
+ });
251
+ }
252
+ }
253
+
194
254
  const unregisterMethod = registerCatalogHandler<InboxListInput, InboxListOutput>(
195
255
  ctx.catalog,
196
256
  INBOX_LIST_METHOD_ID,
@@ -68,14 +68,53 @@ export class InboundPoller {
68
68
  start(): void {
69
69
  if (this.started) return;
70
70
  this.started = true;
71
- for (const [id, adapter] of this.adapters) {
72
- const handle = this.setIntervalImpl(() => {
73
- void this.pollProvider(id, adapter);
74
- }, adapter.pollIntervalMs);
75
- // Do not keep the event loop alive solely for polling (Bun/Node unref).
76
- (handle as unknown as { unref?: () => void }).unref?.();
77
- this.timers.set(id, handle);
78
- }
71
+ for (const id of this.adapters.keys()) this.startProvider(id);
72
+ }
73
+
74
+ /**
75
+ * Begin ONE provider's interval loop. Idempotent.
76
+ *
77
+ * Each inbox account is its own surface in the LAN election, so the machine
78
+ * that reads the work Slack account may not be the machine that reads the
79
+ * mailbox. A blanket start/stop cannot express that: it would take every
80
+ * account down to hand one of them over. Everything below is therefore
81
+ * addressable per provider, and the blanket calls are fan-outs over it.
82
+ */
83
+ startProvider(id: string): void {
84
+ if (this.timers.has(id)) return;
85
+ const adapter = this.adapters.get(id);
86
+ if (!adapter) return;
87
+ const handle = this.setIntervalImpl(() => {
88
+ void this.pollProvider(id, adapter);
89
+ }, adapter.pollIntervalMs);
90
+ // Do not keep the event loop alive solely for polling (Bun/Node unref).
91
+ (handle as unknown as { unref?: () => void }).unref?.();
92
+ this.timers.set(id, handle);
93
+ }
94
+
95
+ /**
96
+ * Stop ONE provider's interval loop. Idempotent.
97
+ *
98
+ * Synchronous underneath — clearing the interval means no further tick can
99
+ * be scheduled — which is what lets the RESIGN that follows a handoff be an
100
+ * honest claim that this node has stopped reading that account.
101
+ */
102
+ stopProvider(id: string): void {
103
+ const handle = this.timers.get(id);
104
+ if (handle === undefined) return;
105
+ this.clearIntervalImpl(handle);
106
+ this.timers.delete(id);
107
+ if (this.timers.size === 0) this.started = false;
108
+ }
109
+
110
+ /** True when this node is polling the given provider right now. */
111
+ isProviderRunning(id: string): boolean {
112
+ return this.timers.has(id);
113
+ }
114
+
115
+ /** Every provider this node has an adapter for. */
116
+ providerIds(): string[] {
117
+ return [...this.adapters.keys()];
79
118
  }
80
119
 
81
120
  /** Run a single poll across all providers now (used on register + tests). */
@@ -85,6 +124,13 @@ export class InboundPoller {
85
124
  );
86
125
  }
87
126
 
127
+ /** Run a single poll for ONE provider now. Never throws. */
128
+ async pollProviderOnce(id: string): Promise<void> {
129
+ const adapter = this.adapters.get(id);
130
+ if (!adapter) return;
131
+ await this.pollProvider(id, adapter);
132
+ }
133
+
88
134
  /** Poll a single provider, dedup + persist, update status. Never throws. */
89
135
  async pollProvider(id: string, adapter: InboundProviderAdapter): Promise<void> {
90
136
  if (this.inFlight.has(id)) return; // skip overlapping ticks
@@ -1,5 +1,6 @@
1
1
  import type { CommandRegistry } from './command-registry.ts';
2
2
  import { SESSION_SUBCOMMAND_ARG_HINTS } from './commands/session.ts';
3
+ import { CLUSTER_SUBCOMMAND_ARG_HINTS } from './commands/cluster-runtime.ts';
3
4
 
4
5
  /**
5
6
  * Per-command subcommand → argument-hint maps. `session`'s entries come
@@ -12,6 +13,7 @@ import { SESSION_SUBCOMMAND_ARG_HINTS } from './commands/session.ts';
12
13
  */
13
14
  const SUBCOMMAND_HINTS: Record<string, Record<string, string>> = {
14
15
  session: SESSION_SUBCOMMAND_ARG_HINTS,
16
+ cluster: CLUSTER_SUBCOMMAND_ARG_HINTS,
15
17
  template: { save: '<name>', use: '<name> [args]', edit: '<name>', delete: '<name>' },
16
18
  secrets: { set: '<KEY> <value>', get: '<KEY>', delete: '<KEY>' },
17
19
  permissions: { tool: '<name> allow|prompt|deny' },
@@ -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 },