@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
@@ -0,0 +1,123 @@
1
+ // ---------------------------------------------------------------------------
2
+ // workstream-graph-render.ts — render a workstream's task graph (fleet.graph.get)
3
+ // legibly, so the observability layer shows the dependency graph's shape,
4
+ // states, and pool posture WITHOUT opening any transcript.
5
+ //
6
+ // The SDK serves a WorkstreamGraphSnapshot: nodes (id/title/state, plus
7
+ // blockedReason, orphaned, stalled, agentId), edges (from → to dependency
8
+ // links), and the elastic-pool state (ready/running/atCap/maxSize). This module
9
+ // is a pure string renderer; every line is fit to the given width (no clipping,
10
+ // no horizontal overflow), so the same output drives a panel or a command.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import type { OperatorMethodOutput } from '@pellux/goodvibes-sdk';
14
+ import { truncateDisplay, getDisplayWidth } from '../utils/terminal-width.ts';
15
+
16
+ export type WorkstreamGraphSnapshot = OperatorMethodOutput<'fleet.graph.get'>;
17
+ export type WorkstreamGraphNode = WorkstreamGraphSnapshot['nodes'][number];
18
+
19
+ /** The pool posture line: "N ready, M running[, at cap (fleet.maxSize=N)]". */
20
+ export function renderPoolSummary(pool: WorkstreamGraphSnapshot['pool']): string {
21
+ if (!pool) return 'no pool state';
22
+ const base = `${pool.ready} ready, ${pool.running} running`;
23
+ return pool.atCap ? `${base}, at cap (fleet.maxSize=${pool.maxSize})` : base;
24
+ }
25
+
26
+ /** The leading glyph for a node — orphaned/blocked/stalled tells win over the base state. */
27
+ function nodeGlyph(node: WorkstreamGraphNode): string {
28
+ if (node.orphaned) return '⊘';
29
+ if (node.blockedReason) return '⊘';
30
+ if (node.stalled) return '◒';
31
+ switch (node.state) {
32
+ case 'running': return '●';
33
+ case 'ready': return '○';
34
+ case 'done': return '✓';
35
+ case 'failed': return '✕';
36
+ default: return '·';
37
+ }
38
+ }
39
+
40
+ /** The state tell for a node: the "waiting on: X" for blocked, running/ready, plus orphaned/stalled markers. */
41
+ function nodeTell(node: WorkstreamGraphNode, needs: readonly string[]): string {
42
+ const parts: string[] = [];
43
+ if (node.blockedReason) {
44
+ parts.push(`blocked — waiting on: ${node.blockedReason}`);
45
+ } else if (node.state === 'running') {
46
+ parts.push(node.agentId ? `running (${node.agentId})` : 'running');
47
+ } else if (node.state === 'ready') {
48
+ parts.push('ready');
49
+ } else {
50
+ parts.push(node.state);
51
+ }
52
+ if (needs.length > 0 && !node.blockedReason) parts.push(`needs: ${needs.join(', ')}`);
53
+ if (node.orphaned) parts.push('orphaned');
54
+ if (node.stalled) parts.push('stalled');
55
+ return parts.join(' · ');
56
+ }
57
+
58
+ /** Fit a line to the width without overflowing (truncates with an ellipsis). */
59
+ function fit(line: string, width: number): string {
60
+ return getDisplayWidth(line) > width ? truncateDisplay(line, width) : line;
61
+ }
62
+
63
+ /**
64
+ * Wrap content to the width on word boundaries — NO clipping: the whole tell
65
+ * (including a long "waiting on: …" reason) survives onto indented continuation
66
+ * lines rather than being cut off. `firstIndent` leads the first line;
67
+ * `contIndent` leads each continuation. A single word longer than the width is
68
+ * hard-fit defensively so a line never overflows.
69
+ */
70
+ function wrapContent(content: string, width: number, firstIndent: string, contIndent: string): string[] {
71
+ const words = content.split(/\s+/).filter(Boolean);
72
+ const out: string[] = [];
73
+ let cur = firstIndent;
74
+ let hasWord = false;
75
+ for (const word of words) {
76
+ const candidate = hasWord ? `${cur} ${word}` : `${cur}${word}`;
77
+ if (getDisplayWidth(candidate) > width && hasWord) {
78
+ out.push(cur);
79
+ cur = `${contIndent}${word}`;
80
+ } else {
81
+ cur = candidate;
82
+ }
83
+ hasWord = true;
84
+ }
85
+ if (hasWord) out.push(cur);
86
+ return out.map((l) => (getDisplayWidth(l) > width ? fit(l, width) : l));
87
+ }
88
+
89
+ /**
90
+ * Render the whole snapshot as legible lines, each fit to `width`. The header
91
+ * carries the pool posture; every node is one line with its glyph, title, and
92
+ * state tell (ready / running / blocked-with-waiting-on / stalled / orphaned),
93
+ * with its dependency edges named inline ("needs: …"). An empty graph states so
94
+ * honestly rather than rendering a blank surface.
95
+ */
96
+ export function renderWorkstreamGraphLines(snapshot: WorkstreamGraphSnapshot, width: number): string[] {
97
+ const lines: string[] = [];
98
+ // Title and pool posture on their own lines so neither clips the other at a
99
+ // narrow width — the pool summary ("… at cap (fleet.maxSize=N)") is load-bearing.
100
+ lines.push(fit(snapshot.title, width));
101
+ lines.push(fit(` ${renderPoolSummary(snapshot.pool)}`, width));
102
+
103
+ if (snapshot.nodes.length === 0) {
104
+ lines.push(fit(' (no work items in this workstream)', width));
105
+ return lines;
106
+ }
107
+
108
+ const titleById = new Map<string, string>();
109
+ for (const node of snapshot.nodes) titleById.set(node.id, node.title);
110
+ const needsByNode = new Map<string, string[]>();
111
+ for (const edge of snapshot.edges) {
112
+ const list = needsByNode.get(edge.to) ?? [];
113
+ list.push(titleById.get(edge.from) ?? edge.from);
114
+ needsByNode.set(edge.to, list);
115
+ }
116
+
117
+ for (const node of snapshot.nodes) {
118
+ const glyph = nodeGlyph(node);
119
+ const tell = nodeTell(node, needsByNode.get(node.id) ?? []);
120
+ lines.push(...wrapContent(`${glyph} ${node.title} ${tell}`, width, ' ', ' '));
121
+ }
122
+ return lines;
123
+ }
@@ -0,0 +1,69 @@
1
+ // ---------------------------------------------------------------------------
2
+ // composer-fragments.ts — composer-adjacent transcript fragments extracted from
3
+ // ui-factory.ts (file-size hygiene): the mid-turn queued-message editable list
4
+ // and the optional "used N memories" provenance chip. Pure renderers; the
5
+ // UIFactory static methods are thin wrappers over these.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ import { type Line } from '../types/grid.ts';
9
+ import { renderConversationFragment } from './conversation-surface.ts';
10
+ import { activeUiTones } from './theme.ts';
11
+ import { activeTheme } from './theme.ts';
12
+
13
+ /**
14
+ * The mid-turn message queue rendered as an EDITABLE list. Each still-undelivered
15
+ * message shows a 1-based number so it can be named to `/queue edit <n> …` /
16
+ * `/queue delete <n>` (the SDK editQueuedMessage / deleteQueuedMessage verbs). A
17
+ * delivered message has already left the queue, so the list only ever shows what
18
+ * is still editable — delivery is immutability, made visible.
19
+ */
20
+ export function renderQueuedMessageList(width: number, items: readonly { readonly id: string; readonly text: string }[]): Line[] {
21
+ if (items.length === 0) return [];
22
+ const t = activeUiTones();
23
+ const bodyBg = activeTheme().collapsedBodyBg;
24
+ const lines: Line[] = [];
25
+ const header = `${items.length} queued — /queue edit·delete until delivered`;
26
+ lines.push(...renderConversationFragment(header, width, { prefix: ' ⧗ ', prefixFg: t.state.reasoning, text: t.fg.dim, bodyBg, dim: true }));
27
+ items.forEach((item, index) => {
28
+ lines.push(...renderConversationFragment(item.text, width, { prefix: ` ${index + 1}. `, prefixFg: t.state.reasoning, text: t.fg.dim, bodyBg, dim: true }));
29
+ });
30
+ return lines;
31
+ }
32
+
33
+ /**
34
+ * One drilled-in memory-provenance row. `record` is the RESOLVED record summary
35
+ * (undefined = still resolving, null = no longer available) — the drill-in
36
+ * shows a human summary, never a raw record id, matching the webui's detail
37
+ * fetch (per-id, so one missing record never blanks the rest).
38
+ */
39
+ export interface MemoryProvenanceEntry {
40
+ readonly id: string;
41
+ readonly record?: { readonly summary: string; readonly cls: string } | null | undefined;
42
+ }
43
+
44
+ /**
45
+ * The optional "used N memories" turn chip. Collapsed: one small line naming the
46
+ * count with the drill-in hint. Expanded (Alt+M): the same line followed by one
47
+ * line per memory — its resolved SUMMARY (with the record class), a "resolving…"
48
+ * placeholder while the fetch is in flight, or an honest "no longer available"
49
+ * for a record that has since been forgotten. Empty when no memories were used.
50
+ */
51
+ export function renderMemoryProvenanceChip(width: number, count: number, entries: readonly MemoryProvenanceEntry[], expanded: boolean): Line[] {
52
+ if (count <= 0) return [];
53
+ const t = activeUiTones();
54
+ const bodyBg = activeTheme().collapsedBodyBg;
55
+ const noun = count === 1 ? 'memory' : 'memories';
56
+ const header = expanded ? `used ${count} ${noun} — Alt+M to hide` : `used ${count} ${noun} — Alt+M to list`;
57
+ const lines: Line[] = renderConversationFragment(header, width, { prefix: ' ◆ ', prefixFg: t.state.info, text: t.fg.dim, bodyBg, dim: true });
58
+ if (expanded) {
59
+ entries.forEach((entry, index) => {
60
+ const body = entry.record === undefined
61
+ ? 'resolving…'
62
+ : entry.record === null
63
+ ? 'no longer available'
64
+ : `${entry.record.summary} · ${entry.record.cls}`;
65
+ lines.push(...renderConversationFragment(body, width, { prefix: ` ${index + 1}. `, prefixFg: t.state.info, text: t.fg.dim, bodyBg, dim: true }));
66
+ });
67
+ }
68
+ return lines;
69
+ }
@@ -77,6 +77,7 @@ export const CATEGORY_LABELS: Record<(typeof SETTINGS_CATEGORIES)[number], strin
77
77
  wrfc: 'WRFC',
78
78
  helper: 'Helper',
79
79
  tts: 'TTS',
80
+ voice: 'Voice (local engines)',
80
81
  service: 'Service',
81
82
  daemon: 'Daemon',
82
83
  controlPlane: 'Control Plane',
@@ -95,6 +96,8 @@ export const CATEGORY_LABELS: Record<(typeof SETTINGS_CATEGORIES)[number], strin
95
96
  surfaces: 'Surfaces',
96
97
  cloudflare: 'Cloudflare',
97
98
  release: 'Release',
99
+ power: 'Power',
100
+ memory: 'Memory',
98
101
  danger: 'Danger',
99
102
  tools: 'Tools',
100
103
  network: 'Network',
@@ -115,6 +118,12 @@ const SETTING_LABELS: Partial<Record<string, string>> = {
115
118
  'ui.voiceEnabled': 'Always Speak',
116
119
  'behavior.autoCompactThreshold': 'Auto-Compact %',
117
120
  'behavior.staleContextWarnings': 'Context Warnings',
121
+ // Blocked-too-long escalation: a fleet node waiting on a human gets a device
122
+ // push once the grace elapses (regardless of an attached surface), then bounded
123
+ // reminders. Real numeric option shapes come from the SDK schema's ranges.
124
+ 'notifications.blockedEscalationGraceMs': 'Blocked-Too-Long Grace (ms)',
125
+ 'notifications.blockedEscalationFollowUpMs': 'Blocked Reminder Interval (ms)',
126
+ 'notifications.blockedEscalationMaxFollowUps': 'Blocked Reminder Limit',
118
127
  'behavior.returnContextMode': 'Return Context',
119
128
  'behavior.guidanceMode': 'Guidance Mode',
120
129
  'storage.secretPolicy': 'Secret Policy',
@@ -40,6 +40,7 @@ const CATEGORY_INFO: Record<SettingsCategory, string> = {
40
40
  wrfc: 'Work-review-fix-cycle thresholds, retry limits, and automatic commit behavior.',
41
41
  helper: 'Helper model defaults used by helper subsystems when they do not use the main chat route.',
42
42
  tts: 'Text-to-speech provider, voice, and optional spoken-turn LLM overrides.',
43
+ voice: 'Free, offline local voice engines (voice.local.*): the STT and TTS engine, binary, and model-path for whisper.cpp/faster-whisper and Piper/Kokoro. All empty by default — configurable-not-configured, nothing auto-downloads, and an unconfigured machine reports honestly (never an error). Once set, "Local engines" appears in the TTS provider picker beside elevenlabs, with no billing dimension.',
43
44
  service: 'Background service posture: enabled state, autostart, restart behavior, service name, platform, and logs.',
44
45
  daemon: 'Local session daemon. It hosts the shared session broker and companion chat so a session started here is visible and steerable from other surfaces. On by default, bound to loopback (127.0.0.1) only.',
45
46
  controlPlane: 'Daemon control-plane settings for local admin/API access.',
@@ -58,6 +59,8 @@ const CATEGORY_INFO: Record<SettingsCategory, string> = {
58
59
  surfaces: 'External app surfaces such as Slack, Discord, ntfy, Home Assistant, Telegram, webhooks, chat bridges, and messaging providers.',
59
60
  cloudflare: 'Optional Cloudflare control plane, batch queue, Worker, Tunnel, Access, DNS, KV, Durable Objects, Secrets Store, and R2 settings.',
60
61
  release: 'Release-channel preference.',
62
+ power: 'Host sleep ownership. Keep-awake holds the host awake (the always-visible "sleep disabled" chip is the safety mechanism — no timer, no AC-only option); automatic inhibition keeps the host awake while real work runs, with an honest hard cap on that work hold.',
63
+ memory: 'Memory provenance surfacing. Whether turns that drew on your standing memories show a small "used N memories" chip with a drill-in listing them. Off by default — when off, nothing is rendered and no context is added.',
61
64
  danger: 'High-impact switches for daemon and HTTP listener behavior. These are operational overrides, not normal preferences.',
62
65
  tools: 'Tool LLM and helper model routing. Empty provider/model values inherit the active chat route unless a specific helper/tool route is set.',
63
66
  network: 'Combined network view for daemon control-plane, HTTP listener, browser web surface, and general outbound network settings.',
@@ -183,8 +186,7 @@ function buildSettingContext(modal: SettingsModal, entry: SettingEntry): string[
183
186
  if (entry.locked) lines.push(`Locked: ${entry.lockReason ?? 'This setting is locked by a higher-priority layer.'}`);
184
187
  if (entry.conflict) lines.push(`Conflict: resolve with /settings-sync resolve ${entry.setting.key} local|synced.`);
185
188
 
186
- // A settings sub-row owned by a feature unit names its feature so "what
187
- // does this do" is answerable without scrolling back to the header row.
189
+ // A settings sub-row owned by a feature unit names its feature so "what does this do" is answerable without scrolling back to the header row.
188
190
  if (entry.ownerFlagId) {
189
191
  const owner = FEATURE_SETTINGS_BY_ID.get(entry.ownerFlagId);
190
192
  if (owner) lines.push(`Part of feature: ${owner.name} (the header row above).`);
@@ -67,6 +67,8 @@ export interface ShellFooterBuildOptions {
67
67
  readonly sessionSpineStatus?: 'online' | 'offline';
68
68
  /** The web surface's reachable URL, when that surface is enabled; a persistent footer segment. */
69
69
  readonly webSurfaceUrl?: string;
70
+ /** True while power.keepAwake holds — renders the always-visible "sleep disabled" chip. */
71
+ readonly powerKeepAwake?: boolean;
70
72
  }
71
73
 
72
74
  export interface ShellFooterBuildResult {
@@ -147,6 +149,7 @@ export function buildShellFooter(
147
149
  options.sessionSpineStatus,
148
150
  options.permissionMode,
149
151
  options.webSurfaceUrl,
152
+ options.powerKeepAwake,
150
153
  );
151
154
  // Compact posture drops the process indicator and context hint entirely so
152
155
  // the footer fits within ~5 rows on short terminals.
@@ -147,7 +147,7 @@ function extractKeyArg(toolCall: ToolCall): string {
147
147
  * Layout: [margin] [icon] [space] [tool name padded] [key arg] [summary] [duration]
148
148
  *
149
149
  * @param toolCall - The tool call being executed
150
- * @param status - 'executing' | 'done' | 'error'
150
+ * @param status - 'executing' | 'done' | 'error' | 'pending' | 'cancelled'
151
151
  * @param resultSummary - Optional brief summary (e.g., "3 files", "exit 0")
152
152
  * @param width - Terminal width
153
153
  * @param durationMs - Optional duration in milliseconds
@@ -157,7 +157,7 @@ function extractKeyArg(toolCall: ToolCall): string {
157
157
  */
158
158
  export function renderToolCallBlock(
159
159
  toolCall: ToolCall,
160
- status: 'executing' | 'done' | 'error' | 'pending',
160
+ status: 'executing' | 'done' | 'error' | 'pending' | 'cancelled',
161
161
  resultSummary: string | undefined,
162
162
  width: number,
163
163
  durationMs?: number,
@@ -174,12 +174,16 @@ export function renderToolCallBlock(
174
174
  // Status icon. 'pending' means the tool is still awaiting a decision
175
175
  // (e.g. an approval prompt) and has NOT run yet — it uses the hollow idle
176
176
  // glyph so a not-yet-run tool never shows the completed green ✓ (2c).
177
+ // 'cancelled' means the user stopped THIS call mid-flight — the blocked glyph
178
+ // in the warn tone, distinct from both success and a real error.
177
179
  const icon = status === 'done' ? TOOL_STATUS.SUCCESS_ICON
178
180
  : status === 'error' ? TOOL_STATUS.FAIL_ICON
181
+ : status === 'cancelled' ? GLYPHS.status.blocked
179
182
  : status === 'pending' ? GLYPHS.status.idle
180
183
  : TOOL_STATUS.SPINNER_FRAMES[(frameIndex ?? 0) % TOOL_STATUS.SPINNER_FRAMES.length];
181
184
  const iconColor = status === 'done' ? t.chrome.good
182
185
  : status === 'error' ? t.chrome.bad
186
+ : status === 'cancelled' ? t.chrome.warn
183
187
  : '244';
184
188
  const rightText = (() => {
185
189
  if (durationMs !== undefined && status === 'done') {
@@ -204,7 +208,7 @@ export function renderToolCallBlock(
204
208
  let col: number = leftStart;
205
209
 
206
210
  if (col < leftEndExclusive) {
207
- line[col] = createStyledCell(icon, { fg: iconColor, bold: status === 'done' || status === 'error' });
211
+ line[col] = createStyledCell(icon, { fg: iconColor, bold: status === 'done' || status === 'error' || status === 'cancelled' });
208
212
  }
209
213
  col += 2; // icon + space
210
214
 
@@ -215,9 +219,11 @@ export function renderToolCallBlock(
215
219
  const keyArg = stripDangerousAnsi(extractKeyArg(toolCall));
216
220
  const suffixText = status === 'error' && errorMsg
217
221
  ? `- ${stripDangerousAnsi(errorMsg).slice(0, 40)}`
218
- : status === 'done' && resultSummary
219
- ? `(${stripDangerousAnsi(resultSummary)})`
220
- : '';
222
+ : status === 'cancelled'
223
+ ? '- cancelled'
224
+ : status === 'done' && resultSummary
225
+ ? `(${stripDangerousAnsi(resultSummary)})`
226
+ : '';
221
227
  const leftBudget = Math.max(0, leftEndExclusive - col);
222
228
  const leftSegments = buildLeftSegments(rawName, keyArg, suffixText, leftBudget);
223
229
  for (const segment of leftSegments) {
@@ -10,6 +10,8 @@ import { formatElapsed } from '../utils/format-elapsed.ts';
10
10
  import { abbreviateCount } from '../utils/format-number.ts';
11
11
  import { computeContextUsage } from '../core/context-usage.ts';
12
12
  import { permissionModeLabel, permissionModeTone } from '../core/permission-mode.ts';
13
+ import { SLEEP_DISABLED_CHIP } from '../core/power-status.ts';
14
+ import { renderQueuedMessageList, renderMemoryProvenanceChip, type MemoryProvenanceEntry } from './composer-fragments.ts';
13
15
  import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
14
16
  import { buildFooterTip, isAgentActive } from './footer-tips.ts';
15
17
  import type { StreamMetrics } from '../core/stream-event-wiring.ts';
@@ -173,6 +175,16 @@ export class UIFactory {
173
175
  });
174
176
  }
175
177
 
178
+ /** The mid-turn queue as an editable list — see composer-fragments.ts. */
179
+ public static createQueuedMessageList(width: number, items: readonly { readonly id: string; readonly text: string }[]): Line[] {
180
+ return renderQueuedMessageList(width, items);
181
+ }
182
+
183
+ /** The optional "used N memories" provenance chip — see composer-fragments.ts. */
184
+ public static createMemoryProvenanceChip(width: number, count: number, entries: readonly MemoryProvenanceEntry[], expanded: boolean): Line[] {
185
+ return renderMemoryProvenanceChip(width, count, entries, expanded);
186
+ }
187
+
176
188
  public static createFooter(
177
189
  width: number,
178
190
  prompt: string,
@@ -205,6 +217,10 @@ export class UIFactory {
205
217
  // The web surface's reachable URL, when that surface is enabled — a
206
218
  // persistent low-priority context segment; undefined suppresses it.
207
219
  webSurfaceUrl?: string,
220
+ // True while power.keepAwake holds — renders the always-visible
221
+ // "sleep disabled" chip in the posture block (the danger-mode idiom: a
222
+ // persistent, colored surface indicator, the safety mechanism itself).
223
+ powerKeepAwake?: boolean,
208
224
  ): Line[] {
209
225
  const lines: Line[] = [];
210
226
  const promptLines = prompt.split('\n');
@@ -368,6 +384,10 @@ export class UIFactory {
368
384
  : t.chrome.warn;
369
385
  composerTokens.push({ text: ` risk:${composerPendingRisk} `, fg: riskColor, bold: true });
370
386
  }
387
+ // "sleep disabled" chip — always visible while keep-awake holds, in the
388
+ // danger-mode idiom (a persistent warn-tone indicator; the chip is the
389
+ // safety mechanism, so there is no timer and no AC-only variant).
390
+ if (powerKeepAwake) composerTokens.push({ text: ` ${SLEEP_DISABLED_CHIP} `, fg: t.chrome.warn, bold: true });
371
391
  if (composerStatus && composerStatus !== 'idle') composerTokens.push({ text: ` state:${composerStatus} `, fg: '244', dim: true });
372
392
  if (composerFlags && composerFlags.length > 0) composerTokens.push({ text: ` flags:${composerFlags.join(',')} `, fg: '244', dim: true });
373
393
  if (!compact && composerTokens.length > 0) {
@@ -391,6 +411,18 @@ export class UIFactory {
391
411
  if (px >= width) break;
392
412
  }
393
413
  lines.push(postureLine);
414
+ } else if (compact && powerKeepAwake) {
415
+ // The keep-awake chip is a safety indicator — it survives compact height
416
+ // even though the rest of the posture row (mode/ctx/risk/state/flags) is
417
+ // dropped at height < 30. Its own compact-surviving slot.
418
+ const chipLine = createBaseLine();
419
+ let cx = 2;
420
+ for (const ch of ` ${SLEEP_DISABLED_CHIP} `) {
421
+ if (cx >= width) break;
422
+ chipLine[cx] = { char: ch, fg: t.chrome.warn, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false };
423
+ cx += getDisplayWidth(ch);
424
+ }
425
+ lines.push(chipLine);
394
426
  }
395
427
  const isRecentlyCopied = Date.now() - lastCopyTime < 2000;
396
428
  // Token usage line + running ~$ cost estimate (derived from the usage
@@ -56,6 +56,7 @@ import {
56
56
 
57
57
  export type CreateBootstrapCommandContextOptions = {
58
58
  configManager: ConfigManager;
59
+ pairingTokens?: import('@pellux/goodvibes-sdk/platform/pairing').PairingTokenManager;
59
60
  featureFlagManager?: import('@/runtime/index.ts').FeatureFlagManager;
60
61
  providerRegistry: ProviderRegistry;
61
62
  conversation: ConversationManager;
@@ -144,6 +145,7 @@ export function createBootstrapCommandContext(
144
145
  const {
145
146
  providerRegistry,
146
147
  configManager,
148
+ pairingTokens,
147
149
  featureFlagManager,
148
150
  conversation,
149
151
  runtime,
@@ -288,7 +290,7 @@ export function createBootstrapCommandContext(
288
290
  projectPlanningProjectId,
289
291
  workPlanStore,
290
292
  }, shellServices);
291
- const platform = createBootstrapCommandPlatformSection({ configManager, featureFlagManager, voiceProviderRegistry, voiceService, webSearchService, mediaProviders, artifactStore }, shellServices);
293
+ const platform = createBootstrapCommandPlatformSection({ configManager, pairingTokens, featureFlagManager, voiceProviderRegistry, voiceService, webSearchService, mediaProviders, artifactStore }, shellServices);
292
294
  const extensions = createBootstrapCommandExtensionsSection({
293
295
  toolRegistry,
294
296
  mcpRegistry,
@@ -74,6 +74,7 @@ export interface BootstrapCommandActionOptions {
74
74
 
75
75
  export interface BootstrapCommandSectionOptions {
76
76
  readonly configManager: ConfigManager;
77
+ readonly pairingTokens?: import('@pellux/goodvibes-sdk/platform/pairing').PairingTokenManager;
77
78
  readonly featureFlagManager?: FeatureFlagManager;
78
79
  readonly providerRegistry: ProviderRegistry;
79
80
  readonly conversation: ConversationManager;
@@ -396,13 +397,14 @@ export function createBootstrapCommandWorkspaceSection(
396
397
  export function createBootstrapCommandPlatformSection(
397
398
  options: Pick<
398
399
  BootstrapCommandSectionOptions,
399
- 'configManager' | 'featureFlagManager' | 'voiceProviderRegistry' | 'voiceService' | 'webSearchService' | 'mediaProviders' | 'artifactStore'
400
+ 'configManager' | 'pairingTokens' | 'featureFlagManager' | 'voiceProviderRegistry' | 'voiceService' | 'webSearchService' | 'mediaProviders' | 'artifactStore'
400
401
  >,
401
402
  shellServices: BootstrapCommandShellServices,
402
403
  ): BootstrapCommandPlatformSection {
403
404
  return {
404
405
  config: getConfigSnapshot(options.configManager),
405
406
  configManager: options.configManager,
407
+ ...(options.pairingTokens ? { pairingTokens: options.pairingTokens } : {}),
406
408
  featureFlagManager: options.featureFlagManager,
407
409
  voiceProviderRegistry: options.voiceProviderRegistry,
408
410
  voiceService: options.voiceService,
@@ -25,6 +25,7 @@ import {
25
25
  import { readExecEnvScrubAllowlist } from '../input/exec-env-scrub-config.ts';
26
26
  import { createSandboxExecAsk, sandboxExecAskDepsFromRuntime } from '../permissions/sandbox-exec-gate.ts';
27
27
  import { createRuntimeServices, type RuntimeServices } from './services.ts';
28
+ import { createHostPowerSeam } from '@pellux/goodvibes-sdk/platform/power';
28
29
  import { runBootMemoryFold } from './memory-fold.ts';
29
30
  import { wireCostPricing } from '../export/cost-utils.ts';
30
31
  import { createUiRuntimeServices, type UiRuntimeServices } from './ui-services.ts';
@@ -37,9 +38,7 @@ import { SessionInboundInputPoller, createBootstrapInboundInputPoller } from './
37
38
  import { trustGatedAsk, type WorkspaceTrustLevel } from './trust/workspace-trust.ts';
38
39
  import { syncNotifierQueueIntegrations } from './bootstrap-notifier-sync.ts';
39
40
 
40
- // ---------------------------------------------------------------------------
41
- // Pre-router buffer
42
- // ---------------------------------------------------------------------------
41
+ // --- Pre-router buffer ---
43
42
 
44
43
  const PRE_ROUTER_BUFFER_MAX = 100;
45
44
 
@@ -133,9 +132,7 @@ export interface BootstrapCoreState {
133
132
 
134
133
  export type CompanionMessagePayload = Extract<SessionEvent, { type: 'COMPANION_MESSAGE_RECEIVED' }>;
135
134
 
136
- // ---------------------------------------------------------------------------
137
- // Operator narration of inbound channel events
138
- // ---------------------------------------------------------------------------
135
+ // --- Operator narration of inbound channel events ---
139
136
 
140
137
  /** Narrate an inbound channel event (GitHub, Slack, ntfy, etc.) that triggered an
141
138
  * agent turn, via the SystemMessageRouter — null for internal/companion sources. */
@@ -252,6 +249,9 @@ export async function initializeBootstrapCore(
252
249
  runtimeStore: store,
253
250
  getConversationTitle: () => getConversationTitle(),
254
251
  workingDir, homeDirectory,
252
+ // Embedded topology: the in-process runtime IS the daemon and holds its own OS
253
+ // inhibitor, so opt into the real host seam (fork default is non-spawning). See services.ts.
254
+ powerSeam: createHostPowerSeam(),
255
255
  });
256
256
  await services.workspaceTrustManager.load(); // settle any already-persisted trust decision before any tool runs
257
257
  const providerRegistry = services.providerRegistry;
@@ -206,6 +206,12 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
206
206
  runtimeStore,
207
207
  openPanel: (panelId: string) => { services.panelManager.open(panelId); },
208
208
  knowledgeApi,
209
+ // Fleet acts (pick / conflict / discard) surface receipts through the command
210
+ // context's print and reuse its one-key jump affordance — both late-bound
211
+ // (the command context is assigned to commandContextRef after bootstrap, and
212
+ // main.ts patches armFixSessionAttach onto it).
213
+ fleetActsNotify: (message: string) => { commandContextRef?.print?.(message); requestRender(); },
214
+ armFixSessionAttach: (sessionId: string) => { commandContextRef?.armFixSessionAttach?.(sessionId); },
209
215
  });
210
216
  services.panelManager.prewarmRegistered();
211
217
 
@@ -239,6 +245,7 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
239
245
 
240
246
  const commandContext: CommandContext = createBootstrapCommandContext({
241
247
  configManager,
248
+ pairingTokens: services.pairingTokens,
242
249
  providerRegistry: services.providerRegistry,
243
250
  conversation,
244
251
  runtime,
@@ -14,19 +14,36 @@
14
14
  * - One credential chain (env -> secrets -> subscription): boot applies
15
15
  * secrets-backed keys; every secrets write/delete re-registers builtins
16
16
  * LIVE (no restart) — badges/picker/chat read the same instances.
17
+ * - Start-time retention janitor: one best-effort append-only sweep over
18
+ * EVERY root the composition knows (working dir, surface root, home,
19
+ * logDir, telemetryDir) — omitting logDir/telemetryDir/home would silently
20
+ * skip the activity-log, telemetry-ledger, and recovery-snapshot stores.
21
+ * - Live config-file watch: external edits to the settings file apply through
22
+ * the same subscribe() pipeline an in-process set() uses — no restart. The
23
+ * underlying watchers are unref'd, so this never pins the event loop.
17
24
  */
18
25
  import { join } from 'node:path';
26
+ import { operations } from '@pellux/goodvibes-sdk/platform/runtime';
19
27
  import { resolveMemoryVectorDbPath } from '@pellux/goodvibes-sdk/platform/state';
20
28
  import { StoreSnapshotScheduler } from '@pellux/goodvibes-sdk/platform/state/store-snapshots';
21
29
  import { UserPermissionRuleStore } from '@pellux/goodvibes-sdk/platform/permissions';
22
30
  import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
23
31
 
24
32
  export interface DurabilityServicesInput {
25
- readonly configManager: { getControlPlaneConfigDir(): string };
33
+ readonly configManager: {
34
+ getControlPlaneConfigDir(): string;
35
+ get(key: never): unknown;
36
+ watchConfigFiles(options?: { intervalMs?: number }): () => void;
37
+ };
26
38
  readonly secretsManager: { onDidChange(listener: (key: string) => void): () => void };
27
39
  readonly providerRegistry: { refreshProviderCredentials(): Promise<void> };
28
40
  readonly memoryDbPath: string;
29
41
  readonly codeIndexDbPath: string;
42
+ /** Retention-sweep roots (mirrors the SDK's own createRuntimeServices roots). */
43
+ readonly workingDirectory: string;
44
+ readonly surfaceRoot: string;
45
+ readonly homeDirectory: string;
46
+ readonly shellPaths: { resolveUserPath(...segments: string[]): string };
30
47
  }
31
48
 
32
49
  export interface DurabilityServices {
@@ -48,6 +65,24 @@ export function createDurabilityServices(input: DurabilityServicesInput): Durabi
48
65
  });
49
66
  storeSnapshotScheduler.start();
50
67
 
68
+ // Start-time retention janitor: pass EVERY root the composition knows so the
69
+ // activity-log, telemetry-ledger, and recovery-snapshot stores are all swept.
70
+ // Best-effort — a retention failure never takes startup down (the sweep
71
+ // swallows its own errors).
72
+ operations.runStartupAppendOnlySweep(
73
+ {
74
+ workingDirectory: input.workingDirectory,
75
+ surfaceRoot: input.surfaceRoot,
76
+ homeDirectory: input.homeDirectory,
77
+ logDir: input.shellPaths.resolveUserPath('logs'),
78
+ telemetryDir: input.shellPaths.resolveUserPath('telemetry'),
79
+ },
80
+ (k: string) => configManager.get(k as never),
81
+ );
82
+ // External config edits apply LIVE through the same subscribe() pipeline an
83
+ // in-process set() uses; the underlying watchers are unref'd.
84
+ configManager.watchConfigFiles();
85
+
51
86
  const userPermissionRuleStore = new UserPermissionRuleStore(join(configManager.getControlPlaneConfigDir(), 'permission-rules.json'));
52
87
  void userPermissionRuleStore.init().catch((error) => logger.warn('user permission rule store init failed; asks will prompt', { error: summarizeError(error) }));
53
88
 
@@ -0,0 +1,41 @@
1
+ /**
2
+ * fleet-services.ts — the shared, archive-aware fleet registry construction,
3
+ * lifted out of services.ts so the composition root stays under the file-size
4
+ * cap. Mirrors the sibling create*Services helpers (durability, code-index,
5
+ * workstream): a single dependency-injected call that services.ts makes once.
6
+ *
7
+ * Also owns the daemon-side observed foreign-agent source: when
8
+ * observeExternalAgents is on (the standalone daemon), externally-launched
9
+ * coding-agent sessions observed read-only on the host fold in as
10
+ * 'observed-external' rows (visibility + steer; never counted against
11
+ * fleet.maxSize, never stopped). The interactive process leaves it off and
12
+ * reads the daemon's snapshot, so it never double-scans. Absence ⇒ a quiet
13
+ * empty set. Mirrors the SDK's own createRuntimeServices wiring.
14
+ */
15
+ import { computeUsageCostUsd, type ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
16
+ import { type ArchivableProcessRegistry } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
17
+ import { ObservedAgentSource } from '@pellux/goodvibes-sdk/platform/runtime/fleet/observed';
18
+ import { createArchivableFleetRegistry, type ProcessRegistryDeps } from '@pellux/goodvibes-terminal-shell';
19
+
20
+ export interface FleetServicesDeps
21
+ extends Omit<ProcessRegistryDeps, 'observedAgents' | 'priceUsage'> {
22
+ /** Turns on the daemon-side observed foreign-agent source (daemon only). */
23
+ readonly observeExternalAgents?: boolean | undefined;
24
+ /** Backs the honest pricing resolver folded into priceUsage below. */
25
+ readonly providerRegistry: Pick<ProviderRegistry, 'resolveModelPricing'>;
26
+ }
27
+
28
+ export function createFleetServices(
29
+ deps: FleetServicesDeps,
30
+ ): { processRegistry: ArchivableProcessRegistry } {
31
+ const { observeExternalAgents, providerRegistry, ...registryDeps } = deps;
32
+ const observedAgents = observeExternalAgents ? new ObservedAgentSource() : undefined;
33
+ const processRegistry = createArchivableFleetRegistry({
34
+ ...registryDeps,
35
+ observedAgents, // Daemon-side observed foreign-agent rows (undefined in the interactive process)
36
+ // Honest-unpriced through the ONE pricing resolver (manual -> registration -> provider-served
37
+ // -> catalog -> unknown); unknown/subscription yields null, never $0. Mirrors the SDK composition.
38
+ priceUsage: (model, usage) => (model ? computeUsageCostUsd(providerRegistry.resolveModelPricing(model), usage) : null),
39
+ });
40
+ return { processRegistry };
41
+ }