@pellux/goodvibes-tui 1.17.0 → 1.19.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 (104) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +5025 -1636
  4. package/package.json +7 -7
  5. package/src/cli/endpoints.ts +50 -4
  6. package/src/cli/help.ts +29 -2
  7. package/src/cli/management-commands.ts +99 -23
  8. package/src/cli/management.ts +0 -2
  9. package/src/cli/service-posture.ts +5 -3
  10. package/src/cli/status.ts +27 -6
  11. package/src/cli/surface-command.ts +29 -15
  12. package/src/core/consolidation-receipt.ts +41 -0
  13. package/src/core/conversation-rendering.ts +14 -5
  14. package/src/core/memory-diagnostics-gateway.ts +99 -0
  15. package/src/core/memory-provenance.ts +67 -0
  16. package/src/core/memory-status.ts +118 -0
  17. package/src/core/pairing-devices.ts +49 -0
  18. package/src/core/pairing-handoff.ts +101 -0
  19. package/src/core/pairing-offers.ts +83 -0
  20. package/src/core/pairing-origin.ts +78 -0
  21. package/src/core/pairing-tailscale-gateway.ts +56 -0
  22. package/src/core/power-chip-source.ts +91 -0
  23. package/src/core/power-status.ts +95 -0
  24. package/src/core/stable-host.ts +171 -0
  25. package/src/core/stream-event-wiring.ts +21 -0
  26. package/src/core/turn-budget-outcome.ts +60 -0
  27. package/src/core/turn-cancellation.ts +39 -0
  28. package/src/core/turn-event-wiring.ts +6 -0
  29. package/src/core/voice-provision-gateway.ts +202 -0
  30. package/src/core/voice-provision-status.ts +197 -0
  31. package/src/daemon/cli.ts +127 -60
  32. package/src/input/command-registry.ts +21 -0
  33. package/src/input/commands/acp-runtime.ts +72 -0
  34. package/src/input/commands/devices-runtime.ts +109 -0
  35. package/src/input/commands/experience-runtime.ts +25 -4
  36. package/src/input/commands/git-runtime.ts +28 -4
  37. package/src/input/commands/graph-runtime.ts +45 -0
  38. package/src/input/commands/health-runtime.ts +15 -1
  39. package/src/input/commands/operator-rpc.ts +32 -11
  40. package/src/input/commands/power-runtime.ts +56 -0
  41. package/src/input/commands/qrcode-runtime.ts +14 -19
  42. package/src/input/commands/queue-runtime.ts +70 -0
  43. package/src/input/commands/workstream-attempts.ts +6 -1
  44. package/src/input/commands/workstream-runtime.ts +44 -25
  45. package/src/input/commands/worktree-runtime.ts +35 -8
  46. package/src/input/commands.ts +10 -0
  47. package/src/input/config-modal-types.ts +11 -0
  48. package/src/input/config-modal.ts +65 -2
  49. package/src/input/handler-shortcuts.ts +23 -0
  50. package/src/input/keybindings.ts +19 -1
  51. package/src/input/settings-modal-data.ts +31 -37
  52. package/src/input/settings-modal-types.ts +5 -2
  53. package/src/input/tts-settings-actions.ts +27 -8
  54. package/src/input/voice-local-settings.ts +52 -0
  55. package/src/main.ts +42 -42
  56. package/src/panels/builtin/operations.ts +54 -1
  57. package/src/panels/builtin/shared.ts +12 -0
  58. package/src/panels/builtin-modals.ts +49 -32
  59. package/src/panels/diff-panel.ts +34 -1
  60. package/src/panels/fleet-acts.ts +446 -0
  61. package/src/panels/fleet-gateway.ts +117 -0
  62. package/src/panels/fleet-observed-render.ts +128 -0
  63. package/src/panels/fleet-panel-format.ts +120 -4
  64. package/src/panels/fleet-panel-worktree-detail.ts +14 -0
  65. package/src/panels/fleet-panel.ts +60 -44
  66. package/src/panels/fleet-read-model.ts +34 -3
  67. package/src/panels/fleet-spawn.ts +266 -0
  68. package/src/panels/fleet-stop.ts +38 -2
  69. package/src/panels/fleet-tabs.ts +2 -2
  70. package/src/panels/memory-consolidation-gateway.ts +94 -0
  71. package/src/panels/modals/devices-modal.ts +161 -0
  72. package/src/panels/modals/memory-modal.ts +149 -5
  73. package/src/panels/modals/pairing-modal.ts +198 -68
  74. package/src/panels/modals/security-modal.ts +6 -0
  75. package/src/panels/workstream-graph-render.ts +123 -0
  76. package/src/renderer/composer-fragments.ts +69 -0
  77. package/src/renderer/settings-modal-helpers.ts +9 -0
  78. package/src/renderer/settings-modal.ts +4 -2
  79. package/src/renderer/shell-surface.ts +3 -0
  80. package/src/renderer/tool-call.ts +12 -6
  81. package/src/renderer/ui-factory.ts +32 -0
  82. package/src/runtime/bootstrap-command-context.ts +3 -1
  83. package/src/runtime/bootstrap-command-parts.ts +3 -1
  84. package/src/runtime/bootstrap-core.ts +6 -6
  85. package/src/runtime/bootstrap-shell.ts +7 -0
  86. package/src/runtime/code-index-services.ts +15 -1
  87. package/src/runtime/durability-services.ts +36 -1
  88. package/src/runtime/fleet-services.ts +41 -0
  89. package/src/runtime/idle-power-services.ts +85 -0
  90. package/src/runtime/index.ts +2 -0
  91. package/src/runtime/interaction-seams.ts +109 -0
  92. package/src/runtime/knowledge-services.ts +94 -0
  93. package/src/runtime/legacy-daemon-migration.ts +38 -6
  94. package/src/runtime/legacy-daemon-reconcile.ts +439 -0
  95. package/src/runtime/memory-governance-services.ts +79 -0
  96. package/src/runtime/notification-dispatch.ts +28 -0
  97. package/src/runtime/power-keepawake-remote.ts +75 -0
  98. package/src/runtime/process-lifecycle.ts +3 -1
  99. package/src/runtime/services.ts +105 -107
  100. package/src/runtime/ui-services.ts +3 -0
  101. package/src/runtime/voice-setup-services.ts +104 -0
  102. package/src/shell/blocking-input.ts +21 -141
  103. package/src/shell/recovery-input-helpers.ts +53 -166
  104. package/src/version.ts +11 -2
@@ -0,0 +1,70 @@
1
+ import type { CommandRegistry } from '../command-registry.ts';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // /queue — the mid-turn message queue as an editable list.
5
+ //
6
+ // Messages typed while a turn is streaming are QUEUED (they deliver after the
7
+ // turn). Until a message is delivered it stays editable and deletable via the
8
+ // SDK's editQueuedMessage / deleteQueuedMessage verbs; a delivered message has
9
+ // left the queue and is no longer listed — delivery is immutability. This
10
+ // command is the editable interface over the same list the composer renders.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export function registerQueueRuntimeCommands(registry: CommandRegistry): void {
14
+ registry.register({
15
+ name: 'queue',
16
+ aliases: ['q'],
17
+ description: 'Mid-turn message queue — list, edit, or delete still-undelivered messages',
18
+ usage: '[list | edit <n> <text> | delete <n>]',
19
+ argsHint: '[list|edit <n> <text>|delete <n>]',
20
+ handler(args, ctx) {
21
+ const list = ctx.listQueuedMessages?.() ?? [];
22
+ const sub = (args[0] ?? 'list').toLowerCase();
23
+
24
+ if (sub === 'list' || args.length === 0) {
25
+ if (list.length === 0) {
26
+ ctx.print('No queued messages. (Messages typed while a turn is streaming queue here until it finishes.)');
27
+ return;
28
+ }
29
+ const lines = [`Queued messages (${list.length}) — editable until delivered:`];
30
+ list.forEach((m, i) => lines.push(` ${i + 1}. ${m.text}`));
31
+ lines.push('Use /queue edit <n> <new text> or /queue delete <n>.');
32
+ ctx.print(lines.join('\n'));
33
+ return;
34
+ }
35
+
36
+ // Both edit and delete address a message by its 1-based list position.
37
+ const n = Number.parseInt(args[1] ?? '', 10);
38
+ if (!Number.isInteger(n) || n < 1 || n > list.length) {
39
+ ctx.print(list.length === 0
40
+ ? 'No queued messages to act on.'
41
+ : `No queued message #${args[1] ?? ''}. Valid range: 1..${list.length}. Run /queue to list them.`);
42
+ return;
43
+ }
44
+ const target = list[n - 1]!;
45
+
46
+ if (sub === 'delete') {
47
+ const ok = ctx.deleteQueuedMessage?.(target.id) ?? false;
48
+ ctx.print(ok
49
+ ? `Deleted queued message #${n}.`
50
+ : `Could not delete message #${n} — it was already delivered (delivered messages are immutable).`);
51
+ return;
52
+ }
53
+
54
+ if (sub === 'edit') {
55
+ const text = args.slice(2).join(' ');
56
+ if (!text.trim()) {
57
+ ctx.print('Usage: /queue edit <n> <new text>. Provide the replacement text.');
58
+ return;
59
+ }
60
+ const ok = ctx.editQueuedMessage?.(target.id, text) ?? false;
61
+ ctx.print(ok
62
+ ? `Edited queued message #${n}.`
63
+ : `Could not edit message #${n} — it was already delivered (delivered messages are immutable).`);
64
+ return;
65
+ }
66
+
67
+ ctx.print('Usage: /queue [list | edit <n> <text> | delete <n>]');
68
+ },
69
+ });
70
+ }
@@ -25,6 +25,7 @@ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
25
25
  import type { CommandContext } from '../command-registry.ts';
26
26
  import type { WorkstreamCommandService } from '../../runtime/workstream-services.ts';
27
27
  import { requirePanelManager } from './runtime-services.ts';
28
+ import { describeFailureReason, isTurnBudgetReason } from '../../core/turn-budget-outcome.ts';
28
29
  import { type AttemptGraphItem, renderDependentHoldLines } from './workstream-attempt-dependents.ts';
29
30
 
30
31
  /** Resolve a workstream's items for dependent-hold display; null-safe against a stubbed engine. */
@@ -58,7 +59,11 @@ function resolveCandidate(group: HeldMergeGroup, ref: string): AttemptCandidate
58
59
 
59
60
  function candidateLine(c: AttemptCandidate): string {
60
61
  const files = c.diff ? `${c.diff.files.length} file(s)` : 'no diff';
61
- const state = c.state === 'held-merge' ? 'held (pick-ready)' : `failed${c.failureReason ? `: ${c.failureReason}` : ''}`;
62
+ const state = c.state === 'held-merge'
63
+ ? 'held (pick-ready)'
64
+ : isTurnBudgetReason(c.failureReason)
65
+ ? describeFailureReason(c.failureReason) // a spent budget, not an infrastructure failure
66
+ : `failed${c.failureReason ? `: ${c.failureReason}` : ''}`;
62
67
  return ` ${c.attemptIndex + 1}. [${state}] ${c.title} — ${files}, item ${shortId(c.itemId)}`;
63
68
  }
64
69
 
@@ -102,6 +102,33 @@ async function checkFanoutQuotaWarning(ctx: CommandContext, draft: WorkstreamDra
102
102
  ].join('\n');
103
103
  }
104
104
 
105
+ /**
106
+ * Launch an already-approved draft through its gates (best-of-N attempt
107
+ * validation, then the fan-out quota warning unless `--force`), and print the
108
+ * outcome. Shared by `launch` and by `approve` (which launches in the same act
109
+ * — the one confirmed step that replaces the old approve-then-retype ceremony).
110
+ */
111
+ async function launchApprovedDraft(ctx: CommandContext, service: WorkstreamCommandService, id: string, draft: WorkstreamDraft, force: boolean): Promise<void> {
112
+ const attemptsCheck = validateAttempts(draft.spec);
113
+ if (attemptsCheck.violations.length > 0) {
114
+ ctx.print(`Cannot launch ${id} — best-of-N plan constraints are violated:\n${attemptsCheck.violations.map((v) => ` - ${v}`).join('\n')}\nFix the plan (or drop the attempts) and re-approve.`);
115
+ return;
116
+ }
117
+ if (!force) {
118
+ const quotaWarning = await checkFanoutQuotaWarning(ctx, draft);
119
+ if (quotaWarning) {
120
+ ctx.print(quotaWarning);
121
+ return;
122
+ }
123
+ }
124
+ const result = service.launchDraft(id);
125
+ if (!result) {
126
+ ctx.print(`Could not launch ${id}.`);
127
+ return;
128
+ }
129
+ ctx.print(`Launched workstream ${result.workstreamId} — track it with /workstream status ${result.workstreamId} or the Fleet panel.`);
130
+ }
131
+
105
132
  /**
106
133
  * phase.role for a 'custom' phase is the free-text description passed to
107
134
  * `/workstream insert-phase` — it is purely COSMETIC. Neither
@@ -377,7 +404,7 @@ export function registerWorkstreamRuntimeCommands(registry: CommandRegistry): vo
377
404
  registry.register({
378
405
  name: 'workstream',
379
406
  description: 'Author and oversee multi-phase agent workstreams (orchestration engine)',
380
- usage: 'create [--isolation shared|worktree] <task...> | list | status [id] | insert-phase <id> <description...> | edit-item <id> <item#> <brief...> | remove-item <id> <item#> | move-item <id> <item#> <pos> | approve <id> | edit <id> [--isolation shared|worktree] <task...> | launch <id> [--force] | cancel <id> | attempts list|diff|judge|pick',
407
+ usage: 'create [--isolation shared|worktree] <task...> | list | status [id] | insert-phase <id> <description...> | edit-item <id> <item#> <brief...> | remove-item <id> <item#> | move-item <id> <item#> <pos> | approve <id> [--no-launch] [--force] | edit <id> [--isolation shared|worktree] <task...> | launch <id> [--force] | cancel <id> | attempts list|diff|judge|pick',
381
408
  argsHint: 'create [--isolation worktree] <task> | list | status [id] | edit-item | remove-item | move-item | approve | edit | launch | cancel | attempts',
382
409
  handler: async (args, ctx: CommandContext) => {
383
410
  const service = ctx.session.workstreamEngine;
@@ -470,9 +497,14 @@ export function registerWorkstreamRuntimeCommands(registry: CommandRegistry): vo
470
497
  }
471
498
 
472
499
  if (sub === 'approve') {
473
- const id = args[1];
500
+ // Approve IS the one confirmed act: it approves AND launches (typing the
501
+ // verb with an explicit id is the confirmation). --no-launch keeps the
502
+ // old split for scripts; --force skips the fan-out quota warning.
503
+ const noLaunch = args.includes('--no-launch');
504
+ const force = args.includes('--force');
505
+ const id = args.find((arg, index) => index > 0 && arg !== '--no-launch' && arg !== '--force');
474
506
  if (!id) {
475
- ctx.print('Usage: /workstream approve <id>');
507
+ ctx.print('Usage: /workstream approve <id> [--no-launch] [--force]');
476
508
  return;
477
509
  }
478
510
  const draft = service.approveDraft(id);
@@ -480,7 +512,11 @@ export function registerWorkstreamRuntimeCommands(registry: CommandRegistry): vo
480
512
  ctx.print(draftNotFoundMessage(service, id));
481
513
  return;
482
514
  }
483
- ctx.print(`Approved ${id}. Launch with: /workstream launch ${id}`);
515
+ if (noLaunch) {
516
+ ctx.print(`Approved ${id} (held, not launched). Launch it with /workstream launch ${id}.`);
517
+ return;
518
+ }
519
+ await launchApprovedDraft(ctx, service, id, draft, force);
484
520
  return;
485
521
  }
486
522
 
@@ -554,27 +590,10 @@ export function registerWorkstreamRuntimeCommands(registry: CommandRegistry): vo
554
590
  return;
555
591
  }
556
592
  if (!draft.approved) {
557
- ctx.print(`Proposal ${id} is not approved yet. Run /workstream approve ${id} first.`);
558
- return;
559
- }
560
- const attemptsCheck = validateAttempts(draft.spec);
561
- if (attemptsCheck.violations.length > 0) {
562
- ctx.print(`Cannot launch ${id} — best-of-N plan constraints are violated:\n${attemptsCheck.violations.map((v) => ` - ${v}`).join('\n')}\nFix the plan (or drop the attempts) and re-approve.`);
563
- return;
564
- }
565
- if (!force) {
566
- const quotaWarning = await checkFanoutQuotaWarning(ctx, draft);
567
- if (quotaWarning) {
568
- ctx.print(quotaWarning);
569
- return;
570
- }
571
- }
572
- const result = service.launchDraft(id);
573
- if (!result) {
574
- ctx.print(`Could not launch ${id}.`);
593
+ ctx.print(`Proposal ${id} is not approved yet. Run /workstream approve ${id} first (or approve launches it directly).`);
575
594
  return;
576
595
  }
577
- ctx.print(`Launched workstream ${result.workstreamId} — track it with /workstream status ${result.workstreamId} or the Fleet panel.`);
596
+ await launchApprovedDraft(ctx, service, id, draft, force);
578
597
  return;
579
598
  }
580
599
 
@@ -616,9 +635,9 @@ export function registerWorkstreamRuntimeCommands(registry: CommandRegistry): vo
616
635
  + ' /workstream edit-item <id> <item#> <new brief...>\n'
617
636
  + ' /workstream remove-item <id> <item#>\n'
618
637
  + ' /workstream move-item <id> <item#> <new-position#>\n'
619
- + ' /workstream approve <id>\n'
638
+ + ' /workstream approve <id> [--no-launch] [--force] (approves AND launches in one act)\n'
620
639
  + ' /workstream edit <id> [--isolation shared|worktree] <new task...>\n'
621
- + ' /workstream launch <id> [--force]\n'
640
+ + ' /workstream launch <id> [--force] (for a --no-launch-held or re-launched draft)\n'
622
641
  + ' /workstream cancel <id>',
623
642
  );
624
643
  },
@@ -145,19 +145,46 @@ export function registerWorktreeRuntimeCommands(registry: CommandRegistry): void
145
145
  : `${header}: ${targetKind} ${targetId}\n No attached worktrees tracked.`);
146
146
  return;
147
147
  }
148
- if (sub === 'pause' || sub === 'resume' || sub === 'keep' || sub === 'discard') {
148
+ if (sub === 'discard') {
149
+ // Discard is a real destructive act, not a metadata flip: route it
150
+ // through the worktrees.discard operator verb (preservation commit,
151
+ // git worktree removal, honest receipt) — the SAME behavior as the
152
+ // Fleet panel's D key. Typing the verb with an explicit path is the
153
+ // confirmation.
154
+ const path = args[1];
155
+ if (!path) {
156
+ ctx.print('Usage: /worktree discard <path>');
157
+ return;
158
+ }
159
+ const rpc = getOperatorRpc(ctx);
160
+ if (!rpc.available) {
161
+ ctx.print(`[worktree discard] ${rpc.reason}`);
162
+ return;
163
+ }
164
+ try {
165
+ const receipt = await rpc.sdk.operator.invoke('worktrees.discard', { path });
166
+ if (receipt.ok) {
167
+ ctx.print([
168
+ `[worktree discard] Discarded ${receipt.path}`,
169
+ ` branch kept: ${receipt.branch || '(unknown)'}`,
170
+ ` preservation commit: ${receipt.preservedCommit || '(none — nothing to preserve)'}`,
171
+ ` ${receipt.detail}`,
172
+ ].join('\n'));
173
+ } else {
174
+ ctx.print(`[worktree discard] refused for ${receipt.path}: ${receipt.detail}`);
175
+ }
176
+ } catch (error) {
177
+ ctx.print(`[worktree discard] round-trip request failed: ${describeOperatorRpcError(error)}`);
178
+ }
179
+ return;
180
+ }
181
+ if (sub === 'pause' || sub === 'resume' || sub === 'keep') {
149
182
  const path = args[1];
150
183
  if (!path) {
151
184
  ctx.print(`Usage: /worktree ${sub} <path>`);
152
185
  return;
153
186
  }
154
- const nextState = sub === 'resume'
155
- ? 'active'
156
- : sub === 'pause'
157
- ? 'paused'
158
- : sub === 'keep'
159
- ? 'kept'
160
- : 'discard';
187
+ const nextState = sub === 'resume' ? 'active' : sub === 'pause' ? 'paused' : 'kept';
161
188
  runtime.setState(path, nextState);
162
189
  ctx.print(`Updated ${path} to state ${nextState}.`);
163
190
  return;
@@ -17,6 +17,9 @@ import { registerOperatorRuntimeCommands } from './commands/operator-runtime.ts'
17
17
  import { registerPluginRuntimeCommands } from './commands/plugin-runtime.ts';
18
18
  import { registerDiffRuntimeCommands } from './commands/diff-runtime.ts';
19
19
  import { registerGitRuntimeCommands } from './commands/git-runtime.ts';
20
+ import { registerQueueRuntimeCommands } from './commands/queue-runtime.ts';
21
+ import { registerPowerRuntimeCommands } from './commands/power-runtime.ts';
22
+ import { registerGraphRuntimeCommands } from './commands/graph-runtime.ts';
20
23
  import { registerTestRuntimeCommands } from './commands/test-runtime.ts';
21
24
  import { registerNotifyRuntimeCommands } from './commands/notify-runtime.ts';
22
25
  import { registerReplayRuntimeCommands } from './commands/replay-runtime.ts';
@@ -55,11 +58,13 @@ import { registerLocalProviderRuntimeCommands } from './commands/local-provider-
55
58
  import { registerHealthRuntimeCommands } from './commands/health-runtime.ts';
56
59
  import { registerSettingsSyncRuntimeCommands } from './commands/settings-sync-runtime.ts';
57
60
  import { registerWorktreeRuntimeCommands } from './commands/worktree-runtime.ts';
61
+ import { registerAcpRuntimeCommands } from './commands/acp-runtime.ts';
58
62
  import { registerProviderAccountsRuntimeCommands } from './commands/provider-accounts-runtime.ts';
59
63
  import { registerLocalAuthRuntimeCommands } from './commands/local-auth-runtime.ts';
60
64
  import { registerIntelligenceRuntimeCommands } from './commands/intelligence-runtime.ts';
61
65
  import { registerConversationRuntimeCommands } from './commands/conversation-runtime.ts';
62
66
  import { registerQrcodeRuntimeCommands } from './commands/qrcode-runtime.ts';
67
+ import { registerDevicesRuntimeCommands } from './commands/devices-runtime.ts';
63
68
  import { registerRelayRuntimeCommands } from './commands/relay-runtime.ts';
64
69
  import { registerOnboardingRuntimeCommands } from './commands/onboarding-runtime.ts';
65
70
  import { registerTtsRuntimeCommands } from './commands/tts-runtime.ts';
@@ -101,6 +106,9 @@ export const BUILTIN_COMMAND_GROUPS: readonly BuiltinCommandGroup[] = [
101
106
  { category: 'Plugins', register: registerPluginRuntimeCommands },
102
107
  { category: 'Diff & Review', register: registerDiffRuntimeCommands },
103
108
  { category: 'Git', register: registerGitRuntimeCommands },
109
+ { category: 'Shell & Session', register: registerQueueRuntimeCommands },
110
+ { category: 'Shell & Session', register: registerPowerRuntimeCommands },
111
+ { category: 'Automation', register: registerGraphRuntimeCommands },
104
112
  { category: 'Testing', register: registerTestRuntimeCommands },
105
113
  { category: 'Notifications', register: registerNotifyRuntimeCommands },
106
114
  { category: 'Sessions & Replay', register: registerReplayRuntimeCommands },
@@ -133,11 +141,13 @@ export const BUILTIN_COMMAND_GROUPS: readonly BuiltinCommandGroup[] = [
133
141
  { category: 'Health', register: registerHealthRuntimeCommands },
134
142
  { category: 'Settings Sync', register: registerSettingsSyncRuntimeCommands },
135
143
  { category: 'Worktrees', register: registerWorktreeRuntimeCommands },
144
+ { category: 'Fleet', register: registerAcpRuntimeCommands },
136
145
  { category: 'Provider Accounts', register: registerProviderAccountsRuntimeCommands },
137
146
  { category: 'Local Auth', register: registerLocalAuthRuntimeCommands },
138
147
  { category: 'Intelligence', register: registerIntelligenceRuntimeCommands },
139
148
  { category: 'Conversation', register: registerConversationRuntimeCommands },
140
149
  { category: 'QR Codes', register: registerQrcodeRuntimeCommands },
150
+ { category: 'Security', register: registerDevicesRuntimeCommands },
141
151
  { category: 'Relay', register: registerRelayRuntimeCommands },
142
152
  { category: 'Onboarding', register: registerOnboardingRuntimeCommands },
143
153
  { category: 'Voice & TTS', register: registerTtsRuntimeCommands },
@@ -106,6 +106,17 @@ export interface ConfigModalActionContext {
106
106
  readonly setStatus: (message: string) => void;
107
107
  /** Close the modal (focus returns to whatever opened it). */
108
108
  readonly close: () => void;
109
+ /**
110
+ * Switch the active tab and select a row within it, by id — the in-surface
111
+ * "jump" affordance (e.g. the Memory modal's Proposals tab jumping to an
112
+ * affected record in the Review Queue tab, rather than opening a second
113
+ * modal). Host-owned, populated by ConfigModal itself (like `close`/
114
+ * `setStatus`) — a surface's `onAction` calls it, never constructs it. A
115
+ * no-op if either id is not present in the surface's current structure; the
116
+ * surface should check reachability itself and print an honest status line
117
+ * when it isn't, rather than relying on a silent no-op.
118
+ */
119
+ readonly jumpToRow?: (tabId: string, rowId: string) => void;
109
120
  }
110
121
 
111
122
  /** A declarative action bound to a key. */
@@ -339,7 +339,7 @@ export class ConfigModal {
339
339
  * action (consumed). Handles the two-press confirm for destructive actions.
340
340
  * This is an interaction boundary (syncs structure first).
341
341
  */
342
- fireAction(key: string, ctx: Omit<ConfigModalActionContext, 'row' | 'tabId' | 'setStatus' | 'close' | 'requestRender'>): boolean {
342
+ fireAction(key: string, ctx: Omit<ConfigModalActionContext, 'row' | 'tabId' | 'setStatus' | 'close' | 'requestRender' | 'jumpToRow'>): boolean {
343
343
  this.syncStructure();
344
344
  const action = this.resolveAction(key);
345
345
  if (!action) return false;
@@ -359,11 +359,36 @@ export class ConfigModal {
359
359
  requestRender: this.requestRender,
360
360
  setStatus: (m: string) => { this.statusMessage = m; },
361
361
  close: () => this.close(),
362
+ jumpToRow: (tabId: string, rowId: string) => this.jumpToRow(tabId, rowId),
362
363
  };
363
364
  this.surface?.onAction?.(action.id, fullCtx);
364
365
  return true;
365
366
  }
366
367
 
368
+ /**
369
+ * In-surface "jump": switch the active tab (by id) and select a specific
370
+ * row (by id) within it — the mechanism a surface's own action handler
371
+ * uses to move the user from one tab to a specific row in another (e.g.
372
+ * the Memory modal's Proposals tab jumping to an affected record in the
373
+ * Review Queue tab). An interaction boundary (re-syncs structure first,
374
+ * same as `_switchTab`). A no-op if either id is absent from the
375
+ * freshly-synced structure.
376
+ */
377
+ jumpToRow(tabId: string, rowId: string): void {
378
+ this._clearConfirm();
379
+ this.filterActive = false;
380
+ this.filterQuery = '';
381
+ this.syncStructure();
382
+ const tab = this.frozenView?.tabs.find((t) => t.id === tabId);
383
+ if (!tab) return;
384
+ if (!tab.rows.some((r) => r.id === rowId && r.selectable !== false)) return;
385
+ this.activeTabId = tabId;
386
+ this.selectedRowId = rowId;
387
+ this.scrollOffset = 0;
388
+ this.statusMessage = '';
389
+ this._clampScroll();
390
+ }
391
+
367
392
  /** Clear a pending confirm when the user navigates or presses an unrelated key. */
368
393
  clearConfirmOnMiss(): void {
369
394
  this._clearConfirm();
@@ -442,7 +467,7 @@ export class ConfigModal {
442
467
  });
443
468
 
444
469
  const visible = this.visibleRows;
445
- const windowed = allRows.slice(this.scrollOffset, this.scrollOffset + visible);
470
+ const windowed = this._windowRowsByLineBudget(allRows, this.scrollOffset, visible, labelWrapWidth);
446
471
 
447
472
  // item 1: while filtering, the surface's own action/tab hints are
448
473
  // unreliable (their printable-letter keys are captured by the filter
@@ -505,6 +530,44 @@ export class ConfigModal {
505
530
  return clamped.join('\n');
506
531
  }
507
532
 
533
+ /**
534
+ * Modal sizing rule (owner, zero tolerance: a modal/list must never clip
535
+ * its full descriptive text — size to content or scroll, never clip).
536
+ * `scrollOffset`/`visible` are ROW-count based, which is exactly right when
537
+ * every row wraps to one line — but ModalFactory renders each row's WRAPPED
538
+ * lines and then bounds the whole section to a fixed content-row budget
539
+ * computed from `visible` (see renderConfigModal's targetContentRows). A
540
+ * row whose label wraps to MULTIPLE lines can therefore push the total past
541
+ * that budget, and the LAST row handed to the renderer gets cut off
542
+ * mid-line instead of being deferred to the next scroll page — the
543
+ * clipping hazard this closes.
544
+ *
545
+ * Windows `rows` starting at `scrollOffset`, greedily including whole rows
546
+ * while their CUMULATIVE wrapped-line count (at `wrapWidth`) stays within
547
+ * the `visible` line budget. Always includes at least the first candidate
548
+ * row so the view is never empty — a single row that alone exceeds the
549
+ * budget is shown in full rather than replaced with nothing; only a
550
+ * SUBSEQUENT row that would overflow is deferred to scrolling instead of
551
+ * being rendered partially.
552
+ */
553
+ private _windowRowsByLineBudget(
554
+ rows: readonly ConfigModalRenderRow[],
555
+ scrollOffset: number,
556
+ visible: number,
557
+ wrapWidth: number,
558
+ ): ConfigModalRenderRow[] {
559
+ const candidates = rows.slice(scrollOffset);
560
+ const out: ConfigModalRenderRow[] = [];
561
+ let usedLines = 0;
562
+ for (const row of candidates) {
563
+ const lineCount = Math.max(1, wrapText(row.label, wrapWidth).length);
564
+ if (out.length > 0 && usedLines + lineCount > visible) break;
565
+ out.push(row);
566
+ usedLines += lineCount;
567
+ }
568
+ return out;
569
+ }
570
+
508
571
  /** Footer text for the active filter: the query + a truthful match count. */
509
572
  private _filterStatusHint(matched: number, total: number): string {
510
573
  const q = this.filterQuery;
@@ -119,6 +119,29 @@ export function handleGlobalShortcutToken(
119
119
  state.handleCtrlC();
120
120
  return true;
121
121
 
122
+ case 'cancel-tool-call':
123
+ // Cancel JUST the currently-running tool call (the live transcript row).
124
+ // No-op (still consumed) when nothing is running — the composer keeps the
125
+ // keystroke rather than letting it fall through as text.
126
+ state.commandContext?.cancelToolCall?.();
127
+ return true;
128
+
129
+ case 'toggle-memory-provenance':
130
+ // Expand/collapse the provenance chip's drill-in. No-op when the chip is
131
+ // not showing; still consumed so the composer never sees a stray Alt+M.
132
+ state.commandContext?.toggleMemoryProvenance?.();
133
+ return true;
134
+
135
+ case 'toggle-keep-awake': {
136
+ // Flip the daemon-held keep-awake toggle; the always-visible "sleep
137
+ // disabled" chip is the confirmation. Fire-and-forget (the seam renders on
138
+ // completion); errors are swallowed so a keystroke never throws.
139
+ const power = state.commandContext;
140
+ const current = power?.getPowerState?.().keepAwake ?? false;
141
+ void power?.setKeepAwake?.(!current);
142
+ return true;
143
+ }
144
+
122
145
  case 'screen-clear':
123
146
  state.commandContext?.clearScreen?.();
124
147
  return true;
@@ -68,7 +68,10 @@ export type KeyAction =
68
68
  | 'kill-to-start'
69
69
  | 'kill-word-forward'
70
70
  | 'yank'
71
- | 'yank-pop';
71
+ | 'yank-pop'
72
+ | 'cancel-tool-call'
73
+ | 'toggle-keep-awake'
74
+ | 'toggle-memory-provenance';
72
75
 
73
76
  /** Human-readable description for each action (used in /keybindings display). */
74
77
  export const ACTION_DESCRIPTIONS: Record<KeyAction, string> = {
@@ -111,6 +114,9 @@ export const ACTION_DESCRIPTIONS: Record<KeyAction, string> = {
111
114
  'kill-word-forward': 'Kill word forward into kill ring (Alt+D)',
112
115
  'yank': 'Yank (paste) from kill ring (Ctrl+Shift+Y)',
113
116
  'yank-pop': 'Rotate kill ring and yank next entry (Alt+Y)',
117
+ 'cancel-tool-call': 'Cancel the running tool call (the turn continues)',
118
+ 'toggle-keep-awake': 'Toggle keep-awake (the "sleep disabled" chip)',
119
+ 'toggle-memory-provenance': 'List/hide the memories a turn used (provenance chip drill-in)',
114
120
  };
115
121
 
116
122
  /** Default key bindings for all actions. */
@@ -189,6 +195,18 @@ export const DEFAULT_KEYBINDINGS: Record<KeyAction, KeyCombo[]> = {
189
195
  'yank': [{ key: 'y', ctrl: true, shift: true }],
190
196
  // Alt+Y: yank-pop (rotate ring after yank)
191
197
  'yank-pop': [{ key: 'y', alt: true }],
198
+ // Alt+C: cancel the currently-running tool call (the turn continues). Alt+C
199
+ // is unused by any other default and representable by the tokenizer's
200
+ // { key, alt } combo form. Ctrl+C stays whole-input clear/cancel; this is the
201
+ // narrower per-tool stop.
202
+ 'cancel-tool-call': [{ key: 'c', alt: true }],
203
+ // Alt+A: toggle keep-awake (the one-key affordance for the "sleep disabled"
204
+ // chip). Alt+A is unused by any other default. The always-visible chip is the
205
+ // confirmation of the toggle's state.
206
+ 'toggle-keep-awake': [{ key: 'a', alt: true }],
207
+ // Alt+M: expand/collapse the "used N memories" provenance chip's drill-in.
208
+ // Alt+M is unused by any other default. A no-op when the chip is not showing.
209
+ 'toggle-memory-provenance': [{ key: 'm', alt: true }],
192
210
  };
193
211
 
194
212
  /** Resolved overrides type: each key can be a single combo or array. */
@@ -25,6 +25,7 @@ import {
25
25
  } from './worktree-setup-config.ts';
26
26
  import { BUDGET_ALERT_USD_CONFIG_KEY, BUDGET_ALERT_USD_DEFAULT, readBudgetAlertUsd } from '../export/cost-utils.ts';
27
27
  import { MEMORY_PROJECTION_DIR_CONFIG_KEY, buildMemoryProjectionDirSyntheticEntry } from './commands/recall-files-config.ts';
28
+ import { MEMORY_SHOW_PROVENANCE_CONFIG_KEY, buildMemoryProvenanceSyntheticEntry } from '../core/memory-provenance.ts';
28
29
  import {
29
30
  injectSandboxExecSyntheticEntries,
30
31
  isSandboxExecListConfigKey,
@@ -120,11 +121,11 @@ export function buildSettingGroups(
120
121
  if ((rawCat === 'controlPlane' || rawCat === 'httpListener' || rawCat === 'web') && groups.has('network')) {
121
122
  groups.get('network')!.push(entry);
122
123
  }
124
+ // fleet.maxSize (was orchestration.maxActiveAgents) surfaces under orchestration.
125
+ if (rawCat === 'fleet' && groups.has('orchestration')) groups.get('orchestration')!.push(entry);
123
126
  }
124
127
 
125
- // inject the synthetic display.themeMode enum (auto|dark|light). TUI-local
126
- // key stored under the existing `display` section (see theme-mode-config.ts for why
127
- // not `appearance`), same rationale as the other synthetic settings below.
128
+ // Synthetic display.themeMode enum (auto|dark|light) — TUI-local, see theme-mode-config.ts.
128
129
  const displayEntries = groups.get('display');
129
130
  if (displayEntries && !displayEntries.some((e) => e.setting.key === (THEME_MODE_CONFIG_KEY as ConfigKey))) {
130
131
  displayEntries.push(buildThemeModeSyntheticEntry(configManager));
@@ -151,26 +152,19 @@ export function buildSettingGroups(
151
152
  }
152
153
  }
153
154
 
154
- // Inject the synthetic tts.speed entry into the tts category.
155
- // tts.speed is not yet a ConfigKey in the SDK schema (pending SDK addition).
156
- // The entry is surfaced here with an honest description caveat so users can
157
- // see and understand the setting before the SDK schema catches up.
155
+ // Synthetic tts.speed (not yet an SDK ConfigKey) — surfaced with an honest caveat.
158
156
  if (ttsEntries && !ttsEntries.some((e) => e.setting.key === ('tts.speed' as ConfigKey))) {
159
157
  ttsEntries.push(buildTtsSpeedSyntheticEntry(configManager));
160
158
  }
161
159
 
162
- // Inject the synthetic behavior.notifyAfterSeconds entry into the behavior
163
- // category. This key is TUI-local (not in the SDK ConfigKey union) and
164
- // controls the long-task push notification threshold.
160
+ // Synthetic behavior.notifyAfterSeconds (TUI-local): long-task push threshold.
165
161
  const behaviorEntries = groups.get('behavior');
166
162
  if (behaviorEntries && !behaviorEntries.some((e) => e.setting.key === ('behavior.notifyAfterSeconds' as ConfigKey))) {
167
163
  behaviorEntries.push(buildNotifyAfterSecondsSyntheticEntry(configManager));
168
164
  }
169
165
 
170
- // Inject the synthetic behavior.budgetAlertUsd entry into the behavior
171
- // category. This key is TUI-local (not in the SDK ConfigKey union), which
172
- // previously left it with no schema-driven inspection surface at all: it
173
- // never appeared in /config, and /settings-sync show rejects any key not
166
+ // Synthetic behavior.budgetAlertUsd (TUI-local): no schema surface previously,
167
+ // never in /config; /settings-sync show rejects any key not
174
168
  // in CONFIG_KEYS. The Cost panel's 'b' key and /cost budget <usd> remain
175
169
  // the primary way to change it; this entry makes the current effective
176
170
  // value (and whether it's still the "no budget configured" default)
@@ -212,6 +206,12 @@ export function buildSettingGroups(
212
206
  storageEntries.push(buildMemoryProjectionDirSyntheticEntry(configManager));
213
207
  }
214
208
 
209
+ // Synthetic memory.showProvenance (TUI-local, default OFF — see memory-provenance.ts).
210
+ const memoryEntries = groups.get('memory');
211
+ if (memoryEntries && !memoryEntries.some((e) => e.setting.key === (MEMORY_SHOW_PROVENANCE_CONFIG_KEY as ConfigKey))) {
212
+ memoryEntries.push(buildMemoryProvenanceSyntheticEntry(configManager) as unknown as SettingEntry);
213
+ }
214
+
215
215
  // Inject the worktree.setup.commands / worktree.setup.carryOverGlobs
216
216
  // synthetic entries into the orchestration category (worktree isolation is
217
217
  // an orchestration-engine concern — see WorkstreamIsolation 'worktree').
@@ -607,44 +607,34 @@ export function refreshEntryValues(
607
607
  ): void {
608
608
  for (const entries of groups.values()) {
609
609
  for (const entry of entries) {
610
- // The worktree.setup.* keys read through a defensive helper. The SDK now
611
- // registers the 'worktree' section in DEFAULT_CONFIG, so a plain
612
- // configManager.get returns the empty default rather than throwing; the
613
- // helper's guard (belt-and-suspenders for an older SDK) keeps this
614
- // refresh from crashing for EVERY setting change if the section is ever
615
- // absent (see worktree-setup-config.ts).
610
+ // worktree.setup.* read through a defensive array-normalizing helper (see worktree-setup-config.ts).
616
611
  if (isWorktreeSetupListConfigKey(entry.setting.key)) {
617
612
  entry.currentValue = readWorktreeSetupList(configManager, entry.setting.key);
618
613
  entry.isDefault = (entry.currentValue as string[]).length === 0;
619
614
  continue;
620
615
  }
621
- // sandbox.egressAllowlist / sandbox.workspaceWritable: 'sandbox' IS a real
622
- // DEFAULT_CONFIG section (has been since before this repack, backing the
623
- // VM/REPL isolation settings), so a plain get() would not throw — but an
624
- // unset leaf still reads back as `undefined`, not `[]`, so this still needs
625
- // the same array-normalizing read as the worktree keys above (see
626
- // sandbox-exec-config.ts).
616
+ // sandbox.* list leaves normalize an unset undefined to [] (see sandbox-exec-config.ts).
627
617
  if (isSandboxExecListConfigKey(entry.setting.key)) {
628
618
  entry.currentValue = readSandboxExecList(configManager, entry.setting.key);
629
619
  entry.isDefault = (entry.currentValue as string[]).length === 0;
630
620
  continue;
631
621
  }
632
- // permissions.execEnvScrubAllowlist: same normalizing-read trap as above —
633
- // 'permissions' is a real section but this leaf is TUI-local (see
634
- // exec-env-scrub-config.ts).
622
+ // permissions.execEnvScrubAllowlist: same array-normalizing read (see exec-env-scrub-config.ts).
635
623
  if (isExecEnvScrubAllowlistConfigKey(entry.setting.key)) {
636
624
  entry.currentValue = readExecEnvScrubAllowlist(configManager);
637
625
  entry.isDefault = (entry.currentValue as string[]).length === 0;
638
626
  continue;
639
627
  }
640
- // 'learning' is now a real DEFAULT_CONFIG section (schema-domain-learning.ts),
641
- // so learning.consolidation.* keys read through the plain path below like any
642
- // other schema-driven setting — no special case needed anymore.
643
- // Same trap still applies here: 'memory' has no DEFAULT_CONFIG entry.
628
+ // 'memory' has no DEFAULT_CONFIG entry — its synthetic leaves read defensively below.
644
629
  if (entry.setting.key === MEMORY_PROJECTION_DIR_CONFIG_KEY) {
645
630
  const refreshed = buildMemoryProjectionDirSyntheticEntry(configManager);
646
- entry.currentValue = refreshed.currentValue;
647
- entry.isDefault = refreshed.isDefault;
631
+ entry.currentValue = refreshed.currentValue; entry.isDefault = refreshed.isDefault;
632
+ continue;
633
+ }
634
+ // memory.showProvenance: same 'memory'-has-no-DEFAULT_CONFIG trap — read defensively.
635
+ if (entry.setting.key === (MEMORY_SHOW_PROVENANCE_CONFIG_KEY as ConfigKey)) {
636
+ const refreshed = buildMemoryProvenanceSyntheticEntry(configManager);
637
+ entry.currentValue = refreshed.currentValue; entry.isDefault = refreshed.isDefault;
648
638
  continue;
649
639
  }
650
640
  const raw = configManager.get(entry.setting.key as ConfigKey);
@@ -677,8 +667,12 @@ export function updateEntryForKey(
677
667
  if (entry) {
678
668
  if (key === MEMORY_PROJECTION_DIR_CONFIG_KEY) {
679
669
  const refreshed = buildMemoryProjectionDirSyntheticEntry(configManager);
680
- entry.currentValue = refreshed.currentValue;
681
- entry.isDefault = refreshed.isDefault;
670
+ entry.currentValue = refreshed.currentValue; entry.isDefault = refreshed.isDefault;
671
+ continue;
672
+ }
673
+ if (key === (MEMORY_SHOW_PROVENANCE_CONFIG_KEY as ConfigKey)) {
674
+ const refreshed = buildMemoryProvenanceSyntheticEntry(configManager);
675
+ entry.currentValue = refreshed.currentValue; entry.isDefault = refreshed.isDefault;
682
676
  continue;
683
677
  }
684
678
  if (isSandboxExecListConfigKey(key)) {