@pellux/goodvibes-tui 1.17.0 → 1.18.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 (84) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +4596 -1732
  4. package/package.json +7 -7
  5. package/src/cli/management-commands.ts +82 -15
  6. package/src/cli/management.ts +0 -2
  7. package/src/core/consolidation-receipt.ts +41 -0
  8. package/src/core/conversation-rendering.ts +14 -5
  9. package/src/core/memory-provenance.ts +67 -0
  10. package/src/core/pairing-devices.ts +49 -0
  11. package/src/core/pairing-handoff.ts +101 -0
  12. package/src/core/pairing-offers.ts +83 -0
  13. package/src/core/pairing-origin.ts +78 -0
  14. package/src/core/pairing-tailscale-gateway.ts +56 -0
  15. package/src/core/power-chip-source.ts +91 -0
  16. package/src/core/power-status.ts +95 -0
  17. package/src/core/stable-host.ts +171 -0
  18. package/src/core/stream-event-wiring.ts +21 -0
  19. package/src/core/turn-budget-outcome.ts +60 -0
  20. package/src/core/turn-cancellation.ts +39 -0
  21. package/src/core/turn-event-wiring.ts +6 -0
  22. package/src/daemon/cli.ts +47 -56
  23. package/src/input/command-registry.ts +21 -0
  24. package/src/input/commands/acp-runtime.ts +72 -0
  25. package/src/input/commands/devices-runtime.ts +109 -0
  26. package/src/input/commands/git-runtime.ts +28 -4
  27. package/src/input/commands/graph-runtime.ts +45 -0
  28. package/src/input/commands/operator-rpc.ts +32 -11
  29. package/src/input/commands/power-runtime.ts +56 -0
  30. package/src/input/commands/qrcode-runtime.ts +14 -19
  31. package/src/input/commands/queue-runtime.ts +70 -0
  32. package/src/input/commands/workstream-attempts.ts +6 -1
  33. package/src/input/commands/workstream-runtime.ts +44 -25
  34. package/src/input/commands/worktree-runtime.ts +35 -8
  35. package/src/input/commands.ts +10 -0
  36. package/src/input/config-modal-types.ts +11 -0
  37. package/src/input/config-modal.ts +65 -2
  38. package/src/input/handler-shortcuts.ts +23 -0
  39. package/src/input/keybindings.ts +19 -1
  40. package/src/input/settings-modal-data.ts +31 -37
  41. package/src/input/settings-modal-types.ts +5 -2
  42. package/src/main.ts +42 -42
  43. package/src/panels/builtin/operations.ts +54 -1
  44. package/src/panels/builtin/shared.ts +12 -0
  45. package/src/panels/builtin-modals.ts +49 -32
  46. package/src/panels/diff-panel.ts +34 -1
  47. package/src/panels/fleet-acts.ts +446 -0
  48. package/src/panels/fleet-gateway.ts +117 -0
  49. package/src/panels/fleet-observed-render.ts +128 -0
  50. package/src/panels/fleet-panel-format.ts +120 -4
  51. package/src/panels/fleet-panel-worktree-detail.ts +14 -0
  52. package/src/panels/fleet-panel.ts +60 -44
  53. package/src/panels/fleet-read-model.ts +34 -3
  54. package/src/panels/fleet-spawn.ts +266 -0
  55. package/src/panels/fleet-stop.ts +38 -2
  56. package/src/panels/fleet-tabs.ts +2 -2
  57. package/src/panels/memory-consolidation-gateway.ts +94 -0
  58. package/src/panels/modals/devices-modal.ts +161 -0
  59. package/src/panels/modals/memory-modal.ts +149 -5
  60. package/src/panels/modals/pairing-modal.ts +198 -68
  61. package/src/panels/modals/security-modal.ts +6 -0
  62. package/src/panels/workstream-graph-render.ts +123 -0
  63. package/src/renderer/composer-fragments.ts +69 -0
  64. package/src/renderer/settings-modal-helpers.ts +9 -0
  65. package/src/renderer/settings-modal.ts +4 -2
  66. package/src/renderer/shell-surface.ts +3 -0
  67. package/src/renderer/tool-call.ts +12 -6
  68. package/src/renderer/ui-factory.ts +32 -0
  69. package/src/runtime/bootstrap-command-context.ts +3 -1
  70. package/src/runtime/bootstrap-command-parts.ts +3 -1
  71. package/src/runtime/bootstrap-core.ts +6 -6
  72. package/src/runtime/bootstrap-shell.ts +7 -0
  73. package/src/runtime/durability-services.ts +36 -1
  74. package/src/runtime/fleet-services.ts +41 -0
  75. package/src/runtime/idle-power-services.ts +85 -0
  76. package/src/runtime/index.ts +2 -0
  77. package/src/runtime/interaction-seams.ts +109 -0
  78. package/src/runtime/power-keepawake-remote.ts +75 -0
  79. package/src/runtime/process-lifecycle.ts +3 -1
  80. package/src/runtime/services.ts +38 -37
  81. package/src/runtime/ui-services.ts +3 -0
  82. package/src/shell/blocking-input.ts +21 -141
  83. package/src/shell/recovery-input-helpers.ts +53 -166
  84. package/src/version.ts +1 -1
@@ -18,6 +18,7 @@ export type SettingsCategory =
18
18
  | 'tools'
19
19
  | 'helper'
20
20
  | 'tts'
21
+ | 'voice'
21
22
  | 'service'
22
23
  | 'daemon'
23
24
  | 'controlPlane'
@@ -46,6 +47,8 @@ export type SettingsCategory =
46
47
  | 'notifications'
47
48
  | 'release'
48
49
  | 'update'
50
+ | 'power'
51
+ | 'memory'
49
52
  | 'danger';
50
53
 
51
54
  export type SettingsFocusPane = 'categories' | 'settings';
@@ -55,11 +58,11 @@ export const SETTINGS_CATEGORY_GROUPS: ReadonlyArray<{
55
58
  readonly categories: readonly SettingsCategory[];
56
59
  }> = [
57
60
  { label: 'Interface', categories: ['display', 'ui', 'behavior', 'notifications', 'permissions', 'policy', 'security'] },
58
- { label: 'AI Routing', categories: ['provider', 'pricing', 'subscriptions', 'helper', 'tools', 'tts'] },
61
+ { label: 'AI Routing', categories: ['provider', 'pricing', 'subscriptions', 'helper', 'tools', 'tts', 'voice'] },
59
62
  { label: 'Service & Network', categories: ['service', 'daemon', 'network', 'controlPlane', 'httpListener', 'web', 'relay'] },
60
63
  { label: 'Surfaces & Cloud', categories: ['surfaces', 'integrations', 'mcp', 'cloudflare'] },
61
64
  { label: 'Automation', categories: ['batch', 'automation', 'checkin', 'watchers', 'orchestration', 'planner', 'wrfc'] },
62
- { label: 'Runtime & Data', categories: ['storage', 'atRest', 'sandbox', 'fetch', 'agents', 'runtime', 'cache', 'telemetry', 'diagnostics', 'learning'] },
65
+ { label: 'Runtime & Data', categories: ['storage', 'atRest', 'sandbox', 'fetch', 'agents', 'runtime', 'power', 'memory', 'cache', 'telemetry', 'diagnostics', 'learning'] },
63
66
  { label: 'Advanced', categories: ['release', 'update', 'danger'] },
64
67
  ];
65
68
 
package/src/main.ts CHANGED
@@ -39,10 +39,10 @@ import { createSessionContinuityHintsBuilder } from './runtime/session-continuit
39
39
  import { resolveWebSurfaceUrl } from '@pellux/goodvibes-sdk/platform/runtime/feature-announcements';
40
40
  import { readLastSessionPointer, writeRecoveryFile } from '@/runtime/index.ts';
41
41
  import { handleBlockingShellInput, type PendingPermissionState } from './shell/blocking-input.ts';
42
- import { createPersistRecoverySnapshot, createRecoveryFileOps, createReopenRecoveryPanels, handleErrorAffordanceKey, resolveStartupRecoveryInfo } from './shell/recovery-input-helpers.ts';
42
+ import { autoRestoreRecoverySession, createPersistRecoverySnapshot, createReopenRecoveryPanels, handleErrorAffordanceKey } from './shell/recovery-input-helpers.ts';
43
43
  import { wireShellUiOpeners } from './shell/ui-openers.ts';
44
44
  import { deriveComposerState } from './core/composer-state.ts';
45
- import { buildPersistedSessionContext, formatReturnContextForDisplay, getReturnContextMode, maybeAssistReturnContextSummary } from '@/runtime/index.ts';
45
+ import { buildPersistedSessionContext, getReturnContextMode, maybeAssistReturnContextSummary } from '@/runtime/index.ts';
46
46
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
47
47
  import { prepareShellCliRuntime } from './cli/entrypoint.ts';
48
48
  import { applyInitialTuiCliState } from './cli/tui-startup.ts';
@@ -57,7 +57,7 @@ import { buildCommandArgsHint } from './input/command-args-hint.ts';
57
57
  import { summarizeRunningAgents } from './renderer/process-summary.ts';
58
58
  import { footerFleetCost } from './panels/fleet-read-model.ts';
59
59
  import { formatUserFacingErrorLine } from './core/format-user-error.ts';
60
- import { wireStreamEventMetrics, type StreamMetrics, type WireStreamEventMetricsResult } from './core/stream-event-wiring.ts';
60
+ import { wireStreamEventMetrics, createStreamMetrics, type StreamMetrics, type WireStreamEventMetricsResult } from './core/stream-event-wiring.ts';
61
61
  import { wireTurnEventHandlers } from './core/turn-event-wiring.ts';
62
62
  import { buildContextStatusHint } from './renderer/context-status-hint.ts';
63
63
  import { isEffectiveDangerMode } from './config/index.ts';
@@ -67,6 +67,9 @@ import { createSessionAutoTitler } from './core/session-auto-titler.ts';
67
67
  import { makeComposerEditorOpener } from './input/composer-editor.ts';
68
68
  import { evaluateSessionMaintenance } from '@/runtime/index.ts';
69
69
  import { createCancelGeneration } from './core/turn-cancellation.ts';
70
+ import { wireInteractionSeams, createMemoryProvenanceUi } from './runtime/interaction-seams.ts';
71
+ import { createPowerChipSource } from './core/power-chip-source.ts';
72
+ import { fetchDaemonPowerState, installKeepAwakeRemoteForward } from './runtime/power-keepawake-remote.ts';
70
73
  import { wrapRequestPermissionWithAlert } from './core/approval-alert.ts';
71
74
  import { createTerminalNotifier } from './core/terminal-notifier.ts';
72
75
  import { setPanelFrameRequester } from './panels/base-panel.ts';
@@ -160,17 +163,7 @@ async function main() {
160
163
  // Cached from the overlay-aware clamp the renderer computes (conversation-layout) each frame so scroll() clamps against exactly what is displayed, not a footer estimate.
161
164
  let lastMaxScroll: number | null = null;
162
165
  // Stream and tool-timer state; mutated by wireStreamEventMetrics handlers, read during render.
163
- const streamMetrics: StreamMetrics = {
164
- startTime: 0,
165
- deltaCount: 0,
166
- tokenSpeed: 0,
167
- ttftMs: undefined,
168
- ttftRecorded: false,
169
- activeToolStartedAtMs: undefined,
170
- activeToolName: undefined,
171
- lastDeltaAtMs: undefined, stallEpisode: 0,
172
- reconnectAttempt: undefined, reconnectMaxAttempts: undefined,
173
- };
166
+ const streamMetrics: StreamMetrics = createStreamMetrics();
174
167
 
175
168
  const getPromptContentWidth = () => computePromptContentWidth(stdout.columns);
176
169
 
@@ -203,9 +196,13 @@ async function main() {
203
196
  const unsubs: Array<() => void> = [];
204
197
  let recoveryInterval: ReturnType<typeof setInterval> | null = null;
205
198
  let stopSpokenOutputForExit: (() => Promise<void>) | null = null;
206
- let recoveryPending = false;
207
- // Which file the current recovery prompt should load/delete from (see recovery-input-helpers.ts).
208
- let recoverySource: 'live' | 'preserved' = 'live';
199
+ // The optional "used N memories" provenance chip (default OFF) — see interaction-seams.ts.
200
+ const memoryProvenanceUi = createMemoryProvenanceUi({ render: () => render(), memorySpine: ctx.services.memorySpine });
201
+ // Topology-aware keep-awake: the chip renders the DAEMON's state in adopted-external mode (power-chip-source.ts) and every toggle is forwarded to that daemon (power-keepawake-remote.ts) so keep-awake survives the TUI closing in BOTH topologies.
202
+ const isExternalDaemon = () => uiServices.platform.externalServices?.inspect()?.daemonStatus?.mode === 'external';
203
+ const powerChipSource = createPowerChipSource({ powerManager: ctx.services.powerManager, render: () => render(), isExternalDaemon,
204
+ onPowerEvent: (cb) => uiServices.events.ops.on('OPS_POWER_STATE_CHANGED', cb as never), fetchDaemonPowerState: () => fetchDaemonPowerState({ configManager, homeDirectory, isExternalDaemon }) });
205
+ unsubs.push(powerChipSource.stop, installKeepAwakeRemoteForward({ configManager, homeDirectory, isExternalDaemon }));
209
206
 
210
207
  const lifecycle = installProcessLifecycle({
211
208
  stdin,
@@ -329,6 +326,10 @@ async function main() {
329
326
  // Late-patched: bootstrap.ts populates uiServices.platform.externalServices AFTER commandContext is built.
330
327
  commandContext.platform.externalServices = uiServices.platform.externalServices;
331
328
  commandContext.cancelGeneration = cancelGeneration;
329
+ wireInteractionSeams(commandContext, {
330
+ orchestrator, powerManager: ctx.services.powerManager, readPowerSurface: () => powerChipSource.get(), render: () => render(), notify: (m) => systemMessageRouter.high(m),
331
+ getActiveToolCallId: () => streamMetrics.activeToolCallId, toggleMemoryProvenance: () => memoryProvenanceUi.toggle(),
332
+ });
332
333
  commandContext.isGenerating = () => orchestrator.isThinking;
333
334
  commandContext.jumpToBookmark = jumpToBookmark; commandContext.scrollToLine = scrollToLine;
334
335
  commandContext.clearScreen = () => {
@@ -373,7 +374,7 @@ async function main() {
373
374
  surfaceRegistry: ctx.services.surfaceRegistry,
374
375
  subscriptionManager: ctx.services.subscriptionManager,
375
376
  secretsManager: ctx.services.secretsManager,
376
- tokenAuditor: ctx.services.tokenAuditor,
377
+ pairingTokens: ctx.services.pairingTokens, tokenAuditor: ctx.services.tokenAuditor,
377
378
  replayEngine: ctx.services.replayEngine,
378
379
  webhookNotifier: ctx.services.webhookNotifier,
379
380
  policyRuntimeState: ctx.services.policyRuntimeState,
@@ -496,6 +497,8 @@ async function main() {
496
497
  // Cross-surface spine posture segment (adopted-daemon mode only).
497
498
  sessionSpineStatus: (() => { const s = uiServices.platform.externalServices?.inspect(); return s?.sessionSpineActive && s.sessionSpineStatus && s.sessionSpineStatus !== 'unknown' ? s.sessionSpineStatus : undefined; })(), runningAgentCount, runningProcessCount,
498
499
  webSurfaceUrl: configManager.get('web.enabled') ? resolveWebSurfaceUrl(configManager) : undefined,
500
+ // Always-visible "sleep disabled" chip — topology-aware: the DAEMON's state in adopted-external mode, the in-process manager otherwise (power-chip-source.ts).
501
+ powerKeepAwake: powerChipSource.get().keepAwake,
499
502
  // Composer must not read as focused while the panel/process indicator owns keyboard focus.
500
503
  promptFocused: !input.panelFocused && !input.indicatorFocused,
501
504
  indicatorFocused: input.indicatorFocused,
@@ -586,7 +589,7 @@ async function main() {
586
589
  viewport.push(...thinking);
587
590
  // Live tool timer: render the currently executing tool row with ticking elapsed.
588
591
  if (streamMetrics.activeToolName !== undefined && streamMetrics.activeToolStartedAtMs !== undefined) {
589
- const liveToolCall = { id: 'live', name: streamMetrics.activeToolName, arguments: {} };
592
+ const liveToolCall = { id: streamMetrics.activeToolCallId ?? 'live', name: streamMetrics.activeToolName, arguments: {} };
590
593
  viewport.push(...renderToolCallBlock(liveToolCall, 'executing', undefined, conversationWidth, undefined, undefined, undefined, streamMetrics.activeToolStartedAtMs));
591
594
  }
592
595
  }
@@ -595,9 +598,8 @@ async function main() {
595
598
  viewport.push(...PermissionPromptUI.createPromptLines(conversationWidth, pendingPermission, pendingPermission.hunkState, pendingPermission.detailsExpanded, pendingPermission.requestedBy, PermissionPromptUI.promptViewState(pendingPermission, conversationWidth, approvalBroker)));
596
599
  }
597
600
 
598
- orchestrator.messageQueue.forEach(msg => {
599
- viewport.push(...UIFactory.createQueuedMessageFragment(conversationWidth, msg.text));
600
- });
601
+ viewport.push(...UIFactory.createQueuedMessageList(conversationWidth, orchestrator.listQueuedMessages()));
602
+ viewport.push(...memoryProvenanceUi.renderChip(conversationWidth, configManager));
601
603
 
602
604
  viewport = applyConversationOverlays(viewport, {
603
605
  input,
@@ -686,6 +688,8 @@ async function main() {
686
688
  });
687
689
  unsubs.push(...turnUnsubs);
688
690
 
691
+ unsubs.push(uiServices.events.turns.on('TURN_COMPLETED', (evt) => memoryProvenanceUi.onTurnCompleted(evt)));
692
+
689
693
  // Stable turn context for failover retry — set in submitInput, read by retryTurn.
690
694
  let retryCtx: { count: number; text: string; content?: ContentPart[]; opts?: Parameters<typeof orchestrator.handleUserInput>[2] } | null = null;
691
695
  // One-key retry affordance, active right after a user-visible TURN_ERROR: 'r' re-submits on the
@@ -735,15 +739,10 @@ async function main() {
735
739
  stdin.on('data', (raw: string) => {
736
740
  const data = themeProbe.filterInput(raw); if (data.length === 0) return;
737
741
  const blocking = handleBlockingShellInput({
738
- data, pendingPermission, recoveryPending, conversation, systemMessageRouter, render,
742
+ data, pendingPermission, render,
739
743
  abortTurn: () => orchestrator.abort(),
740
- ...createRecoveryFileOps(() => recoverySource, { homeDirectory }),
741
- homeDirectory, sessionId: runtime.sessionId,
742
- persistSnapshot: createPersistRecoverySnapshot({ sessionManager: ctx.services.sessionManager, runtime, conversation }),
743
- reopenPanels: createReopenRecoveryPanels({ panelManager, render }),
744
744
  });
745
745
  pendingPermission = blocking.pendingPermission;
746
- recoveryPending = blocking.recoveryPending;
747
746
  if (blocking.handled) {
748
747
  return;
749
748
  }
@@ -765,26 +764,27 @@ async function main() {
765
764
  process.on('unhandledRejection', unhandledRejectionHandler);
766
765
  stdout.on('resize', resizeHandler);
767
766
 
767
+ // --- Silent crash-recovery restore (no prompt/banner) ---
768
+ // Restore the newest crash snapshot in place, replay any post-snapshot
769
+ // journal turns, reopen its panels, and emit a single one-line receipt. The
770
+ // SDK scopes both the load and the follow-up delete to that snapshot's own
771
+ // session id, so a concurrent session's snapshot is never touched. Runs
772
+ // before the initial render so the restored content shows immediately.
773
+ autoRestoreRecoverySession({
774
+ workingDirectory: workingDir,
775
+ homeDirectory,
776
+ conversation,
777
+ persistSnapshot: createPersistRecoverySnapshot({ sessionManager: ctx.services.sessionManager, runtime, conversation }),
778
+ reopenPanels: createReopenRecoveryPanels({ panelManager, render }),
779
+ systemMessageRouter,
780
+ });
781
+
768
782
  // Initial render
769
783
  conversation.rebuildHistory();
770
784
  render();
771
785
 
772
- // --- Crash recovery check (also checks the preserve-on-dismiss sibling; see recovery-input-helpers.ts) ---
773
- const recoveryInfo = resolveStartupRecoveryInfo({ workingDirectory: workingDir, homeDirectory });
774
- if (recoveryInfo) {
775
- recoverySource = recoveryInfo.source;
776
- systemMessageRouter.high(`[Recovery] Found unsaved session from ${new Date(recoveryInfo.timestamp).toLocaleString()}. Title: "${recoveryInfo.title}". Press Ctrl+R to restore, Esc to discard, or start typing to ignore it.`);
777
- for (const line of formatReturnContextForDisplay(recoveryInfo.returnContext)) {
778
- systemMessageRouter.low(`[Recovery] ${line}`);
779
- }
780
- render();
781
- recoveryPending = true;
782
- }
783
-
784
786
  // --- Auto-save to recovery file every 60s ---
785
787
  recoveryInterval = setInterval(() => {
786
- // Skip while an earlier recovery prompt is unresolved (avoids racing preserve-on-dismiss).
787
- if (recoveryPending) return;
788
788
  const snapshot = conversation.toJSON() as { messages: Array<import('./core/conversation.ts').ConversationMessageSnapshot> };
789
789
  const persisted = buildPersistedSessionContext(snapshot.messages, conversation.getTitleSource(), buildSessionContinuityHints());
790
790
  writeRecoveryFile(
@@ -1,6 +1,10 @@
1
1
  import type { PanelManager } from '../panel-manager.ts';
2
2
  import { FleetPanel } from '../fleet-panel.ts';
3
3
  import { createFleetReadModel } from '../fleet-read-model.ts';
4
+ import { FleetActs, type FleetDiffSurface } from '../fleet-acts.ts';
5
+ import { createFleetGateway } from '../fleet-gateway.ts';
6
+ import { FleetSpawn, createAcpSpawnGateway } from '../fleet-spawn.ts';
7
+ import { DiffPanel } from '../diff-panel.ts';
4
8
  import { LocalAuthPanel } from '../local-auth-panel.ts';
5
9
  import { NotificationsPanel } from '../notifications-panel.ts';
6
10
  import type { ResolvedBuiltinPanelDeps } from './shared.ts';
@@ -43,6 +47,55 @@ export function registerOperationsPanels(manager: PanelManager, deps: ResolvedBu
43
47
  // to the honest COMMUNICATION_CONSUMED steer-ack signal (fleet-read-model.ts).
44
48
  const fleetReadModel = createFleetReadModel(ui.runtime.processRegistry, ui.runtime.runtimeBus);
45
49
 
50
+ // The Fleet panel's waiting-on-human acts (pick / conflict / discard) drive
51
+ // gateway verbs over the same operator invoke path the command layer uses, and
52
+ // reuse the shared DiffPanel (candidate diffs + the pick confirm overlay) and
53
+ // the CI fix-session jump affordance. Built here so the panel stays thin.
54
+ const fleetNotify = deps.fleetActsNotify ?? (() => {});
55
+ const armFixSessionAttach = deps.armFixSessionAttach ?? (() => {});
56
+ const diffSurface: FleetDiffSurface = {
57
+ show: (_title, unifiedDiff) => {
58
+ let panel = manager.getAllOpen().find((p) => p.id === 'diff');
59
+ if (!panel) { try { panel = manager.open('diff'); } catch { return; } }
60
+ manager.activateById('diff');
61
+ if (!manager.isVisible()) manager.show();
62
+ manager.focusPanels();
63
+ (panel as DiffPanel).loadRawDiff(unifiedDiff);
64
+ },
65
+ armConfirm: (opts) => {
66
+ let panel = manager.getAllOpen().find((p) => p.id === 'diff');
67
+ if (!panel) { try { panel = manager.open('diff'); } catch { return; } }
68
+ manager.activateById('diff');
69
+ if (!manager.isVisible()) manager.show();
70
+ manager.focusPanels();
71
+ (panel as DiffPanel).confirmOverlay.arm(opts);
72
+ },
73
+ close: () => { manager.close('diff'); },
74
+ };
75
+ const fleetActs = new FleetActs({
76
+ resolveGateway: () => createFleetGateway({
77
+ configManager: deps.configManager,
78
+ homeDirectory: ui.environment.shellPaths.homeDirectory,
79
+ armFixSessionAttach,
80
+ }),
81
+ diffSurface,
82
+ notify: fleetNotify,
83
+ markDirty: deps.requestRender ?? (() => {}),
84
+ findNode: (nodeId: string) => fleetReadModel.getSnapshot().rows.find((r) => r.node.id === nodeId)?.node ?? null,
85
+ });
86
+
87
+ // The ACP spawn affordance ('n'): host a discovered third-party agent as an
88
+ // acp-agent row over the same operator invoke path the acts use.
89
+ const fleetSpawn = new FleetSpawn({
90
+ resolveGateway: () => createAcpSpawnGateway({
91
+ configManager: deps.configManager,
92
+ homeDirectory: ui.environment.shellPaths.homeDirectory,
93
+ }),
94
+ currentDirectory: () => ui.environment.shellPaths.workingDirectory,
95
+ notify: fleetNotify,
96
+ markDirty: deps.requestRender ?? (() => {}),
97
+ });
98
+
46
99
  manager.registerType({
47
100
  id: 'fleet',
48
101
  name: 'Fleet',
@@ -63,7 +116,7 @@ export function registerOperationsPanels(manager: PanelManager, deps: ResolvedBu
63
116
  resolveSessionLogPath: (agentId: string) => ui.environment.shellPaths.resolveProjectPath('tui', 'sessions', `${agentId}.jsonl`),
64
117
  // Queue a message for a live in-process agent/wrfc-subtask member.
65
118
  steer: (id: string, text: string) => fleetReadModel.steer(id, text),
66
- }, deps.configManager),
119
+ }, deps.configManager, fleetActs, fleetSpawn),
67
120
  });
68
121
 
69
122
  // Compat: '/panel open <id>' (and any saved layout/muscle memory) for
@@ -150,6 +150,18 @@ export interface BuiltinPanelDeps {
150
150
  * cross-panel navigation instead of printing a "/panel open …" signpost.
151
151
  */
152
152
  openPanel?: (panelId: string) => void;
153
+ /**
154
+ * Surface a Fleet act result/receipt/error to the operator (late-bound to the
155
+ * command context's print, which the Fleet pick/conflict/discard acts use for
156
+ * their receipts — the same conversation sink the command flow prints to).
157
+ */
158
+ fleetActsNotify?: (message: string) => void;
159
+ /**
160
+ * Hand a spawned session id to the shared one-key jump/attach affordance (the
161
+ * CI fix-session machinery). Late-bound: main.ts patches it onto the command
162
+ * context after bootstrap; the Fleet conflict-resolve act reuses it verbatim.
163
+ */
164
+ armFixSessionAttach?: (sessionId: string) => void;
153
165
  }
154
166
 
155
167
  export type ResolvedBuiltinPanelDeps = Omit<
@@ -1,12 +1,12 @@
1
- import { networkInterfaces } from 'node:os';
2
- import { readFileSync } from 'node:fs';
3
1
  import type { PanelManager } from './panel-manager.ts';
4
2
  import { requireUiServices, type ResolvedBuiltinPanelDeps } from './builtin/shared.ts';
5
3
  import type { ConfigModalSurface, ConfigModalView } from '../input/config-modal-types.ts';
6
4
  import { createProviderRuntimeInspectionQuery } from '../runtime/ui-service-queries.ts';
7
5
  import { createRuntimeProviderApi } from '@/runtime/index.ts';
8
6
  import { copyToClipboard } from '../utils/clipboard.ts';
9
- import { getOrCreateCompanionToken, buildCompanionConnectionInfo } from '@pellux/goodvibes-sdk/platform/pairing';
7
+ import { ensurePublicBaseUrl } from '../core/pairing-origin.ts';
8
+ import { availablePairingOffers, mintPairingHandoff, defaultPairingTokenName } from '../core/pairing-handoff.ts';
9
+ import { probePairingTailscale, runPairingTailscaleServe } from '../core/pairing-tailscale-gateway.ts';
10
10
  // ── Providers & Connectivity + Security subset ────────────────────────────────
11
11
  import { createServicesModalSurface } from './modals/services-modal.ts';
12
12
  import { createSubscriptionModalSurface } from './modals/subscription-modal.ts';
@@ -24,9 +24,11 @@ import { createSecurityModalSurface } from './modals/security-modal.ts';
24
24
  import { createPolicyModalSurface } from './modals/policy-modal.ts';
25
25
  import { createKnowledgeModalSurface } from './modals/knowledge-modal.ts';
26
26
  import { createMemoryModalSurface, type MemoryModalDeps } from './modals/memory-modal.ts';
27
+ import { createMemoryConsolidationGateway } from './memory-consolidation-gateway.ts';
27
28
  import { createWorkPlanModalSurface } from './modals/work-plan-modal.ts';
28
29
  import { createKeybindingsModalSurface } from './modals/keybindings-modal.ts';
29
30
  import { createPairingModalSurface, type PairingModalConnectionInfo } from './modals/pairing-modal.ts';
31
+ import { createDevicesModalSurface } from './modals/devices-modal.ts';
30
32
  import { createPlanningModalSurface } from './modals/planning-modal.ts';
31
33
 
32
34
  /**
@@ -131,6 +133,13 @@ export function registerBuiltinModals(manager: PanelManager, deps: ResolvedBuilt
131
133
 
132
134
  manager.registerModalSurface(createMemoryModalSurface({
133
135
  memoryRegistry: deps.memoryRegistry as MemoryModalDeps['memoryRegistry'],
136
+ // Lazily resolved — a fresh factory call per Proposals-tab fetch, exactly
137
+ // like the Fleet gateway (fleet-gateway.ts) — so a daemon that comes up
138
+ // AFTER this session started is still reachable on the next refresh.
139
+ resolveConsolidationGateway: () => createMemoryConsolidationGateway({
140
+ configManager: deps.configManager,
141
+ homeDirectory: ui.environment.shellPaths.homeDirectory,
142
+ }),
134
143
  }));
135
144
  manager.registerModalRedirect('memory', 'memory-modal');
136
145
 
@@ -144,13 +153,24 @@ export function registerBuiltinModals(manager: PanelManager, deps: ResolvedBuilt
144
153
  }));
145
154
  manager.registerModalRedirect('docs', 'keybindings-modal');
146
155
 
156
+ const tailscaleGatewayDeps = { configManager: deps.configManager, homeDirectory: ui.environment.shellPaths.homeDirectory };
147
157
  manager.registerModalSurface(createPairingModalSurface({
148
158
  getConnectionInfo: () => buildPairingConnectionInfo(deps),
149
159
  controlPlaneReadModel: ui.readModels.controlPlane,
150
160
  copyToClipboard,
161
+ probeTailscale: () => probePairingTailscale(tailscaleGatewayDeps),
162
+ runTailscaleServe: () => runPairingTailscaleServe(tailscaleGatewayDeps),
151
163
  }));
152
164
  manager.registerModalRedirect('qr-code', 'pairing-modal');
153
165
 
166
+ // Paired-device management (settings security domain): list/rename/revoke/
167
+ // migrate-shared over the same per-device token store the pairing QR mints into.
168
+ manager.registerModalSurface(createDevicesModalSurface({
169
+ pairingTokens: ui.platform.pairingTokens,
170
+ copyToClipboard,
171
+ }));
172
+ manager.registerModalRedirect('devices', 'devices-modal');
173
+
154
174
  manager.registerModalSurface(createPlanningModalSurface({
155
175
  service: deps.projectPlanningService,
156
176
  projectId: deps.projectPlanningProjectId,
@@ -171,15 +191,33 @@ export function registerBuiltinModals(manager: PanelManager, deps: ResolvedBuilt
171
191
  * Mirrors the retired QrPanel factory's construction.
172
192
  */
173
193
  function buildPairingConnectionInfo(deps: ResolvedBuiltinPanelDeps): PairingModalConnectionInfo | null {
174
- if (!deps.daemonHomeDir) return null;
175
194
  try {
176
- const daemonHomeDir = deps.daemonHomeDir;
177
- const tokenRecord = getOrCreateCompanionToken('tui', { daemonHomeDir });
178
- const daemonPort = deps.configManager.get('controlPlane.port');
179
- const daemonHost = String(process.env['GOODVIBES_DAEMON_HOST'] ?? getLocalNetworkIp());
180
- const daemonUrl = `http://${daemonHost}:${daemonPort}`;
181
- const bootstrapPassword = readBootstrapPassword(deps.localUserAuthManager.getBootstrapCredentialPath());
182
- return buildCompanionConnectionInfo({ daemonUrl, token: tokenRecord.token, password: bootstrapPassword, surface: 'tui' }) as PairingModalConnectionInfo;
195
+ const ui = requireUiServices(deps);
196
+ const configManager = deps.configManager;
197
+ // Freeze a stable web origin once (never clobbering a user-set value).
198
+ const webOrigin = ensurePublicBaseUrl(configManager);
199
+ const offers = availablePairingOffers({
200
+ relayEnabled: configManager.get('relay.enabled') === true,
201
+ // The TUI composition always wires a step-up service ⇒ the passkey offer is
202
+ // presentable; the web app declines it if the device has no authenticator.
203
+ stepUpAvailable: true,
204
+ });
205
+ // Mint a fresh per-device token and encode the canonical `#pair=` deep link —
206
+ // the same mint the pairing.handoff.create verb performs, no raw JSON blob.
207
+ const handoff = mintPairingHandoff({
208
+ pairingTokens: ui.platform.pairingTokens,
209
+ name: defaultPairingTokenName(),
210
+ offers,
211
+ webOrigin: webOrigin.origin,
212
+ });
213
+ return {
214
+ url: webOrigin.origin,
215
+ token: handoff.token.token,
216
+ tokenName: handoff.token.name,
217
+ deepLink: handoff.deepLink ?? handoff.fragment,
218
+ offers: handoff.offers,
219
+ posture: handoff.posture,
220
+ };
183
221
  } catch {
184
222
  return null;
185
223
  }
@@ -195,24 +233,3 @@ function unwiredSurface(name: string, title: string, reason: string): ConfigModa
195
233
  };
196
234
  }
197
235
 
198
- function getLocalNetworkIp(): string {
199
- const nets = networkInterfaces();
200
- for (const name of Object.keys(nets)) {
201
- for (const net of nets[name] ?? []) {
202
- if (net.family === 'IPv4' && !net.internal) return net.address;
203
- }
204
- }
205
- return 'localhost';
206
- }
207
-
208
- function readBootstrapPassword(credentialPath: string): string | undefined {
209
- try {
210
- const content = readFileSync(credentialPath, 'utf-8');
211
- for (const line of content.split('\n')) {
212
- if (line.startsWith('password=')) return line.slice('password='.length).trim();
213
- }
214
- } catch {
215
- // credential file may not exist yet
216
- }
217
- return undefined;
218
- }
@@ -5,7 +5,7 @@
5
5
  import type { Line } from '../types/grid.ts';
6
6
  import { createStyledCell, createEmptyLine } from '../types/grid.ts';
7
7
  import { truncateDisplay, getDisplayWidth } from '../utils/terminal-width.ts';
8
- import { GitService } from '@pellux/goodvibes-sdk/platform/git';
8
+ import { GitService, type StructuredDiff, type StructuredDiffFile } from '@pellux/goodvibes-sdk/platform/git';
9
9
  import { BasePanel } from './base-panel.ts';
10
10
  import { UI_TONES, DIFF_TONES } from '../renderer/ui-primitives.ts';
11
11
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
@@ -199,6 +199,19 @@ function extractDiffFilePath(trimmed: string): string {
199
199
  return 'unknown';
200
200
  }
201
201
 
202
+ /** Reconstruct one file's unified-diff text from a StructuredDiffFile (header + hunks, prefixes re-attached; no size cap). */
203
+ function reconstructStructuredFile(file: StructuredDiffFile): string {
204
+ const out: string[] = [...file.headerLines];
205
+ for (const hunk of file.hunks) {
206
+ out.push(hunk.header);
207
+ for (const line of hunk.lines) {
208
+ const prefix = line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' ';
209
+ out.push(`${prefix}${line.text}`);
210
+ }
211
+ }
212
+ return out.join('\n');
213
+ }
214
+
202
215
  function splitIntoDiffEntries(raw: string): DiffEntry[] {
203
216
  const entries: DiffEntry[] = [];
204
217
  // Split on "diff --git" / "diff --cc" / "diff --combined" lines
@@ -306,6 +319,26 @@ export class DiffPanel extends BasePanel {
306
319
  this.markDirty();
307
320
  }
308
321
 
322
+ /**
323
+ * Ingest a StructuredDiff (GitService.diffStructured — the FULL, uncapped
324
+ * working-tree diff). Each file becomes one DiffEntry (unified text
325
+ * reconstructed) so the existing tabs/renderer work unchanged; the old
326
+ * 4,000-char slice is gone and a diff of any length renders complete.
327
+ */
328
+ loadStructuredDiff(diff: StructuredDiff): void {
329
+ this.entries = diff.files.map((file) => {
330
+ const raw = reconstructStructuredFile(file);
331
+ return {
332
+ filePath: file.newPath ?? file.oldPath ?? 'unknown',
333
+ raw,
334
+ lines: parseDiff(raw),
335
+ };
336
+ });
337
+ this.selectedFile = 0;
338
+ this.scrollOffset = 0;
339
+ this.markDirty();
340
+ }
341
+
309
342
  /**
310
343
  * Defensive gate mirroring GitPanel's own notGitRepo messaging — short-
311
344
  * circuits before any of this panel's self-load git spawns run, instead of