@pellux/goodvibes-tui 1.1.0 → 1.9.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 (267) hide show
  1. package/CHANGELOG.md +85 -21
  2. package/README.md +28 -18
  3. package/docs/foundation-artifacts/operator-contract.json +4045 -1763
  4. package/package.json +2 -2
  5. package/src/audio/spoken-turn-controller.ts +12 -2
  6. package/src/audio/spoken-turn-wiring.ts +2 -1
  7. package/src/cli/help.ts +8 -1
  8. package/src/cli/service-posture.ts +2 -1
  9. package/src/cli/status.ts +2 -1
  10. package/src/cli/surface-command.ts +2 -2
  11. package/src/core/alert-gating.ts +67 -0
  12. package/src/core/approval-alert.ts +72 -0
  13. package/src/core/budget-breach-notifier.ts +106 -0
  14. package/src/core/composer-state.ts +11 -3
  15. package/src/core/conversation-line-cache.ts +27 -3
  16. package/src/core/conversation-rendering.ts +90 -14
  17. package/src/core/conversation.ts +18 -0
  18. package/src/core/focus-tracker.ts +41 -0
  19. package/src/core/long-task-notifier.ts +33 -6
  20. package/src/core/stream-event-wiring.ts +20 -1
  21. package/src/core/system-message-noise.ts +87 -0
  22. package/src/core/system-message-router.ts +104 -51
  23. package/src/core/turn-cancellation.ts +25 -0
  24. package/src/core/turn-event-wiring.ts +72 -3
  25. package/src/daemon/cli.ts +29 -2
  26. package/src/daemon/handlers/register.ts +8 -1
  27. package/src/daemon/service-commands.ts +329 -0
  28. package/src/export/cost-utils.ts +36 -0
  29. package/src/input/autocomplete.ts +27 -1
  30. package/src/input/command-registry.ts +84 -5
  31. package/src/input/commands/checkpoint-runtime.ts +280 -0
  32. package/src/input/commands/codebase-runtime.ts +232 -0
  33. package/src/input/commands/config.ts +43 -3
  34. package/src/input/commands/eval.ts +1 -1
  35. package/src/input/commands/health-runtime.ts +10 -2
  36. package/src/input/commands/image-runtime.ts +112 -0
  37. package/src/input/commands/intelligence-runtime.ts +11 -1
  38. package/src/input/commands/local-auth-runtime.ts +4 -1
  39. package/src/input/commands/local-runtime.ts +9 -3
  40. package/src/input/commands/marketplace-runtime.ts +2 -2
  41. package/src/input/commands/memory.ts +73 -35
  42. package/src/input/commands/operator-panel-runtime.ts +71 -31
  43. package/src/input/commands/planning-runtime.ts +116 -4
  44. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  45. package/src/input/commands/plugin-runtime.ts +2 -2
  46. package/src/input/commands/policy-dispatch.ts +21 -0
  47. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  48. package/src/input/commands/qrcode-runtime.ts +26 -6
  49. package/src/input/commands/recall-review.ts +35 -0
  50. package/src/input/commands/remote-runtime-setup.ts +5 -3
  51. package/src/input/commands/remote-runtime.ts +3 -3
  52. package/src/input/commands/runtime-services.ts +54 -13
  53. package/src/input/commands/services-runtime.ts +1 -1
  54. package/src/input/commands/session-content.ts +20 -9
  55. package/src/input/commands/session-workflow.ts +16 -1
  56. package/src/input/commands/settings-sync-runtime.ts +16 -4
  57. package/src/input/commands/shell-core.ts +25 -8
  58. package/src/input/commands/skills-runtime.ts +2 -8
  59. package/src/input/commands/subscription-runtime.ts +2 -2
  60. package/src/input/commands/test-runtime.ts +277 -0
  61. package/src/input/commands/websearch-runtime.ts +101 -0
  62. package/src/input/commands/work-plan-runtime.ts +36 -10
  63. package/src/input/commands/workstream-runtime.ts +488 -0
  64. package/src/input/commands.ts +12 -0
  65. package/src/input/config-modal-types.ts +175 -0
  66. package/src/input/config-modal.ts +592 -0
  67. package/src/input/feed-context-factory.ts +10 -8
  68. package/src/input/handler-command-route.ts +37 -20
  69. package/src/input/handler-content-actions.ts +17 -2
  70. package/src/input/handler-feed-routes.ts +74 -114
  71. package/src/input/handler-feed.ts +79 -27
  72. package/src/input/handler-interactions.ts +1 -3
  73. package/src/input/handler-modal-routes.ts +136 -6
  74. package/src/input/handler-modal-stack.ts +14 -7
  75. package/src/input/handler-modal-token-routes.ts +16 -49
  76. package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
  77. package/src/input/handler-onboarding.ts +71 -59
  78. package/src/input/handler-picker-routes.ts +26 -102
  79. package/src/input/handler-shortcuts.ts +74 -14
  80. package/src/input/handler-types.ts +2 -6
  81. package/src/input/handler-ui-state.ts +10 -22
  82. package/src/input/handler.ts +12 -29
  83. package/src/input/keybindings.ts +23 -8
  84. package/src/input/model-picker-types.ts +24 -6
  85. package/src/input/model-picker.ts +77 -2
  86. package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
  87. package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
  88. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
  89. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
  90. package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
  91. package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
  92. package/src/input/panel-integration-actions.ts +9 -170
  93. package/src/input/panel-mouse-geometry.ts +97 -0
  94. package/src/input/panel-paste-flood-guard.ts +86 -0
  95. package/src/input/selection-modal.ts +11 -0
  96. package/src/input/session-picker-modal.ts +44 -4
  97. package/src/input/settings-modal-data.ts +146 -2
  98. package/src/input/settings-modal-mutations.ts +6 -4
  99. package/src/input/settings-modal-types.ts +4 -2
  100. package/src/main.ts +49 -51
  101. package/src/panels/agent-inspector-shared.ts +2 -1
  102. package/src/panels/base-panel.ts +22 -1
  103. package/src/panels/builtin/agent.ts +21 -107
  104. package/src/panels/builtin/development.ts +15 -61
  105. package/src/panels/builtin/knowledge.ts +8 -32
  106. package/src/panels/builtin/operations.ts +84 -428
  107. package/src/panels/builtin/session.ts +21 -112
  108. package/src/panels/builtin/shared.ts +18 -46
  109. package/src/panels/builtin-modals.ts +218 -0
  110. package/src/panels/builtin-panels.ts +5 -0
  111. package/src/panels/cost-tracker-panel.ts +36 -10
  112. package/src/panels/diff-panel.ts +12 -43
  113. package/src/panels/eval-registry.ts +60 -0
  114. package/src/panels/fleet-deep-link.ts +31 -0
  115. package/src/panels/fleet-panel-format.ts +62 -0
  116. package/src/panels/fleet-panel-worktree-detail.ts +48 -0
  117. package/src/panels/fleet-panel.ts +758 -0
  118. package/src/panels/fleet-read-model.ts +520 -0
  119. package/src/panels/fleet-steer.ts +125 -0
  120. package/src/panels/fleet-stop.ts +153 -0
  121. package/src/panels/fleet-tabs.ts +230 -0
  122. package/src/panels/fleet-transcript.ts +356 -0
  123. package/src/panels/index.ts +7 -31
  124. package/src/panels/modals/hooks-modal.ts +187 -0
  125. package/src/panels/modals/keybindings-modal.ts +166 -0
  126. package/src/panels/modals/knowledge-modal.ts +158 -0
  127. package/src/panels/modals/local-auth-modal.ts +132 -0
  128. package/src/panels/modals/marketplace-modal.ts +218 -0
  129. package/src/panels/modals/memory-modal.ts +180 -0
  130. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  131. package/src/panels/modals/modal-theme.ts +42 -0
  132. package/src/panels/modals/pairing-modal.ts +163 -0
  133. package/src/panels/modals/planning-modal.ts +304 -0
  134. package/src/panels/modals/plugins-modal.ts +174 -0
  135. package/src/panels/modals/policy-modal.ts +256 -0
  136. package/src/panels/modals/provider-health-modal.ts +136 -0
  137. package/src/panels/modals/remote-modal.ts +115 -0
  138. package/src/panels/modals/sandbox-modal.ts +150 -0
  139. package/src/panels/modals/security-modal.ts +217 -0
  140. package/src/panels/modals/services-modal.ts +179 -0
  141. package/src/panels/modals/settings-sync-modal.ts +142 -0
  142. package/src/panels/modals/skills-modal.ts +189 -0
  143. package/src/panels/modals/subscription-modal.ts +172 -0
  144. package/src/panels/modals/work-plan-modal.ts +177 -0
  145. package/src/panels/panel-confirm-overlay.ts +72 -0
  146. package/src/panels/panel-manager.ts +123 -9
  147. package/src/panels/polish-core.ts +38 -25
  148. package/src/panels/project-planning-answer-actions.ts +8 -11
  149. package/src/panels/skills-panel.ts +7 -51
  150. package/src/panels/types.ts +37 -0
  151. package/src/permissions/hunk-selection.ts +179 -0
  152. package/src/permissions/prompt.ts +216 -13
  153. package/src/renderer/autocomplete-overlay.ts +28 -2
  154. package/src/renderer/compaction-history-modal.ts +19 -3
  155. package/src/renderer/compaction-preview.ts +13 -2
  156. package/src/renderer/compaction-quality.ts +100 -0
  157. package/src/renderer/compositor.ts +2 -3
  158. package/src/renderer/config-modal.ts +95 -0
  159. package/src/renderer/conversation-overlays.ts +10 -17
  160. package/src/renderer/fleet-tab-strip.ts +56 -0
  161. package/src/renderer/footer-tips.ts +15 -3
  162. package/src/renderer/fullscreen-primitives.ts +32 -22
  163. package/src/renderer/git-status.ts +43 -1
  164. package/src/renderer/help-overlay.ts +2 -2
  165. package/src/renderer/layout.ts +0 -4
  166. package/src/renderer/markdown.ts +7 -3
  167. package/src/renderer/modal-factory.ts +25 -20
  168. package/src/renderer/model-workspace.ts +62 -4
  169. package/src/renderer/overlay-box.ts +21 -17
  170. package/src/renderer/panel-workspace-bar.ts +8 -2
  171. package/src/renderer/process-indicator.ts +14 -3
  172. package/src/renderer/selection-modal-overlay.ts +6 -1
  173. package/src/renderer/session-picker-modal.ts +196 -3
  174. package/src/renderer/settings-modal-helpers.ts +2 -0
  175. package/src/renderer/settings-modal.ts +7 -0
  176. package/src/renderer/shell-surface.ts +8 -1
  177. package/src/renderer/status-glyphs.ts +14 -15
  178. package/src/renderer/system-message.ts +15 -3
  179. package/src/renderer/terminal-bg-probe.ts +339 -0
  180. package/src/renderer/terminal-escapes.ts +20 -0
  181. package/src/renderer/theme-mode-config.ts +67 -0
  182. package/src/renderer/theme.ts +91 -1
  183. package/src/renderer/thinking.ts +11 -3
  184. package/src/renderer/tool-call.ts +15 -9
  185. package/src/renderer/tool-result-summary.ts +148 -0
  186. package/src/renderer/turn-injection.ts +133 -0
  187. package/src/renderer/ui-factory.ts +154 -85
  188. package/src/renderer/ui-primitives.ts +30 -129
  189. package/src/runtime/bootstrap-command-context.ts +27 -1
  190. package/src/runtime/bootstrap-command-parts.ts +41 -10
  191. package/src/runtime/bootstrap-core.ts +47 -15
  192. package/src/runtime/bootstrap-hook-bridge.ts +7 -0
  193. package/src/runtime/bootstrap-shell.ts +48 -17
  194. package/src/runtime/bootstrap.ts +129 -22
  195. package/src/runtime/code-index-services.ts +135 -0
  196. package/src/runtime/legacy-daemon-migration.ts +516 -0
  197. package/src/runtime/memory-fold.ts +26 -0
  198. package/src/runtime/onboarding/derivation.ts +7 -2
  199. package/src/runtime/onboarding/snapshot.ts +27 -1
  200. package/src/runtime/onboarding/types.ts +31 -1
  201. package/src/runtime/operator-token-cleanup.ts +82 -1
  202. package/src/runtime/orchestrator-core-services.ts +59 -0
  203. package/src/runtime/process-lifecycle.ts +3 -1
  204. package/src/runtime/resume-notice.ts +209 -0
  205. package/src/runtime/services.ts +98 -46
  206. package/src/runtime/session-inbound-inputs.ts +252 -0
  207. package/src/runtime/session-spine-transport.ts +64 -0
  208. package/src/runtime/terminal-output-guard.ts +15 -8
  209. package/src/runtime/ui-services.ts +27 -3
  210. package/src/runtime/workstream-services.ts +327 -0
  211. package/src/runtime/wrfc-persistence.ts +124 -17
  212. package/src/shell/blocking-input.ts +68 -4
  213. package/src/shell/recovery-input-helpers.ts +170 -1
  214. package/src/shell/ui-openers.ts +171 -26
  215. package/src/utils/format-duration.ts +8 -18
  216. package/src/utils/splash-lines.ts +1 -1
  217. package/src/utils/terminal-width.ts +52 -0
  218. package/src/version.ts +1 -1
  219. package/src/panels/agent-inspector-panel.ts +0 -786
  220. package/src/panels/approval-panel.ts +0 -252
  221. package/src/panels/automation-control-panel.ts +0 -479
  222. package/src/panels/cockpit-panel.ts +0 -481
  223. package/src/panels/cockpit-read-model.ts +0 -238
  224. package/src/panels/communication-panel.ts +0 -256
  225. package/src/panels/control-plane-panel.ts +0 -470
  226. package/src/panels/debug-panel.ts +0 -615
  227. package/src/panels/docs-panel.ts +0 -384
  228. package/src/panels/eval-panel.ts +0 -627
  229. package/src/panels/file-explorer-panel.ts +0 -673
  230. package/src/panels/file-preview-panel.ts +0 -517
  231. package/src/panels/hooks-panel.ts +0 -401
  232. package/src/panels/incident-review-panel.ts +0 -406
  233. package/src/panels/intelligence-panel.ts +0 -383
  234. package/src/panels/knowledge-graph-panel.ts +0 -515
  235. package/src/panels/marketplace-panel.ts +0 -399
  236. package/src/panels/memory-panel.ts +0 -558
  237. package/src/panels/ops-control-panel.ts +0 -329
  238. package/src/panels/ops-strategy-panel.ts +0 -321
  239. package/src/panels/orchestration-panel.ts +0 -465
  240. package/src/panels/panel-list-panel.ts +0 -566
  241. package/src/panels/plan-dashboard-panel.ts +0 -707
  242. package/src/panels/policy-panel.ts +0 -517
  243. package/src/panels/project-planning-panel.ts +0 -731
  244. package/src/panels/provider-health-panel.ts +0 -678
  245. package/src/panels/provider-health-tracker.ts +0 -310
  246. package/src/panels/provider-health-views.ts +0 -567
  247. package/src/panels/qr-panel.ts +0 -280
  248. package/src/panels/remote-panel.ts +0 -534
  249. package/src/panels/routes-panel.ts +0 -241
  250. package/src/panels/sandbox-panel.ts +0 -456
  251. package/src/panels/security-panel.ts +0 -447
  252. package/src/panels/services-panel.ts +0 -329
  253. package/src/panels/session-browser-panel.ts +0 -496
  254. package/src/panels/settings-sync-panel.ts +0 -398
  255. package/src/panels/subscription-panel.ts +0 -342
  256. package/src/panels/symbol-outline-panel.ts +0 -619
  257. package/src/panels/system-messages-panel.ts +0 -364
  258. package/src/panels/tasks-panel.ts +0 -608
  259. package/src/panels/thinking-panel.ts +0 -333
  260. package/src/panels/tool-inspector-panel.ts +0 -537
  261. package/src/panels/work-plan-panel.ts +0 -540
  262. package/src/panels/worktree-panel.ts +0 -360
  263. package/src/panels/wrfc-panel.ts +0 -790
  264. package/src/renderer/agent-detail-modal.ts +0 -466
  265. package/src/renderer/live-tail-modal.ts +0 -156
  266. package/src/renderer/process-modal.ts +0 -671
  267. package/src/renderer/qr-renderer.ts +0 -120
@@ -0,0 +1,256 @@
1
+ import { MODAL_TONES } from './modal-theme.ts';
2
+ import type {
3
+ DivergenceDashboardSnapshot,
4
+ DivergenceStats,
5
+ PermissionAuditEntry,
6
+ PolicyBundleVersion,
7
+ PolicyDiffResult,
8
+ PolicyLintFinding,
9
+ PolicyPreflightReview,
10
+ PolicySimulationSummary,
11
+ } from '@/runtime/index.ts';
12
+ import type { ModalSectionStyle } from '../../renderer/modal-factory.ts';
13
+ import type {
14
+ ConfigModalActionContext,
15
+ ConfigModalRow,
16
+ ConfigModalSurface,
17
+ ConfigModalView,
18
+ } from '../../input/config-modal-types.ts';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Policy → config-modal surface (W6.1 group-B port). Governance CONTENT view,
22
+ // not a selectable list — current/candidate bundle state, diff, divergence
23
+ // gate, history, permission audit, lint findings, preflight review, and
24
+ // simulation samples render as non-selectable rows (the host windows/scrolls
25
+ // them). EVERY dispatchable key in the original panel (s/f/l/p/b) already
26
+ // routed to `/policy <sub>` via executeCommand — none were in-panel mutations;
27
+ // the panel's promote/rollback confirm step is dropped per charter (never fold
28
+ // a confirm into a modal) and the command dispatches directly. Determinism:
29
+ // timestamps are formatted (fmtTime/fmtRate), never read from the clock.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ interface PolicyModalSnapshot {
33
+ readonly current: PolicyBundleVersion | null;
34
+ readonly candidate: PolicyBundleVersion | null;
35
+ readonly history: readonly PolicyBundleVersion[];
36
+ readonly diff: PolicyDiffResult | null;
37
+ readonly divergence: DivergenceDashboardSnapshot | null;
38
+ readonly recentPermissionAudit: readonly PermissionAuditEntry[];
39
+ readonly lintFindings: readonly PolicyLintFinding[];
40
+ readonly lastSimulationSummary: PolicySimulationSummary | null;
41
+ readonly lastPreflightReview: PolicyPreflightReview | null;
42
+ readonly capturedAt: string;
43
+ }
44
+
45
+ export interface PolicyModalDeps {
46
+ readonly policyRuntimeState: { getSnapshot(): PolicyModalSnapshot };
47
+ }
48
+
49
+ const BAD: ModalSectionStyle = { fg: MODAL_TONES.bad };
50
+ const WARN: ModalSectionStyle = { fg: MODAL_TONES.warn };
51
+ const GOOD: ModalSectionStyle = { fg: MODAL_TONES.good };
52
+ const DIM: ModalSectionStyle = { dim: true };
53
+ const MAX_ROWS = 5;
54
+
55
+ function fmtTime(value: string | undefined): string {
56
+ if (!value) return 'n/a';
57
+ return value.replace('T', ' ').slice(0, 19);
58
+ }
59
+
60
+ function fmtRate(value: number | undefined): string {
61
+ if (value === undefined) return 'n/a';
62
+ return `${(value * 100).toFixed(1)}%`;
63
+ }
64
+
65
+ function gateColor(status: string | undefined): ModalSectionStyle | undefined {
66
+ switch (status) {
67
+ case 'allowed': return GOOD;
68
+ case 'blocked': return BAD;
69
+ case 'no_data': return WARN;
70
+ default: return DIM;
71
+ }
72
+ }
73
+
74
+ function ruleLine(rule: { id: string; type: string; effect: string }): string {
75
+ return `${rule.id} (${rule.type}, effect=${rule.effect})`;
76
+ }
77
+
78
+ class PolicyModalSurface implements ConfigModalSurface {
79
+ readonly name = 'policy-modal';
80
+ readonly title = 'Policy And Governance';
81
+
82
+ constructor(private readonly deps: PolicyModalDeps) {}
83
+
84
+ readonly actions = [
85
+ { key: 's', id: 'simulate', label: 'simulate' },
86
+ { key: 'f', id: 'preflight', label: 'preflight' },
87
+ { key: 'l', id: 'lint', label: 'lint' },
88
+ { key: 'p', id: 'promote', label: 'promote' },
89
+ { key: 'b', id: 'rollback', label: 'rollback' },
90
+ ];
91
+
92
+ buildView(): ConfigModalView {
93
+ const snapshot = this.deps.policyRuntimeState.getSnapshot();
94
+ const preflight = snapshot.lastPreflightReview;
95
+ const divergence = snapshot.divergence;
96
+ const lintCount = snapshot.lintFindings.length;
97
+ const preflightStatus = preflight ? preflight.status : 'none';
98
+ const gateStatus = divergence?.gate.status ?? 'n/a';
99
+
100
+ const header = [`bundles ${snapshot.current ? 1 : 0}+${snapshot.candidate ? 1 : 0}c preflight ${preflightStatus.toUpperCase()} gate ${gateStatus} lint ${lintCount}`];
101
+
102
+ const rows: ConfigModalRow[] = [];
103
+ let n = 0;
104
+ const push = (label: string, style?: ModalSectionStyle): void => { rows.push({ id: `row:${n++}`, label, selectable: false, ...(style ? { style } : {}) }); };
105
+
106
+ const nothingRecorded = !snapshot.current && !snapshot.candidate && !divergence
107
+ && snapshot.history.length === 0 && snapshot.recentPermissionAudit.length === 0
108
+ && snapshot.lintFindings.length === 0 && !snapshot.lastSimulationSummary && !preflight;
109
+
110
+ if (nothingRecorded) {
111
+ push('No policy bundles loaded.');
112
+ push('Bundle: none active, none candidate. Gate: n/a.', DIM);
113
+ push('Get started');
114
+ push('/policy load — load a policy bundle to begin governance review', DIM);
115
+ return { title: 'Policy And Governance', tabs: [{ id: 'governance', label: 'Governance', header, rows, emptyText: '' }] };
116
+ }
117
+
118
+ if (snapshot.current) {
119
+ push('Current');
120
+ push(`Bundle ${snapshot.current.bundle.bundleId} State ${snapshot.current.state}`);
121
+ push(`Loaded ${fmtTime(snapshot.current.loadedAt)} Active ${fmtTime(snapshot.current.activatedAt)}`, DIM);
122
+ }
123
+ if (snapshot.candidate) {
124
+ push('Candidate');
125
+ push(`Bundle ${snapshot.candidate.bundle.bundleId} State ${snapshot.candidate.state}`);
126
+ push(`Loaded ${fmtTime(snapshot.candidate.loadedAt)} Rules ${snapshot.candidate.rules.length}`, DIM);
127
+ }
128
+ if (snapshot.diff) {
129
+ const diff = snapshot.diff;
130
+ push('Diff');
131
+ push(`${diff.fromBundleId} -> ${diff.toBundleId} changes ${diff.totalChanges}`);
132
+ if (diff.added.length > 0) { push('Added', GOOD); for (const r of diff.added.slice(0, MAX_ROWS)) push(` + ${ruleLine(r)}`, DIM); }
133
+ if (diff.removed.length > 0) { push('Removed', BAD); for (const r of diff.removed.slice(0, MAX_ROWS)) push(` - ${ruleLine(r)}`, DIM); }
134
+ if (diff.changed.length > 0) { push('Changed', WARN); for (const c of diff.changed.slice(0, MAX_ROWS)) push(` ~ ${c.ruleId}`, DIM); }
135
+ }
136
+ if (divergence) {
137
+ push('Governance Gate');
138
+ push(`Mode ${divergence.mode} Gate ${divergence.gate.status} Divergence ${fmtRate(divergence.gate.divergenceRate ?? divergence.report.overall.divergenceRate)}`, gateColor(divergence.gate.status));
139
+ push(`Evaluations ${divergence.report.overall.totalEvaluations} Trend points ${divergence.trend.length}`, DIM);
140
+ const prefixEntries = Object.entries(divergence.report.byCommandPrefix);
141
+ if (prefixEntries.length > 0) {
142
+ push('Divergence by command prefix', DIM);
143
+ for (const [prefix, stats] of prefixEntries.slice(0, MAX_ROWS)) push(` ${prefix} ${fmtRate(stats.divergenceRate)} ${stats.total}/${stats.totalEvaluations}`, stats.divergenceRate > 0 ? WARN : undefined);
144
+ }
145
+ const classEntries = Object.entries(divergence.report.byToolClass).filter((e): e is [string, DivergenceStats] => e[1] !== undefined);
146
+ if (classEntries.length > 0) {
147
+ push('Divergence by tool class', DIM);
148
+ for (const [cls, stats] of classEntries.slice(0, MAX_ROWS)) push(` ${cls} ${fmtRate(stats.divergenceRate)} ${stats.total}/${stats.totalEvaluations}`, stats.divergenceRate > 0 ? WARN : undefined);
149
+ }
150
+ }
151
+ if (snapshot.history.length > 0) {
152
+ push('History');
153
+ for (const v of snapshot.history.slice(0, MAX_ROWS)) push(`${v.bundle.bundleId} ${v.state} ${fmtTime(v.activatedAt ?? v.loadedAt)}`, DIM);
154
+ }
155
+ if (snapshot.recentPermissionAudit.length > 0) {
156
+ push('Permission Audit');
157
+ for (const entry of snapshot.recentPermissionAudit.slice(0, MAX_ROWS)) {
158
+ const outcome = entry.approved === undefined ? 'pending' : entry.approved ? 'approved' : 'denied';
159
+ push(`${entry.tool} ${entry.riskLevel.toUpperCase()} ${outcome}`, entry.approved === undefined ? WARN : entry.approved ? undefined : BAD);
160
+ push(` ${entry.summary}`, DIM);
161
+ }
162
+ }
163
+ if (snapshot.lintFindings.length > 0) {
164
+ push('Policy Lint');
165
+ for (const f of snapshot.lintFindings.slice(0, MAX_ROWS)) push(`${f.severity.toUpperCase()} ${f.message}`, f.severity === 'error' ? BAD : f.severity === 'warn' ? WARN : undefined);
166
+ }
167
+ if (preflight) {
168
+ push('Preflight Review');
169
+ push(`Status ${preflight.status.toUpperCase()} Issues ${preflight.issueCount} Generated ${fmtTime(preflight.generatedAt)}`, preflight.status === 'pass' ? GOOD : preflight.status === 'warn' ? WARN : BAD);
170
+ push(preflight.summary, DIM);
171
+ for (const issue of preflight.issues.slice(0, 4)) push(`${issue.severity.toUpperCase()} ${issue.message}`, issue.severity === 'error' ? BAD : issue.severity === 'warn' ? WARN : undefined);
172
+ }
173
+ if (snapshot.lastSimulationSummary) {
174
+ const sim = snapshot.lastSimulationSummary;
175
+ push('Simulation Samples');
176
+ push(`Mode ${sim.mode} Diverged ${sim.divergentScenarios}/${sim.totalScenarios} Allowed(actual/sim) ${sim.allowedByActual}/${sim.allowedBySimulated}`, sim.divergentScenarios > 0 ? WARN : GOOD);
177
+ for (const result of sim.results.slice(0, 4)) push(`${result.scenario.label} ${(result.authoritativeDecision.allowed ? 'allow' : 'deny').toUpperCase()} ${result.diverged ? (result.divergenceType ?? 'diverged') : 'aligned'}`, result.diverged ? WARN : (result.authoritativeDecision.allowed ? GOOD : BAD));
178
+ }
179
+
180
+ return { title: 'Policy And Governance', tabs: [{ id: 'governance', label: 'Governance', header, rows }] };
181
+ }
182
+
183
+ onAction(id: string, ctx: ConfigModalActionContext): void {
184
+ // "Rollout" in the WO brief maps to the registry's actual subcommand —
185
+ // there is no separate `/policy rollout`.
186
+ const sub = id === 'simulate' ? 'simulate' : id === 'preflight' ? 'preflight' : id === 'lint' ? 'lint' : id === 'promote' ? 'promote' : id === 'rollback' ? 'rollback' : null;
187
+ if (!sub) return;
188
+ void ctx.executeCommand?.('policy', [sub]);
189
+ ctx.setStatus(`Dispatched /policy ${sub}.`);
190
+ }
191
+ }
192
+
193
+ export function createPolicyModalSurface(deps: PolicyModalDeps): ConfigModalSurface {
194
+ return new PolicyModalSurface(deps);
195
+ }
196
+
197
+ /**
198
+ * Deterministic golden fixture. A fixed `PolicyModalSnapshot` literal covering
199
+ * every rendered branch in one shot: an active current bundle, a simulating
200
+ * candidate, a 3-rule diff, a blocked divergence gate with one command-prefix
201
+ * and one tool-class breakdown row, one rolled-back history entry, one denied
202
+ * permission-audit entry, one lint warning, one preflight review with one issue,
203
+ * and one diverged simulation sample. No Date.now().
204
+ */
205
+ export function policyModalGoldenSurface(): ConfigModalSurface {
206
+ const currentBundle: PolicyBundleVersion = {
207
+ bundle: { bundleId: 'bundle-current-1', issuedAt: '2023-11-01T00:00:00.000Z', payload: { version: 1, rules: [] }, issuer: 'ops' },
208
+ provenance: { policyBundleId: 'bundle-current-1', signatureStatus: 'unsigned', provenanceSource: 'local-file', issuedAt: '2023-11-01T00:00:00.000Z', issuer: 'ops' },
209
+ rules: [{ id: 'rule-base-1', type: 'prefix', origin: 'managed', effect: 'allow', toolPattern: '*', commandPrefixes: [] }],
210
+ state: 'active', loadedAt: '2023-11-01T00:00:00.000Z', activatedAt: '2023-11-01T00:05:00.000Z',
211
+ };
212
+ const candidateBundle: PolicyBundleVersion = {
213
+ bundle: { bundleId: 'bundle-candidate-1', issuedAt: '2023-11-10T00:00:00.000Z', payload: { version: 1, rules: [] }, issuer: 'ops' },
214
+ provenance: { policyBundleId: 'bundle-candidate-1', signatureStatus: 'unsigned', provenanceSource: 'local-file', issuedAt: '2023-11-10T00:00:00.000Z', issuer: 'ops' },
215
+ rules: [
216
+ { id: 'rule-base-1', type: 'prefix', origin: 'managed', effect: 'allow', toolPattern: '*', commandPrefixes: [] },
217
+ { id: 'rule-added-1', type: 'network-scope', origin: 'user', effect: 'deny', toolPattern: 'Fetch', hostPatterns: ['*'] },
218
+ ],
219
+ state: 'simulating', loadedAt: '2023-11-10T00:00:00.000Z',
220
+ };
221
+ const historyBundle: PolicyBundleVersion = {
222
+ bundle: { bundleId: 'bundle-prev-1', issuedAt: '2023-10-01T00:00:00.000Z', payload: { version: 1, rules: [] } },
223
+ provenance: { policyBundleId: 'bundle-prev-1', signatureStatus: 'unsigned', provenanceSource: 'local-file', issuedAt: '2023-10-01T00:00:00.000Z' },
224
+ rules: [], state: 'rolled-back', loadedAt: '2023-10-01T00:00:00.000Z', activatedAt: '2023-10-01T00:05:00.000Z', rolledBackAt: '2023-11-01T00:00:00.000Z',
225
+ };
226
+ const removedRule = { id: 'rule-removed-1', type: 'path-scope' as const, origin: 'user' as const, effect: 'deny' as const, toolPattern: '*', pathPatterns: ['/tmp/**'] };
227
+ const addedRule = { id: 'rule-added-1', type: 'network-scope' as const, origin: 'user' as const, effect: 'deny' as const, toolPattern: 'Fetch', hostPatterns: ['*'] };
228
+ const changedFrom = { id: 'rule-base-1', type: 'prefix' as const, origin: 'managed' as const, effect: 'allow' as const, toolPattern: '*', commandPrefixes: [] };
229
+ const changedTo = { id: 'rule-base-1', type: 'prefix' as const, origin: 'managed' as const, effect: 'deny' as const, toolPattern: '*', commandPrefixes: [] };
230
+ const stats = (divergenceRate: number): DivergenceStats => ({ total: 1, byType: { 'allow-vs-deny': 1, 'deny-vs-allow': 0, 'reason-mismatch': 0 }, divergenceRate, totalEvaluations: 20 });
231
+ const decision = (allowed: boolean, reason: 'DEFAULT_ALLOW' | 'RULE_DENY_USER') => ({ allowed, reason, sourceLayer: 'policy' as const, toolName: 'Fetch', args: {}, timestamp: 1700000000000, evaluationTrace: [] });
232
+
233
+ const snapshot: PolicyModalSnapshot = {
234
+ current: currentBundle, candidate: candidateBundle, history: [historyBundle],
235
+ diff: { fromBundleId: 'bundle-current-1', toBundleId: 'bundle-candidate-1', removed: [removedRule], added: [addedRule], changed: [{ ruleId: 'rule-base-1', from: changedFrom, to: changedTo }], unchanged: [], totalChanges: 3 },
236
+ divergence: {
237
+ report: { overall: stats(0.1), byToolClass: { network: stats(0.1) }, byCommandPrefix: { 'fetch ': stats(0.1) }, byMode: {}, records: [] },
238
+ mode: 'warn-on-divergence',
239
+ gate: { status: 'blocked', divergenceRate: 0.1, threshold: 0.05, totalEvaluations: 20, message: 'divergence rate 10.0% exceeds 5.0% threshold' },
240
+ trend: [{ ts: 1700000000000, divergenceRate: 0.1, totalEvaluations: 20, totalDivergences: 2, gatePassing: false }],
241
+ capturedAt: 1700000000000,
242
+ },
243
+ recentPermissionAudit: [{ callId: 'call-1', tool: 'Bash', category: 'exec', approved: false, riskLevel: 'high', classification: 'destructive', summary: 'blocked rm -rf outside sandbox root', reasons: ['path escape'], requestedAt: 1700000000000, decidedAt: 1700000001000 }],
244
+ lintFindings: [{ severity: 'warn', ruleId: 'rule-added-1', message: 'toolPattern "Fetch" combined with a wildcard host pattern is broad; consider narrowing.' }],
245
+ lastSimulationSummary: {
246
+ simulatedAt: '2023-11-10T00:00:00.000Z', mode: 'warn-on-divergence', totalScenarios: 4, divergentScenarios: 1, allowedByActual: 3, allowedBySimulated: 2,
247
+ results: [{ scenario: { id: 'scenario-1', label: 'fetch external API', toolName: 'Fetch', args: {} }, actualDecision: decision(true, 'DEFAULT_ALLOW'), simulatedDecision: decision(false, 'RULE_DENY_USER'), authoritativeDecision: decision(true, 'DEFAULT_ALLOW'), diverged: true, divergenceType: 'allow-vs-deny' }],
248
+ },
249
+ lastPreflightReview: {
250
+ generatedAt: '2023-11-10T00:10:00.000Z', status: 'warn', summary: '1 candidate rule broadens network access beyond the current policy.', issueCount: 1,
251
+ issues: [{ severity: 'warn', source: 'policy', message: 'candidate rule rule-added-1 denies all outbound network access', detail: 'verify this is intentional before promotion' }],
252
+ },
253
+ capturedAt: '2023-11-10T00:15:00.000Z',
254
+ };
255
+ return createPolicyModalSurface({ policyRuntimeState: { getSnapshot: () => snapshot } });
256
+ }
@@ -0,0 +1,136 @@
1
+ import type { ConfigModalActionContext, ConfigModalRow, ConfigModalSurface, ConfigModalView } from '../../input/config-modal-types.ts';
2
+ import type { UiReadModel, UiProvidersSnapshot } from '../../runtime/ui-read-models.ts';
3
+ import type { ProviderRuntimeSnapshot } from '@pellux/goodvibes-sdk/platform/providers';
4
+ import { toneStyle, statusGlyph, pad, postureLine, kv } from './modal-surface-helpers.ts';
5
+
6
+ /** The slice of ProviderRuntimeInspectionQuery this surface consumes. */
7
+ export interface ProviderRuntimeInspect {
8
+ listProviderIds(): readonly string[];
9
+ inspectAll(): Promise<readonly ProviderRuntimeSnapshot[]>;
10
+ }
11
+
12
+ /**
13
+ * Provider-health config-modal surface (migrated from the `provider-health`
14
+ * panel — the charter's live-modal exemplar; also the target of the `providers`
15
+ * and `accounts` redirects). providerRuntime.inspectAll() is async, so its
16
+ * result is cached and refreshed on open, on `r`, and on a 3s live tick (the
17
+ * panel's display-tick cadence); buildView reads the cache synchronously so
18
+ * live status/model-count values update in place with a stable layout.
19
+ *
20
+ * Fidelity boundary (stated divergence): the panel's per-request latency
21
+ * timelines, auth-route table, and 8 repair-domain posture blocks are driven by
22
+ * an event-fed tracker + account-posture snapshots; those deep views remain on
23
+ * the `/health` and `/accounts` commands (which print them in full). The modal
24
+ * ports the live provider status list + the panel's one dispatch action
25
+ * (Enter → `/accounts repair <provider>`), across a Health and an Accounts tab.
26
+ */
27
+ class ProviderHealthModalSurface implements ConfigModalSurface {
28
+ readonly name = 'providers-modal';
29
+ readonly title = 'Providers';
30
+ private requestRender: () => void = () => {};
31
+ private cache = new Map<string, ProviderRuntimeSnapshot>();
32
+ private timer: ReturnType<typeof setInterval> | null = null;
33
+ private unsub: (() => void) | null = null;
34
+ private loaded = false;
35
+
36
+ constructor(
37
+ private readonly providerRuntime: ProviderRuntimeInspect,
38
+ private readonly providersReadModel?: UiReadModel<UiProvidersSnapshot>,
39
+ ) {}
40
+
41
+ readonly actions = [
42
+ { key: 'enter', id: 'repair', label: 'repair' },
43
+ { key: 'r', id: 'refresh', label: 'refresh posture' },
44
+ ];
45
+
46
+ onOpen(requestRender: () => void): void {
47
+ this.requestRender = requestRender;
48
+ void this.reinspect();
49
+ if (this.providersReadModel && !this.unsub) this.unsub = this.providersReadModel.subscribe(() => this.requestRender());
50
+ if (this.timer === null) this.timer = setInterval(() => { void this.reinspect(); }, 3_000);
51
+ }
52
+
53
+ onClose(): void {
54
+ if (this.timer !== null) { clearInterval(this.timer); this.timer = null; }
55
+ this.unsub?.();
56
+ this.unsub = null;
57
+ }
58
+
59
+ private providerIds(): string[] {
60
+ const ids = new Set<string>([
61
+ ...(this.providersReadModel?.getSnapshot().providerIds ?? []),
62
+ ...this.providerRuntime.listProviderIds(),
63
+ ...this.cache.keys(),
64
+ ]);
65
+ return [...ids].sort((a, b) => a.localeCompare(b));
66
+ }
67
+
68
+ buildView(): ConfigModalView {
69
+ const ids = this.providerIds();
70
+ const active = ids.filter((id) => this.cache.get(id)?.active).length;
71
+ const header = [postureLine([
72
+ kv('providers', ids.length),
73
+ kv('active', active),
74
+ kv('inspected', this.cache.size),
75
+ ])];
76
+
77
+ const rowFor = (id: string): ConfigModalRow => {
78
+ const snap = this.cache.get(id);
79
+ const isActive = Boolean(snap?.active);
80
+ return {
81
+ id: `provider:${id}`,
82
+ label: `${statusGlyph(isActive ? 'good' : 'dim')} ${pad(id, 18)} ${pad(isActive ? 'ACTIVE' : 'idle', 8)} models=${snap?.modelCount ?? '—'}`,
83
+ style: toneStyle(isActive ? 'good' : 'dim'),
84
+ };
85
+ };
86
+
87
+ const rows = ids.map(rowFor);
88
+ const emptyText = ids.length === 0
89
+ ? (this.loaded ? 'No providers registered. Try /provider or /subscription.' : 'Inspecting providers…')
90
+ : undefined;
91
+
92
+ return {
93
+ title: 'Providers',
94
+ tabs: [
95
+ { id: 'health', label: 'Health', header, rows, emptyText, hints: ['r refresh posture', '/health for latency & routes'] },
96
+ { id: 'accounts', label: 'Accounts', header, rows: ids.map(rowFor), emptyText, hints: ['Enter repair', '/accounts routes <p> for detail'] },
97
+ ],
98
+ };
99
+ }
100
+
101
+ onAction(id: string, ctx: ConfigModalActionContext): void {
102
+ if (id === 'refresh') {
103
+ void this.reinspect();
104
+ ctx.setStatus('Refreshing provider posture…');
105
+ return;
106
+ }
107
+ if (id === 'repair') {
108
+ const provider = ctx.row?.id.startsWith('provider:') ? ctx.row.id.slice('provider:'.length) : null;
109
+ if (!provider) return;
110
+ void ctx.executeCommand?.('accounts', ['repair', provider]);
111
+ ctx.setStatus(`Dispatched /accounts repair ${provider} (see transcript).`);
112
+ }
113
+ }
114
+
115
+ private async reinspect(): Promise<void> {
116
+ try {
117
+ const snapshots = await this.providerRuntime.inspectAll();
118
+ const next = new Map<string, ProviderRuntimeSnapshot>();
119
+ for (const s of snapshots) next.set(s.providerId, s);
120
+ this.cache = next;
121
+ } catch {
122
+ // Leave the last-known cache in place; a transient inspect failure should
123
+ // not blank the live table (honest degraded behaviour).
124
+ } finally {
125
+ this.loaded = true;
126
+ this.requestRender();
127
+ }
128
+ }
129
+ }
130
+
131
+ export function createProviderHealthModalSurface(
132
+ providerRuntime: ProviderRuntimeInspect,
133
+ providersReadModel?: UiReadModel<UiProvidersSnapshot>,
134
+ ): ConfigModalSurface {
135
+ return new ProviderHealthModalSurface(providerRuntime, providersReadModel);
136
+ }
@@ -0,0 +1,115 @@
1
+ import type { ConfigModalActionContext, ConfigModalSurface, ConfigModalView } from '../../input/config-modal-types.ts';
2
+ import type { UiReadModel, UiRemoteSnapshot } from '../../runtime/ui-read-models.ts';
3
+ import { toneStyle, statusGlyph, pad, postureLine, kv, type Tone } from './modal-surface-helpers.ts';
4
+
5
+ function stateTone(state: string): Tone {
6
+ switch (state) {
7
+ case 'connected':
8
+ case 'syncing':
9
+ return 'good';
10
+ case 'degraded':
11
+ case 'reconnecting':
12
+ case 'authenticating':
13
+ case 'initializing':
14
+ return 'warn';
15
+ case 'terminal_failure':
16
+ return 'bad';
17
+ default:
18
+ return 'dim';
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Remote config-modal surface (migrated from the `remote` panel). The read-model
24
+ * snapshot is synchronous; a subscribe() keeps live transport/heartbeat values
25
+ * refreshing between key presses. The panel's `Tab` connections/contracts browse
26
+ * toggle becomes two real tabs. `r`/Enter dispatch `/remote recover` for the
27
+ * selected runner — the panel's only corrective keybinding; the richer
28
+ * `/remote` command set (show/supervisor/cancel/dispatch/artifact/…) stays on
29
+ * the command front-door.
30
+ */
31
+ class RemoteModalSurface implements ConfigModalSurface {
32
+ readonly name = 'remote-modal';
33
+ readonly title = 'Remote';
34
+ private requestRender: () => void = () => {};
35
+ private unsub: (() => void) | null = null;
36
+
37
+ constructor(private readonly readModel?: UiReadModel<UiRemoteSnapshot>) {}
38
+
39
+ readonly actions = [
40
+ { key: 'r', id: 'recover', label: 'recover' },
41
+ { key: 'enter', id: 'recover', label: 'recover' },
42
+ ];
43
+
44
+ onOpen(requestRender: () => void): void {
45
+ this.requestRender = requestRender;
46
+ if (this.readModel && !this.unsub) this.unsub = this.readModel.subscribe(() => this.requestRender());
47
+ }
48
+
49
+ onClose(): void {
50
+ this.unsub?.();
51
+ this.unsub = null;
52
+ }
53
+
54
+ buildView(): ConfigModalView {
55
+ const snapshot = this.readModel?.getSnapshot();
56
+ if (!snapshot) {
57
+ return {
58
+ title: 'Remote',
59
+ degraded: 'Runtime store not wired into this runtime.',
60
+ tabs: [{ id: 'connections', label: 'Connections', rows: [], emptyText: 'Try /remote setup.' }],
61
+ };
62
+ }
63
+ const { daemon, acp, contracts, artifacts, supervisor, distributed } = snapshot;
64
+ const peersConnected = distributed.peers.filter((p) => p.status === 'connected').length;
65
+ const header = [
66
+ postureLine([
67
+ `daemon ${daemon.transportState.toUpperCase()}`,
68
+ kv('running', daemon.isRunning ? 'yes' : 'no'),
69
+ kv('reconnects', daemon.reconnectAttempts),
70
+ kv('jobs', daemon.runningJobCount),
71
+ ]),
72
+ postureLine([
73
+ `acp ${acp.transportState.toUpperCase()}`,
74
+ kv('conns', acp.activeConnections.length),
75
+ kv('contracts', contracts.length),
76
+ kv('artifacts', artifacts.length),
77
+ kv('sessions', supervisor.sessions.length),
78
+ kv('peers', `${peersConnected}/${distributed.peers.length}`),
79
+ ]),
80
+ ];
81
+
82
+ const connectionRows = acp.activeConnections.map((c) => ({
83
+ id: `conn:${c.agentId}`,
84
+ label: `${statusGlyph(stateTone(c.transportState))} ${pad(c.agentId, 20)} ${pad(c.transportState.toUpperCase(), 14)} msgs=${c.messageCount} errs=${c.errorCount} ${c.label ?? ''}`,
85
+ style: toneStyle(stateTone(c.transportState)),
86
+ }));
87
+
88
+ const contractRows = contracts.map((ct) => ({
89
+ id: `contract:${ct.runnerId}`,
90
+ label: `${statusGlyph(stateTone(ct.transport.state))} ${pad(ct.runnerId, 20)} ${pad(ct.transport.state.toUpperCase(), 14)} ${ct.template ?? ''}`,
91
+ style: toneStyle(stateTone(ct.transport.state)),
92
+ }));
93
+
94
+ return {
95
+ title: 'Remote',
96
+ tabs: [
97
+ { id: 'connections', label: 'Connections', header, rows: connectionRows, emptyText: 'No active ACP or remote subagent connections.', hints: ['r recover'] },
98
+ { id: 'contracts', label: 'Contracts', header, rows: contractRows, emptyText: 'No registered remote runner contracts.', hints: ['r recover'] },
99
+ ],
100
+ };
101
+ }
102
+
103
+ onAction(id: string, ctx: ConfigModalActionContext): void {
104
+ if (id !== 'recover') return;
105
+ const rowId = ctx.row?.id ?? '';
106
+ const runner = rowId.includes(':') ? rowId.slice(rowId.indexOf(':') + 1) : '';
107
+ void ctx.executeCommand?.('remote', runner ? ['recover', runner] : ['recover']);
108
+ ctx.setStatus(runner ? `Recovering ${runner}…` : 'Recovering…');
109
+ this.requestRender();
110
+ }
111
+ }
112
+
113
+ export function createRemoteModalSurface(readModel?: UiReadModel<UiRemoteSnapshot>): ConfigModalSurface {
114
+ return new RemoteModalSurface(readModel);
115
+ }
@@ -0,0 +1,150 @@
1
+ import type { ConfigModalAction, ConfigModalActionContext, ConfigModalRow, ConfigModalSurface, ConfigModalView } from '../../input/config-modal-types.ts';
2
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
3
+ import { buildSandboxReview, listSandboxProfiles } from '@/runtime/index.ts';
4
+ import type { SandboxProfile, SandboxSession, SandboxSessionRegistry } from '@/runtime/index.ts';
5
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
6
+ import { toneStyle, statusGlyph, pad, postureLine, kv, type Tone } from './modal-surface-helpers.ts';
7
+
8
+ /** SandboxSessionRegistry has no event subscription — poll at the same 3s
9
+ * cadence the retired panel used (POLL_INTERVAL_MS in sandbox-panel.ts). */
10
+ const POLL_INTERVAL_MS = 3_000;
11
+
12
+ function sessionTone(state: SandboxSession['state']): Tone {
13
+ switch (state) {
14
+ case 'running': return 'good';
15
+ case 'failed': return 'bad';
16
+ case 'planned': return 'warn';
17
+ case 'stopped': return 'dim';
18
+ default: return 'dim';
19
+ }
20
+ }
21
+
22
+ function profileIdOf(row: ConfigModalRow | null): SandboxProfile['id'] | null {
23
+ return row?.id.startsWith('profile:') ? (row.id.slice('profile:'.length) as SandboxProfile['id']) : null;
24
+ }
25
+
26
+ function sessionIdOf(row: ConfigModalRow | null): string | null {
27
+ return row?.id.startsWith('session:') ? row.id.slice('session:'.length) : null;
28
+ }
29
+
30
+ /**
31
+ * Sandbox config-modal surface (migrated from the `sandbox` panel). All reads
32
+ * (buildSandboxReview, listSandboxProfiles, sessions.list) are synchronous, so
33
+ * buildView reads them live each tick. The panel's single profile+session
34
+ * selectable list becomes two real tabs. s/x/e call the SandboxSessionRegistry
35
+ * directly (these were never slash commands) — start/execute are wrapped so a
36
+ * rejection surfaces via ctx.setStatus instead of an unhandled rejection,
37
+ * matching the panel's try/catch + setError behaviour.
38
+ */
39
+ class SandboxModalSurface implements ConfigModalSurface {
40
+ readonly name = 'sandbox-modal';
41
+ readonly title = 'Sandbox';
42
+ private requestRender: () => void;
43
+ private timer: ReturnType<typeof setInterval> | null = null;
44
+
45
+ constructor(
46
+ private readonly config: ConfigManager,
47
+ private readonly sessions: SandboxSessionRegistry,
48
+ requestRender: () => void = () => {},
49
+ ) {
50
+ this.requestRender = requestRender;
51
+ }
52
+
53
+ readonly actions: ConfigModalAction[] = [
54
+ { key: 's', id: 'start', label: 'start', enabledFor: (row, tabId) => tabId === 'profiles' && profileIdOf(row) !== null },
55
+ { key: 'x', id: 'stop', label: 'stop', confirm: true, enabledFor: (row, tabId) => tabId === 'sessions' && sessionIdOf(row) !== null },
56
+ { key: 'e', id: 'execute', label: 'execute probe', enabledFor: (row, tabId) => tabId === 'sessions' && sessionIdOf(row) !== null },
57
+ ];
58
+
59
+ onOpen(requestRender: () => void): void {
60
+ this.requestRender = requestRender;
61
+ if (this.timer === null) this.timer = setInterval(() => this.requestRender(), POLL_INTERVAL_MS);
62
+ }
63
+
64
+ onClose(): void {
65
+ if (this.timer !== null) { clearInterval(this.timer); this.timer = null; }
66
+ }
67
+
68
+ buildView(): ConfigModalView {
69
+ try {
70
+ const review = buildSandboxReview(this.config);
71
+ const profiles = listSandboxProfiles(this.config);
72
+ const sessions = this.sessions.list();
73
+
74
+ const header = [postureLine([
75
+ kv('platform', review.host.platform),
76
+ kv('backend', review.config.vmBackend),
77
+ kv('sessions', sessions.length),
78
+ kv('ready', review.host.secureSandboxReady ? 'yes' : 'no'),
79
+ ])];
80
+
81
+ const profileRows: ConfigModalRow[] = profiles.map((profile) => ({
82
+ id: `profile:${profile.id}`,
83
+ label: `${pad(profile.id, 14)} ${pad(profile.kind, 6)} ${pad(profile.isolation, 10)} vm=${profile.requiresVm ? 'yes' : 'no'}`,
84
+ }));
85
+
86
+ const sessionRows: ConfigModalRow[] = sessions.map((session) => ({
87
+ id: `session:${session.id}`,
88
+ label: `${statusGlyph(sessionTone(session.state))} ${pad(session.id, 20)} ${pad(session.state.toUpperCase(), 9)} backend=${pad(session.resolvedBackend ?? session.backend, 6)} runs=${session.executionCount ?? 0}`,
89
+ style: toneStyle(sessionTone(session.state)),
90
+ }));
91
+
92
+ return {
93
+ title: 'Sandbox',
94
+ tabs: [
95
+ { id: 'profiles', label: 'Profiles', header, rows: profileRows, emptyText: 'No sandbox profiles available.', hints: ['s start'] },
96
+ { id: 'sessions', label: 'Sessions', header, rows: sessionRows, emptyText: 'No active sandbox sessions.', hints: ['x stop', 'e execute probe'] },
97
+ ],
98
+ };
99
+ } catch (error) {
100
+ return {
101
+ title: 'Sandbox',
102
+ degraded: `Sandbox posture unavailable: ${summarizeError(error)}`,
103
+ tabs: [
104
+ { id: 'profiles', label: 'Profiles', rows: [] },
105
+ { id: 'sessions', label: 'Sessions', rows: [] },
106
+ ],
107
+ };
108
+ }
109
+ }
110
+
111
+ onAction(id: string, ctx: ConfigModalActionContext): void {
112
+ switch (id) {
113
+ case 'start': {
114
+ const profileId = profileIdOf(ctx.row);
115
+ if (!profileId) return;
116
+ void this.sessions.start(profileId, undefined, this.config)
117
+ .catch((error: unknown) => ctx.setStatus(summarizeError(error)))
118
+ .finally(() => this.requestRender());
119
+ return;
120
+ }
121
+ case 'stop': {
122
+ const sessionId = sessionIdOf(ctx.row);
123
+ if (!sessionId) return;
124
+ this.sessions.stop(sessionId);
125
+ break;
126
+ }
127
+ case 'execute': {
128
+ const sessionId = sessionIdOf(ctx.row);
129
+ if (!sessionId) return;
130
+ try {
131
+ this.sessions.execute(sessionId, process.execPath, ['-e', "console.log('sandbox modal probe ok')"], this.config, { timeoutMs: 5_000 });
132
+ } catch (error) {
133
+ ctx.setStatus(summarizeError(error));
134
+ }
135
+ break;
136
+ }
137
+ default:
138
+ return;
139
+ }
140
+ this.requestRender();
141
+ }
142
+ }
143
+
144
+ export function createSandboxModalSurface(
145
+ config: ConfigManager,
146
+ sessions: SandboxSessionRegistry,
147
+ requestRender: () => void = () => {},
148
+ ): ConfigModalSurface {
149
+ return new SandboxModalSurface(config, sessions, requestRender);
150
+ }