@pellux/goodvibes-agent 1.5.5 → 1.5.6

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 (165) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +1 -1
  3. package/dist/package/main.js +94130 -79335
  4. package/docs/tools-and-commands.md +2 -1
  5. package/package.json +6 -3
  6. package/src/agent/autonomy-schedule-format.ts +1 -1
  7. package/src/agent/autonomy-schedule.ts +3 -3
  8. package/src/agent/calendar/calendar-oauth-service.ts +324 -0
  9. package/src/agent/calendar-subscription-registry.ts +497 -0
  10. package/src/agent/email/email-service.ts +57 -0
  11. package/src/agent/email/smtp-client.ts +30 -10
  12. package/src/agent/memory-prompt.ts +143 -12
  13. package/src/agent/operator-actions.ts +26 -22
  14. package/src/agent/prompt-context-receipts.ts +45 -18
  15. package/src/agent/reminder-schedule-format.ts +1 -1
  16. package/src/agent/reminder-schedule.ts +3 -3
  17. package/src/agent/routine-schedule-format.ts +2 -2
  18. package/src/agent/routine-schedule-promotion.ts +5 -5
  19. package/src/agent/routine-schedule-receipts.ts +2 -2
  20. package/src/agent/schedule-edit-format.ts +1 -1
  21. package/src/agent/schedule-edit.ts +5 -5
  22. package/src/agent/session-registration.ts +281 -0
  23. package/src/agent/vibe-file.ts +50 -0
  24. package/src/cli/local-library-command-shared.ts +146 -0
  25. package/src/cli/management.ts +2 -1
  26. package/src/cli/personas-command.ts +252 -0
  27. package/src/cli/resume-relaunch-notice.ts +151 -0
  28. package/src/cli/routines-command.ts +25 -2
  29. package/src/cli/service-posture.ts +3 -2
  30. package/src/cli/skill-bundle-command.ts +175 -0
  31. package/src/cli/skills-command.ts +309 -0
  32. package/src/cli/status.ts +43 -7
  33. package/src/cli/tui-startup.ts +24 -1
  34. package/src/config/agent-settings-policy.ts +0 -1
  35. package/src/config/secret-config.ts +4 -0
  36. package/src/core/conversation-rendering.ts +20 -11
  37. package/src/core/focus-tracker.ts +41 -0
  38. package/src/core/system-message-noise.ts +108 -0
  39. package/src/core/system-message-router.ts +54 -1
  40. package/src/core/thinking-overlay.ts +83 -0
  41. package/src/input/agent-workspace-access-command-editor-submission.ts +75 -108
  42. package/src/input/agent-workspace-access-command-editors.ts +108 -128
  43. package/src/input/agent-workspace-activation.ts +15 -0
  44. package/src/input/agent-workspace-basic-command-editor-submission.ts +292 -513
  45. package/src/input/agent-workspace-basic-command-editors.ts +433 -564
  46. package/src/input/agent-workspace-calendar-connect-editor.ts +116 -0
  47. package/src/input/agent-workspace-calendar-oauth-editor.ts +152 -0
  48. package/src/input/agent-workspace-calendar-subscribe-editor.ts +128 -0
  49. package/src/input/agent-workspace-categories.ts +7 -2
  50. package/src/input/agent-workspace-channel-command-editor-submission.ts +38 -52
  51. package/src/input/agent-workspace-channel-command-editors.ts +42 -46
  52. package/src/input/agent-workspace-command-editor-engine.ts +133 -0
  53. package/src/input/agent-workspace-command-editor.ts +4 -0
  54. package/src/input/agent-workspace-direct-editor-submission.ts +59 -0
  55. package/src/input/agent-workspace-email-connect-editor.ts +162 -0
  56. package/src/input/agent-workspace-knowledge-command-editor-submission.ts +52 -81
  57. package/src/input/agent-workspace-knowledge-command-editors.ts +71 -71
  58. package/src/input/agent-workspace-library-command-editor-submission.ts +10 -28
  59. package/src/input/agent-workspace-library-command-editors.ts +16 -2
  60. package/src/input/agent-workspace-live-counters.ts +55 -0
  61. package/src/input/agent-workspace-mcp-command-editor-submission.ts +60 -82
  62. package/src/input/agent-workspace-mcp-command-editors.ts +52 -0
  63. package/src/input/agent-workspace-media-command-editor-submission.ts +12 -39
  64. package/src/input/agent-workspace-media-command-editors.ts +10 -8
  65. package/src/input/agent-workspace-memory-command-editor-submission.ts +76 -151
  66. package/src/input/agent-workspace-memory-command-editors.ts +116 -141
  67. package/src/input/agent-workspace-operations-command-editor-submission.ts +131 -184
  68. package/src/input/agent-workspace-operations-command-editors.ts +150 -162
  69. package/src/input/agent-workspace-provider-command-editor-submission.ts +60 -106
  70. package/src/input/agent-workspace-provider-command-editors.ts +58 -68
  71. package/src/input/agent-workspace-search.ts +8 -1
  72. package/src/input/agent-workspace-session-command-editor-submission.ts +104 -132
  73. package/src/input/agent-workspace-session-command-editors.ts +160 -195
  74. package/src/input/agent-workspace-settings.ts +40 -1
  75. package/src/input/agent-workspace-skill-bundle-command-editor-submission.ts +55 -71
  76. package/src/input/agent-workspace-skill-bundle-command-editors.ts +65 -69
  77. package/src/input/agent-workspace-snapshot-builders.ts +431 -0
  78. package/src/input/agent-workspace-snapshot-config.ts +43 -0
  79. package/src/input/agent-workspace-snapshot.ts +198 -432
  80. package/src/input/agent-workspace-task-command-editor-submission.ts +34 -44
  81. package/src/input/agent-workspace-task-command-editors.ts +27 -28
  82. package/src/input/agent-workspace-types.ts +35 -1
  83. package/src/input/agent-workspace.ts +5 -14
  84. package/src/input/command-registry.ts +23 -1
  85. package/src/input/commands/calendar-connect-runtime.ts +226 -0
  86. package/src/input/commands/calendar-runtime.ts +110 -7
  87. package/src/input/commands/calendar-subscription-runtime.ts +225 -0
  88. package/src/input/commands/email-runtime.ts +100 -40
  89. package/src/input/commands/knowledge.ts +1 -1
  90. package/src/input/commands/network-scan-runtime.ts +75 -0
  91. package/src/input/commands/operator-actions-runtime.ts +9 -6
  92. package/src/input/commands/personas-runtime.ts +1 -1
  93. package/src/input/commands/schedule-runtime.ts +4 -4
  94. package/src/input/commands/session-content.ts +13 -1
  95. package/src/input/commands/session-workflow.ts +15 -20
  96. package/src/input/commands/session.ts +3 -1
  97. package/src/input/commands/shell-core.ts +20 -6
  98. package/src/input/commands.ts +2 -2
  99. package/src/input/delete-key-policy.ts +46 -0
  100. package/src/input/feed-context-factory.ts +10 -0
  101. package/src/input/handler-feed.ts +87 -0
  102. package/src/input/handler-modal-routes.ts +6 -1
  103. package/src/input/handler.ts +5 -0
  104. package/src/input/panel-paste-flood-guard.ts +94 -0
  105. package/src/input/settings-modal-types.ts +5 -3
  106. package/src/input/settings-modal.ts +21 -0
  107. package/src/main.ts +36 -36
  108. package/src/permissions/approval-posture.ts +141 -0
  109. package/src/renderer/agent-workspace-context-lines.ts +10 -2
  110. package/src/renderer/compositor.ts +27 -4
  111. package/src/renderer/diff.ts +61 -18
  112. package/src/renderer/fullscreen-primitives.ts +37 -18
  113. package/src/renderer/markdown.ts +20 -10
  114. package/src/renderer/modal-factory.ts +25 -15
  115. package/src/renderer/overlay-box.ts +23 -12
  116. package/src/renderer/process-indicator.ts +8 -3
  117. package/src/renderer/prompt-content-width.ts +16 -0
  118. package/src/renderer/settings-modal-helpers.ts +2 -0
  119. package/src/renderer/settings-modal.ts +2 -0
  120. package/src/renderer/startup-theme-probe.ts +35 -0
  121. package/src/renderer/status-glyphs.ts +11 -15
  122. package/src/renderer/system-message.ts +17 -2
  123. package/src/renderer/term-caps.ts +318 -0
  124. package/src/renderer/terminal-bg-probe.ts +373 -0
  125. package/src/renderer/terminal-escapes.ts +24 -0
  126. package/src/renderer/theme-mode-config.ts +87 -0
  127. package/src/renderer/theme.ts +241 -0
  128. package/src/renderer/thinking.ts +12 -3
  129. package/src/renderer/tool-call.ts +7 -3
  130. package/src/renderer/ui-factory.ts +92 -36
  131. package/src/renderer/ui-primitives.ts +33 -93
  132. package/src/runtime/bootstrap-core.ts +15 -0
  133. package/src/runtime/bootstrap-hook-bridge.ts +6 -0
  134. package/src/runtime/bootstrap-shell.ts +2 -0
  135. package/src/runtime/bootstrap.ts +68 -5
  136. package/src/runtime/calendar-boot-refresh.ts +105 -0
  137. package/src/runtime/lan-scan-consent.ts +253 -0
  138. package/src/runtime/services.ts +127 -2
  139. package/src/runtime/session-spine-rest-transport.ts +160 -0
  140. package/src/runtime/terminal-output-guard.ts +6 -1
  141. package/src/runtime/ui-services.ts +3 -0
  142. package/src/shell/agent-workspace-fullscreen.ts +5 -0
  143. package/src/shell/terminal-focus-mode.ts +120 -0
  144. package/src/shell/ui-openers.ts +13 -4
  145. package/src/tools/agent-harness-autonomy-intake.ts +5 -5
  146. package/src/tools/agent-harness-autonomy-queue.ts +1 -1
  147. package/src/tools/agent-harness-background-processes.ts +2 -1
  148. package/src/tools/agent-harness-metadata.ts +16 -8
  149. package/src/tools/agent-harness-operator-methods.ts +44 -8
  150. package/src/tools/agent-harness-personal-ops-discovery.ts +57 -10
  151. package/src/tools/agent-harness-personal-ops-lanes.ts +17 -5
  152. package/src/tools/agent-harness-personal-ops-operations.ts +7 -7
  153. package/src/tools/agent-harness-personal-ops-types.ts +6 -0
  154. package/src/tools/agent-harness-prompt-context.ts +26 -12
  155. package/src/tools/agent-harness-workspace-actions.ts +4 -0
  156. package/src/tools/agent-local-registry-args.ts +117 -0
  157. package/src/tools/agent-local-registry-memory.ts +227 -0
  158. package/src/tools/agent-local-registry-tool.ts +19 -237
  159. package/src/tools/agent-operator-briefing-tool.ts +2 -2
  160. package/src/tools/agent-operator-method-tool.ts +13 -0
  161. package/src/tools/agent-policy-explanation.ts +39 -4
  162. package/src/tools/agent-schedule-tool.ts +5 -5
  163. package/src/utils/terminal-width.ts +98 -1
  164. package/src/version.ts +1 -1
  165. package/src/cli/local-library-command.ts +0 -825
@@ -17,6 +17,12 @@ import {
17
17
  type SettingsSecretsManager,
18
18
  } from './settings-modal-secrets.ts';
19
19
  import { buildSubscriptionEntries } from './settings-modal-subscriptions.ts';
20
+ import {
21
+ coerceThemeModeSetting,
22
+ THEME_MODE_CONFIG_KEY,
23
+ THEME_MODE_DEFAULT,
24
+ THEME_MODE_SYNTHETIC_SETTING,
25
+ } from '../renderer/theme-mode-config.ts';
20
26
  import type { FeatureFlagManager } from '@/runtime/index.ts';
21
27
  import type { FeatureFlag, FlagState } from '@/runtime/index.ts';
22
28
  import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
@@ -634,6 +640,21 @@ export class SettingsModal {
634
640
  if (this.groups.has(cat)) this.groups.get(cat)!.push(entry);
635
641
  }
636
642
 
643
+ // W4-R4: inject the synthetic display.themeMode enum (auto|dark|light) —
644
+ // agent-local key stored under the existing `display` section (not in the
645
+ // SDK ConfigKey union; setDynamic/get round-trip it), the TUI's
646
+ // settings-modal-data.ts synthetic-setting pattern. Cycles like any other
647
+ // enum entry; forced modes are applied by the ui-openers change hook.
648
+ const displayEntries = this.groups.get('display');
649
+ if (displayEntries && !displayEntries.some((e) => e.setting.key === (THEME_MODE_CONFIG_KEY as ConfigKey))) {
650
+ const themeModeValue = coerceThemeModeSetting(configManager.get(THEME_MODE_CONFIG_KEY as ConfigKey));
651
+ displayEntries.push({
652
+ setting: THEME_MODE_SYNTHETIC_SETTING,
653
+ currentValue: themeModeValue,
654
+ isDefault: themeModeValue === THEME_MODE_DEFAULT,
655
+ });
656
+ }
657
+
637
658
  const uiEntries = this.groups.get('ui');
638
659
  if (uiEntries) {
639
660
  const uiPriority: Record<string, number> = {
package/src/main.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  import { homedir } from 'node:os';
3
3
  import { Compositor } from './renderer/compositor.ts';
4
+ import { installStartupThemeProbe } from './renderer/startup-theme-probe.ts';
5
+ import { ThinkingStallClock, buildThinkingOverlay } from './core/thinking-overlay.ts';
4
6
  import { UIFactory } from './renderer/ui-factory.ts';
5
7
  import { Orchestrator } from './core/orchestrator';
6
8
  import { conversationMessagesAsSessionRecords } from './core/conversation-message-snapshot.ts';
@@ -47,6 +49,7 @@ import { wireShellUiOpeners } from './shell/ui-openers.ts';
47
49
  import { deriveComposerState } from './core/composer-state.ts';
48
50
  import { buildPersistedSessionContext } from '@/runtime/index.ts';
49
51
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
52
+ import { FOCUS_ENABLE, FOCUS_DISABLE, installFocusModeExitGuard, markFocusModeEnabled, wrapRequestPermissionWithApprovalAlert } from './shell/terminal-focus-mode.ts';
50
53
  import { prepareShellCliRuntime } from './cli/entrypoint.ts';
51
54
  import { applyInitialTuiCliState, formatFatalStartupErrorForLog, formatFatalStartupErrorForUser, getInteractiveTerminalLaunchError } from './cli/tui-startup.ts';
52
55
  import { wireSpokenTurnRuntime } from './audio/spoken-turn-wiring.ts';
@@ -56,6 +59,7 @@ import { buildCommandArgsHint } from './input/command-args-hint.ts';
56
59
  import { GOODVIBES_AGENT_PAIRING_SURFACE } from './config/surface.ts';
57
60
  import { createAutonomySurfacing, buildCalendarEventsLister, buildSkillDraftProposer } from './shell/autonomy-surfacing.ts';
58
61
  import { startHardwareProbe } from './core/hardware-profile.ts';
62
+ import { readApprovalPostureFromConfig } from './permissions/approval-posture.ts';
59
63
 
60
64
  const ALT_SCREEN_ENTER = '\x1b[?1049h', ALT_SCREEN_EXIT = '\x1b[?1049l', MOUSE_ENABLE = '\x1b[?1000h\x1b[?1002h\x1b[?1006h', MOUSE_DISABLE = '\x1b[?1006l\x1b[?1002l\x1b[?1000l', CURSOR_HIDE = '\x1b[?25l', CURSOR_SHOW = '\x1b[?25h', CLEAR_SCREEN = '\x1b[2J\x1b[3J\x1b[H';
61
65
  const KEYBOARD_EXT_ENABLE = '\x1b[>4;2m' + '\x1b[?1u', KEYBOARD_EXT_DISABLE = '\x1b[>4;0m' + '\x1b[?1l', PASTE_ENABLE = '\x1b[?2004h', PASTE_DISABLE = '\x1b[?2004l';
@@ -63,6 +67,7 @@ const KEYBOARD_EXT_ENABLE = '\x1b[>4;2m' + '\x1b[?1u', KEYBOARD_EXT_DISABLE = '\
63
67
  async function main() {
64
68
  const stdout = process.stdout;
65
69
  const stdin = process.stdin;
70
+ installFocusModeExitGuard(stdout); // W4-R3 — see shell/terminal-focus-mode.ts
66
71
  const { cli, configManager, bootstrapWorkingDir, bootstrapHomeDirectory } = await prepareShellCliRuntime(process.argv.slice(2), {
67
72
  defaultWorkingDirectory: process.env['GOODVIBES_WORKING_DIR'] ?? process.cwd(),
68
73
  homeDirectory: process.env['GOODVIBES_AGENT_HOME'] ?? homedir(),
@@ -154,6 +159,8 @@ async function main() {
154
159
 
155
160
  let streamTokenSpeed = 0;
156
161
 
162
+ const thinkingClock = new ThinkingStallClock(); // W4-R4 thinking-indicator stall clock
163
+
157
164
  let scrollTop = 0;
158
165
  let scrollLocked = true;
159
166
 
@@ -265,7 +272,7 @@ async function main() {
265
272
  process.removeListener('SIGINT', sigintHandler);
266
273
  process.removeListener('unhandledRejection', unhandledRejectionHandler);
267
274
  const exitScreen = cli.flags.noAltScreen ? CLEAR_SCREEN : CLEAR_SCREEN + ALT_SCREEN_EXIT;
268
- allowTerminalWrite(() => stdout.write(PASTE_DISABLE + KEYBOARD_EXT_DISABLE + MOUSE_DISABLE + CURSOR_SHOW + exitScreen));
275
+ allowTerminalWrite(() => stdout.write(PASTE_DISABLE + KEYBOARD_EXT_DISABLE + MOUSE_DISABLE + FOCUS_DISABLE + CURSOR_SHOW + exitScreen));
269
276
  terminalOutputGuard.dispose();
270
277
  stdin.setRawMode(false);
271
278
  process.exit(0);
@@ -394,14 +401,11 @@ async function main() {
394
401
  sidebarOverride = !(sidebarWidthFor(width) > 0);
395
402
  render();
396
403
  };
397
- permissionPromptRef.requestPermission = (request) =>
398
- new Promise((resolve) => {
399
- pendingPermission = {
400
- ...request,
401
- resolve: (approved: boolean, remember = false) => resolve({ approved, remember }),
402
- };
403
- render();
404
- });
404
+ const rawRequestPermission: typeof permissionPromptRef.requestPermission = (request) => new Promise((resolve) => { // W4-R3: see shell/terminal-focus-mode.ts
405
+ pendingPermission = { ...request, resolve: (approved: boolean, remember = false) => resolve({ approved, remember }) };
406
+ render();
407
+ });
408
+ permissionPromptRef.requestPermission = wrapRequestPermissionWithApprovalAlert(rawRequestPermission, { focusTracker: ctx.services.focusTracker });
405
409
 
406
410
  const input: InputHandler = new InputHandler(
407
411
  () => render(),
@@ -428,6 +432,7 @@ async function main() {
428
432
  tokenAuditor: ctx.services.tokenAuditor,
429
433
  replayEngine: ctx.services.replayEngine,
430
434
  webhookNotifier: ctx.services.webhookNotifier,
435
+ focusTracker: ctx.services.focusTracker,
431
436
  policyRuntimeState: ctx.services.policyRuntimeState,
432
437
  externalServices: uiServices.platform.externalServices,
433
438
  },
@@ -531,16 +536,12 @@ async function main() {
531
536
  provider: runtime.provider,
532
537
  contextWindow: currentModel.contextWindow,
533
538
  compactThreshold: configManager.get('behavior.autoCompactThreshold') as number,
534
- dangerMode: (() => {
535
- if (configManager.get('behavior.autoApprove')) return true;
536
- const permMode = configManager.get('permissions.mode');
537
- if (permMode === 'allow-all') return true;
538
- if (permMode === 'custom') {
539
- const tools = configManager.getCategory('permissions').tools;
540
- if (Object.values(tools).every(v => v === 'allow')) return true;
541
- }
542
- return false;
543
- })(),
539
+ // Single source of truth for "will this bypass the approval prompt?" —
540
+ // computed the same way cli/status.ts and the policy-explain tool
541
+ // compute it, so the footer can never disagree with them or with the
542
+ // permission gate itself (behavior.autoApprove first, then
543
+ // permissions.mode).
544
+ dangerMode: readApprovalPostureFromConfig(configManager).bypassesPrompts,
544
545
  lastInputTokens: orchestrator.lastInputTokens,
545
546
  commandArgsHint,
546
547
  hitlMode: modeManager.getHITLMode(),
@@ -597,21 +598,11 @@ async function main() {
597
598
  scrollTop = conversationViewport.nextScrollTop;
598
599
  let viewport = conversationViewport.viewport;
599
600
 
600
- if (orchestrator.isThinking) {
601
- const showSpeed = configManager.get('display.showTokenSpeed') as boolean;
602
- const showPreview = configManager.get('display.showToolPreview') as boolean;
603
- const partialToolPreview = showPreview ? sessionSnapshot.streamToolPreview : undefined;
604
- const thinking = UIFactory.createThinkingFragment(
605
- conversationWidth,
606
- orchestrator.getSpinner(),
607
- orchestrator.thinkingFrame,
608
- showSpeed ? streamTokenSpeed : undefined,
609
- showPreview ? partialToolPreview : undefined,
610
- orchestrator.streamingInputTokens > 0 ? orchestrator.streamingInputTokens : undefined,
611
- orchestrator.streamingOutputTokens > 0 ? orchestrator.streamingOutputTokens : undefined,
612
- );
613
- viewport.push(...thinking);
614
- }
601
+ viewport.push(...buildThinkingOverlay({ // honest waiting state; [] when not thinking
602
+ orchestrator, configManager, streamTokenSpeed, clock: thinkingClock,
603
+ streamToolPreview: sessionSnapshot.streamToolPreview,
604
+ approvalPending: pendingPermission !== null, width: conversationWidth,
605
+ }));
615
606
 
616
607
  if (pendingPermission) {
617
608
  viewport.push(...PermissionPromptUI.createPromptLines(conversationWidth, pendingPermission));
@@ -699,7 +690,13 @@ async function main() {
699
690
  stdin.setRawMode(true);
700
691
  stdin.resume();
701
692
  stdin.setEncoding('utf8');
702
- allowTerminalWrite(() => stdout.write((cli.flags.noAltScreen ? '' : ALT_SCREEN_ENTER) + CLEAR_SCREEN + CURSOR_HIDE + MOUSE_ENABLE + KEYBOARD_EXT_ENABLE + PASTE_ENABLE));
693
+ allowTerminalWrite(() => { markFocusModeEnabled(); return stdout.write((cli.flags.noAltScreen ? '' : ALT_SCREEN_ENTER) + CLEAR_SCREEN + CURSOR_HIDE + MOUSE_ENABLE + KEYBOARD_EXT_ENABLE + PASTE_ENABLE + FOCUS_ENABLE); });
694
+
695
+ // W4-R4: forced dark/light before first paint; auto (TTY) probes + repaints once if light.
696
+ const themeProbe = installStartupThemeProbe({
697
+ configManager, stdout, writeAllowed: allowTerminalWrite,
698
+ resetDiff: () => compositor.resetDiff(), render,
699
+ });
703
700
 
704
701
  applyInitialTuiCliState({
705
702
  cli,
@@ -710,7 +707,10 @@ async function main() {
710
707
  render,
711
708
  });
712
709
 
713
- stdin.on('data', (data: string) => {
710
+ stdin.on('data', (raw: string) => {
711
+ // Strip any OSC 11 background-probe reply before the input pipeline sees it.
712
+ const data = themeProbe.filterInput(raw);
713
+ if (data.length === 0) return;
714
714
  const blocking = handleBlockingShellInput({
715
715
  data,
716
716
  pendingPermission,
@@ -0,0 +1,141 @@
1
+ import { isAutoApproveEnabled } from '@pellux/goodvibes-sdk/platform/config';
2
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
3
+
4
+ /**
5
+ * ApprovalPosture — the single source of truth for "what will happen when a
6
+ * tool runs right now", displayed identically everywhere the Agent surfaces
7
+ * approval posture (cli status, cli doctor, the security policy-explain tool,
8
+ * and the footer's danger indicator).
9
+ *
10
+ * Mirrors PermissionManager.checkDetailed()'s exact precedence (SDK
11
+ * dist/platform/permissions/manager.js:103-138), which this module does not
12
+ * modify — the gate is correct; the historical bug was surfaces disagreeing
13
+ * about what the gate does:
14
+ * 1. behavior.autoApprove === true -> every tool call is approved
15
+ * automatically, before permissions.mode is even read.
16
+ * 2. permissions.mode === 'allow-all' -> every tool call is approved
17
+ * automatically.
18
+ * 3. permissions.mode === 'custom' -> each tool category is allowed,
19
+ * prompted, or denied per its own configured rule; bypassesPrompts is
20
+ * true only when EVERY configured tool category resolves to 'allow'
21
+ * (the only custom configuration where nothing ever prompts).
22
+ * 4. permissions.mode === 'prompt' (the default) -> read-only actions are
23
+ * auto-allowed; write, execute, and delegate actions prompt.
24
+ *
25
+ * Any surface that wants to say "will this prompt me?" must call
26
+ * computeApprovalPosture (or the config-reading convenience below) rather
27
+ * than re-deriving the precedence locally — that re-derivation is exactly
28
+ * how cli/status.ts drifted from the gate (it read permissions.mode alone
29
+ * and never looked at behavior.autoApprove).
30
+ */
31
+
32
+ export type ApprovalPostureKind = 'auto-approve' | 'allow-all' | 'custom' | 'prompt';
33
+
34
+ export interface ApprovalPostureInput {
35
+ /** behavior.autoApprove, read via the SDK's isAutoApproveEnabled (or an equivalent duck-typed read). */
36
+ readonly autoApprove: boolean;
37
+ /** permissions.mode, as read from config (may be unknown/malformed input from a loose config source). */
38
+ readonly mode: unknown;
39
+ /**
40
+ * permissions.tools, only consulted when mode === 'custom'. Values are the
41
+ * per-tool-category actions ('allow' | 'prompt' | 'deny'); anything else
42
+ * (missing/unrecognized) counts as NOT allow for the bypass computation.
43
+ */
44
+ readonly customTools?: Readonly<Record<string, unknown>>;
45
+ }
46
+
47
+ export interface ApprovalPosture {
48
+ /** Which precedence branch produced this posture. */
49
+ readonly kind: ApprovalPostureKind;
50
+ /** The raw permissions.mode value, normalized to a known mode string (defaults to 'prompt'). */
51
+ readonly mode: 'prompt' | 'allow-all' | 'custom';
52
+ /** The raw behavior.autoApprove value that drove this posture. */
53
+ readonly autoApprove: boolean;
54
+ /**
55
+ * True when NO tool call will ever hit a Human-in-the-Loop prompt under the
56
+ * current configuration — mirrors the gate's "auto-approve everything"
57
+ * branches (autoApprove, allow-all, and custom-all-allow).
58
+ */
59
+ readonly bypassesPrompts: boolean;
60
+ /** A short, honest, human-facing label — always names auto-approve explicitly when it is what is actually gating tool calls. */
61
+ readonly label: string;
62
+ /** A one-sentence explanation of the mechanism, suitable for a doctor/status detail line. */
63
+ readonly detail: string;
64
+ }
65
+
66
+ function normalizeMode(mode: unknown): 'prompt' | 'allow-all' | 'custom' {
67
+ if (mode === 'allow-all' || mode === 'custom') return mode;
68
+ return 'prompt';
69
+ }
70
+
71
+ /**
72
+ * Pure precedence computation — no config access. Every display surface
73
+ * should route its "what is the approval posture" question through this
74
+ * function so they provably agree with each other and with the gate.
75
+ */
76
+ export function computeApprovalPosture(input: ApprovalPostureInput): ApprovalPosture {
77
+ const mode = normalizeMode(input.mode);
78
+
79
+ if (input.autoApprove) {
80
+ return {
81
+ kind: 'auto-approve',
82
+ mode,
83
+ autoApprove: true,
84
+ bypassesPrompts: true,
85
+ label: 'Auto-approve ON — powerful actions run without asking',
86
+ detail: 'behavior.autoApprove is enabled: every tool call is approved automatically, regardless of permissions.mode or any custom per-tool rule.',
87
+ };
88
+ }
89
+
90
+ if (mode === 'allow-all') {
91
+ return {
92
+ kind: 'allow-all',
93
+ mode,
94
+ autoApprove: false,
95
+ bypassesPrompts: true,
96
+ label: 'Allow everything',
97
+ detail: 'permissions.mode is allow-all: every tool call is approved automatically.',
98
+ };
99
+ }
100
+
101
+ if (mode === 'custom') {
102
+ const values = Object.values(input.customTools ?? {});
103
+ const allAllow = values.length > 0 && values.every((value) => value === 'allow');
104
+ return {
105
+ kind: 'custom',
106
+ mode,
107
+ autoApprove: false,
108
+ bypassesPrompts: allAllow,
109
+ label: allAllow ? 'Custom rules (every category allows — no prompts)' : 'Custom rules',
110
+ detail: allAllow
111
+ ? 'permissions.mode is custom and every configured tool category is set to allow: no tool call prompts.'
112
+ : 'permissions.mode is custom: each tool category is allowed, prompted, or denied per its own configured rule.',
113
+ };
114
+ }
115
+
116
+ return {
117
+ kind: 'prompt',
118
+ mode: 'prompt',
119
+ autoApprove: false,
120
+ bypassesPrompts: false,
121
+ label: 'Ask before powerful actions',
122
+ detail: 'permissions.mode is prompt (default): read-only actions are auto-allowed; write, execute, and delegate actions prompt for approval.',
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Convenience for callers holding a real ConfigManager (or any subset
128
+ * exposing `get` + `getCategory`): reads behavior.autoApprove and
129
+ * permissions.mode/tools the same way the gate does and computes the posture.
130
+ */
131
+ export function readApprovalPostureFromConfig(
132
+ configManager: Pick<ConfigManager, 'get' | 'getCategory'>,
133
+ ): ApprovalPosture {
134
+ const autoApprove = isAutoApproveEnabled(configManager);
135
+ const permissions = configManager.getCategory('permissions');
136
+ return computeApprovalPosture({
137
+ autoApprove,
138
+ mode: permissions.mode,
139
+ customTools: { ...permissions.tools },
140
+ });
141
+ }
@@ -28,6 +28,14 @@ function formatMegabytes(bytes: number): string {
28
28
  return `${Math.round(bytes / (1024 * 1024))} MB`;
29
29
  }
30
30
 
31
+ // W4-A6: the memory/routine counts below are re-derived live on every
32
+ // repaint (AgentWorkspace.syncLiveCountersForRender). If that live read
33
+ // fails, liveCountersStale flags it so we say so instead of asserting a
34
+ // number the disk might already contradict.
35
+ function liveCounterStaleSuffix(snapshot: AgentWorkspaceRuntimeSnapshot): string {
36
+ return snapshot.liveCountersStale ? ' (refreshing...)' : '';
37
+ }
38
+
31
39
  export function compactText(text: string, maxWidth = 104): string {
32
40
  const normalized = text.replace(/\s+/g, ' ').trim();
33
41
  if (normalized.length === 0) return '';
@@ -546,7 +554,7 @@ export function snapshotLines(workspace: AgentWorkspace, category: AgentWorkspac
546
554
  ...snapshot.localRoutines.filter((item) => item.enabled),
547
555
  ].filter((item) => item.reviewState !== 'reviewed').length;
548
556
  base.push(
549
- { text: `Memory: ${snapshot.localMemoryCount}; prompt ${snapshot.localMemoryPromptActiveCount}; queue ${snapshot.localMemoryReviewQueueCount}; session ${snapshot.sessionMemoryCount}.`, fg: PALETTE.info },
557
+ { text: `Memory: ${snapshot.localMemoryCount}; prompt ${snapshot.localMemoryPromptActiveCount}; queue ${snapshot.localMemoryReviewQueueCount}; session ${snapshot.sessionMemoryCount}.${liveCounterStaleSuffix(snapshot)}`, fg: PALETTE.info },
550
558
  { text: `Notes: ${snapshot.localNoteCount}; skills ${snapshot.localSkillCount}/${snapshot.enabledSkillCount}; routines ${snapshot.localRoutineCount}/${snapshot.enabledRoutineCount}; personas ${snapshot.localPersonaCount}.`, fg: PALETTE.info },
551
559
  { text: `Learning curator: memory queue ${snapshot.localMemoryReviewQueueCount}; note queue ${snapshot.localNoteReviewQueueCount}; setup gaps ${behaviorNeedsSetup}; injected review ${injectedNeedsReview}.`, fg: behaviorNeedsSetup > 0 || injectedNeedsReview > 0 ? PALETTE.warn : PALETTE.good },
552
560
  { text: 'Prompt plan: score reviewed context, show suppressed records, and route review before behavior expands.', fg: PALETTE.good },
@@ -581,7 +589,7 @@ export function snapshotLines(workspace: AgentWorkspace, category: AgentWorkspac
581
589
  const needsSetup = routinesNeedingSetup(snapshot);
582
590
  const needsReview = routinesNeedingReview(snapshot);
583
591
  base.push(
584
- { text: `Routines: ${snapshot.localRoutineCount}; enabled: ${snapshot.enabledRoutineCount}`, fg: PALETTE.info },
592
+ { text: `Routines: ${snapshot.localRoutineCount}; enabled: ${snapshot.enabledRoutineCount}${liveCounterStaleSuffix(snapshot)}`, fg: PALETTE.info },
585
593
  { text: `Schedule-ready routines: ${ready.length}; setup gaps: ${needsSetup.length}; review needed: ${needsReview.length}`, fg: needsSetup.length > 0 || needsReview.length > 0 ? PALETTE.warn : PALETTE.good },
586
594
  routineNextActionLine(snapshot),
587
595
  compactRoutineReceiptLine(snapshot),
@@ -4,6 +4,8 @@ import { type Line, createEmptyCell, createEmptyLine, createStyledCell } from '.
4
4
  import { getDisplayWidth } from '../utils/terminal-width.ts';
5
5
  import type { SearchManager } from '../input/search.ts';
6
6
  import { allowTerminalWrite } from '../runtime/terminal-output-guard.ts';
7
+ import { probeTermCaps, type TermColorCaps } from './term-caps.ts';
8
+ import { activeTheme } from './theme.ts';
7
9
 
8
10
  export interface SelectionInfo {
9
11
  isCellSelected: (col: number, absoluteRow: number) => boolean;
@@ -43,9 +45,24 @@ export class Compositor {
43
45
  /** Double-buffer reuse: back is written, front is the last-rendered reference. */
44
46
  private frontBuffer: TerminalBuffer | null = null;
45
47
  private backBuffer: TerminalBuffer | null = null;
46
- private diffEngine = new DiffEngine();
48
+ private readonly caps: TermColorCaps;
49
+ private diffEngine: DiffEngine;
50
+
51
+ constructor(private stdout: NodeJS.WriteStream) {
52
+ // Probe terminal color capabilities once at construction time so the
53
+ // DiffEngine downsamples every emitted SGR to the terminal's real level.
54
+ // The hardcoded truecolor search-highlight hex below (and any future theme
55
+ // colors) is therefore cap-gated — no raw #rrggbb leaks on a non-truecolor
56
+ // terminal. (R4 later replaces the hardcoded hex with live activeTheme()
57
+ // reads in its tone-read region; this R2 region owns only the caps wiring.)
58
+ this.caps = probeTermCaps(stdout);
59
+ this.diffEngine = new DiffEngine(this.caps);
60
+ }
47
61
 
48
- constructor(private stdout: NodeJS.WriteStream) {}
62
+ /** Exposed for unit tests — returns the detected color capability. */
63
+ public get termCapsForTest(): TermColorCaps {
64
+ return this.caps;
65
+ }
49
66
 
50
67
  /** Exposed for unit tests — returns the last composited buffer. */
51
68
  public get lastBufferForTest(): TerminalBuffer | null {
@@ -86,6 +103,12 @@ export class Compositor {
86
103
  const lineCount = selection?.lineCount ?? 0;
87
104
  const offset = Math.max(0, vHeight - lineCount);
88
105
 
106
+ // R4 tone-read region (the compositor is the pre-ruled R2→R4 shared file;
107
+ // R2 owns the DiffEngine caps wiring above, R4 owns these live theme reads).
108
+ // Read the search-highlight tones live per frame so they flip in light mode;
109
+ // dark is byte-identical (the searchCurrent/searchMatch tones resolve to the
110
+ // prior hardcoded yellow/gold pair). The separator stays a neutral dim grey.
111
+ const T = activeTheme();
89
112
  const sepFg = '238';
90
113
 
91
114
  viewport.forEach((line, i) => {
@@ -147,9 +170,9 @@ export class Compositor {
147
170
  const isCurrent = search.manager.isCurrentMatch(absoluteRow, match.col);
148
171
  for (let x = match.col; x < match.col + match.length && x < leftWidth; x++) {
149
172
  if (isCurrent) {
150
- newBuffer.setCell(x, screenY, { bg: '#ffff00', fg: '#000000', bold: true, dim: false });
173
+ newBuffer.setCell(x, screenY, { bg: T.searchCurrentBg, fg: T.searchCurrentFg, bold: true, dim: false });
151
174
  } else {
152
- newBuffer.setCell(x, screenY, { bg: '#806600', fg: '#ffffff', bold: false, dim: false });
175
+ newBuffer.setCell(x, screenY, { bg: T.searchMatchBg, fg: T.searchMatchFg, bold: false, dim: false });
153
176
  }
154
177
  }
155
178
  }
@@ -1,8 +1,18 @@
1
1
  import { TerminalBuffer } from './buffer.ts';
2
2
  import { type Cell } from '../types/grid.ts';
3
+ import {
4
+ type TermColorCaps,
5
+ downsampleColor,
6
+ wrapSynced,
7
+ } from './term-caps.ts';
3
8
 
4
9
  /**
5
10
  * DiffEngine - Generates minimal ANSI updates between two buffers.
11
+ *
12
+ * Accepts a TermColorCaps probe result so that color sequences are
13
+ * downsampled to the terminal's actual capability level (truecolor /
14
+ * ansi256 / basic16 / none), and frames are wrapped in DEC 2026
15
+ * synchronized-output markers when supported.
6
16
  */
7
17
  export class DiffEngine {
8
18
  private lastFg = '';
@@ -13,6 +23,11 @@ export class DiffEngine {
13
23
  private lastItalic = false;
14
24
  private lastStrikethrough = false;
15
25
  private lastLink = '';
26
+ private caps: TermColorCaps;
27
+
28
+ constructor(caps: TermColorCaps = { capability: 'truecolor', syncedOutput: true }) {
29
+ this.caps = caps;
30
+ }
16
31
 
17
32
  public reset(): void {
18
33
  this.lastFg = '';
@@ -56,7 +71,7 @@ export class DiffEngine {
56
71
  this.lastLink = '';
57
72
  }
58
73
 
59
- return output;
74
+ return wrapSynced(output, this.caps);
60
75
  }
61
76
 
62
77
  private isCellDifferent(a: Cell | undefined, b: Cell): boolean {
@@ -66,6 +81,11 @@ export class DiffEngine {
66
81
  (a.link ?? '') !== (b.link ?? '');
67
82
  }
68
83
 
84
+ /**
85
+ * Convert a raw color string (hex or r;g;b) to the "r;g;b" form used for
86
+ * change-detection against lastFg/lastBg. Palette indices pass through as-is.
87
+ * Capability downsampling happens in applyStyles via downsampleColor.
88
+ */
69
89
  private sanitizeColor(color: string): string {
70
90
  if (color.startsWith('#')) {
71
91
  const r = parseInt(color.slice(1, 3), 16);
@@ -77,7 +97,7 @@ export class DiffEngine {
77
97
  }
78
98
 
79
99
  private applyStyles(cell: Cell): string {
80
- let style = '';
100
+ // Normalize hex → r;g;b (for change-detection against lastFg/lastBg)
81
101
  const fg = this.sanitizeColor(cell.fg);
82
102
  const bg = this.sanitizeColor(cell.bg);
83
103
  const link = cell.link ?? '';
@@ -87,21 +107,40 @@ export class DiffEngine {
87
107
  cell.underline !== this.lastUnderline || cell.italic !== this.lastItalic ||
88
108
  cell.strikethrough !== this.lastStrikethrough;
89
109
 
110
+ let style = '';
111
+
90
112
  if (changed) {
91
- style += '\x1b[0m'; // Reset all attributes
92
- if (cell.bold) style += '\x1b[1m';
93
- if (cell.dim) style += '\x1b[2m';
94
- if (cell.italic) style += '\x1b[3m';
95
- if (cell.underline) style += '\x1b[4m';
96
- if (cell.strikethrough) style += '\x1b[9m';
97
-
98
- if (fg) {
99
- const isRgb = fg.includes(';');
100
- style += isRgb ? `\x1b[38;2;${fg}m` : `\x1b[38;5;${fg}m`;
113
+ // Reset all attributes first (only when the terminal renders color/SGR).
114
+ if (this.caps.capability !== 'none') {
115
+ style += '\x1b[0m';
116
+ if (cell.bold) style += '\x1b[1m';
117
+ if (cell.dim) style += '\x1b[2m';
118
+ if (cell.italic) style += '\x1b[3m';
119
+ if (cell.underline) style += '\x1b[4m';
120
+ if (cell.strikethrough) style += '\x1b[9m';
101
121
  }
102
- if (bg) {
103
- const isRgb = bg.includes(';');
104
- style += isRgb ? `\x1b[48;2;${bg}m` : `\x1b[48;5;${bg}m`;
122
+
123
+ // Foreground color — capability-downsampled (no raw hex leak on a
124
+ // truecolor-incapable terminal; null emit nothing).
125
+ const fgOut = downsampleColor(fg, this.caps, 'fg');
126
+ if (fgOut !== null) {
127
+ if (this.caps.capability === 'basic16') {
128
+ style += `\x1b[${fgOut}m`;
129
+ } else {
130
+ const isRgb = fgOut.includes(';');
131
+ style += isRgb ? `\x1b[38;2;${fgOut}m` : `\x1b[38;5;${fgOut}m`;
132
+ }
133
+ }
134
+
135
+ // Background color — capability-downsampled
136
+ const bgOut = downsampleColor(bg, this.caps, 'bg');
137
+ if (bgOut !== null) {
138
+ if (this.caps.capability === 'basic16') {
139
+ style += `\x1b[${bgOut}m`;
140
+ } else {
141
+ const isRgb = bgOut.includes(';');
142
+ style += isRgb ? `\x1b[48;2;${bgOut}m` : `\x1b[48;5;${bgOut}m`;
143
+ }
105
144
  }
106
145
 
107
146
  this.lastFg = fg;
@@ -113,16 +152,20 @@ export class DiffEngine {
113
152
  this.lastStrikethrough = cell.strikethrough;
114
153
  }
115
154
 
116
- // OSC 8 hyperlink: emit open/close/change sequences only when link changes
117
- if (link !== this.lastLink) {
155
+ // OSC 8 hyperlink: emit open/close/change sequences only when link changes.
156
+ // Suppressed in no-color mode (dumb/no-color terminals cannot render them).
157
+ if (link !== this.lastLink && this.caps.capability !== 'none') {
118
158
  if (link) {
119
159
  // Open new hyperlink (close previous if any was open)
120
160
  style += `\x1b]8;;${link}\x1b\\`;
121
161
  } else {
122
162
  // Close hyperlink
123
- style += `\x1b]8;;\x1b\\`;
163
+ style += '\x1b]8;;\x1b\\';
124
164
  }
125
165
  this.lastLink = link;
166
+ } else if (link !== this.lastLink) {
167
+ // capability === 'none': track state but emit nothing
168
+ this.lastLink = link;
126
169
  }
127
170
 
128
171
  return style;
@@ -1,25 +1,44 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine, createStyledCell } from '../types/grid.ts';
3
3
  import { getDisplayWidth } from '../utils/terminal-width.ts';
4
- import { GLYPHS, UI_TONES } from './ui-primitives.ts';
4
+ import { GLYPHS } from './ui-primitives.ts';
5
+ import { activeUiTones, registerThemeRefresh } from './theme.ts';
5
6
 
6
- export const FULLSCREEN_PALETTE = {
7
- border: '#64748b',
8
- title: '#67e8f9',
9
- subtitle: '#93c5fd',
10
- text: '#e2e8f0',
11
- muted: '#94a3b8',
12
- dim: '#64748b',
13
- selectedBg: '#223049',
14
- categoryBg: '#141b25',
15
- contextBg: '#121923',
16
- controlsBg: '#0f141d',
17
- footerBg: '#111827',
18
- good: UI_TONES.state.good,
19
- warn: UI_TONES.state.warn,
20
- bad: UI_TONES.state.bad,
21
- info: UI_TONES.state.info,
22
- } as const;
7
+ // Built from the mode-resolved chrome tones (activeUiTones) and rebuilt IN PLACE
8
+ // on a mode flip via the registered refresher (read by reference across the
9
+ // fullscreen workspace surfaces). This is an OPAQUE dark panel: fg/bg roles stay
10
+ // dark in the SDK light tones (only state.* flips), so dark is byte-identical.
11
+ // title / categoryBg / contextBg / controlsBg are agent-local fullscreen values
12
+ // with no shared tone equivalent — kept as-is (opaque dark panel surfaces).
13
+ function buildFullscreenPalette(): Record<string, string> {
14
+ const t = activeUiTones();
15
+ return {
16
+ border: t.border,
17
+ title: '#67e8f9',
18
+ subtitle: t.accent.conversation,
19
+ text: t.fg.primary,
20
+ muted: t.fg.muted,
21
+ dim: t.border,
22
+ selectedBg: t.bg.selected,
23
+ categoryBg: '#141b25',
24
+ contextBg: '#121923',
25
+ controlsBg: '#0f141d',
26
+ footerBg: t.bg.footer,
27
+ good: t.state.good,
28
+ warn: t.state.warn,
29
+ bad: t.state.bad,
30
+ info: t.state.info,
31
+ };
32
+ }
33
+
34
+ export const FULLSCREEN_PALETTE = buildFullscreenPalette() as {
35
+ readonly border: string; readonly title: string; readonly subtitle: string;
36
+ readonly text: string; readonly muted: string; readonly dim: string;
37
+ readonly selectedBg: string; readonly categoryBg: string; readonly contextBg: string;
38
+ readonly controlsBg: string; readonly footerBg: string; readonly good: string;
39
+ readonly warn: string; readonly bad: string; readonly info: string;
40
+ };
41
+ registerThemeRefresh(() => Object.assign(FULLSCREEN_PALETTE as Record<string, string>, buildFullscreenPalette()));
23
42
 
24
43
  export type FullscreenTextStyle = Partial<Omit<Line[number], 'char'>>;
25
44