@pellux/goodvibes-tui 1.1.0 → 1.7.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 (194) hide show
  1. package/CHANGELOG.md +85 -21
  2. package/README.md +20 -11
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +2 -2
  5. package/src/core/alert-gating.ts +67 -0
  6. package/src/core/approval-alert.ts +72 -0
  7. package/src/core/budget-breach-notifier.ts +106 -0
  8. package/src/core/conversation-rendering.ts +19 -0
  9. package/src/core/conversation.ts +18 -0
  10. package/src/core/focus-tracker.ts +41 -0
  11. package/src/core/long-task-notifier.ts +33 -6
  12. package/src/core/system-message-router.ts +37 -51
  13. package/src/core/turn-cancellation.ts +20 -0
  14. package/src/core/turn-event-wiring.ts +64 -3
  15. package/src/export/cost-utils.ts +36 -0
  16. package/src/input/command-registry.ts +38 -1
  17. package/src/input/commands/checkpoint-runtime.ts +280 -0
  18. package/src/input/commands/codebase-runtime.ts +192 -0
  19. package/src/input/commands/eval.ts +1 -1
  20. package/src/input/commands/health-runtime.ts +1 -1
  21. package/src/input/commands/image-runtime.ts +112 -0
  22. package/src/input/commands/intelligence-runtime.ts +11 -1
  23. package/src/input/commands/local-auth-runtime.ts +4 -1
  24. package/src/input/commands/local-runtime.ts +9 -3
  25. package/src/input/commands/marketplace-runtime.ts +2 -2
  26. package/src/input/commands/memory.ts +6 -1
  27. package/src/input/commands/operator-panel-runtime.ts +40 -24
  28. package/src/input/commands/planning-runtime.ts +26 -3
  29. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  30. package/src/input/commands/plugin-runtime.ts +2 -2
  31. package/src/input/commands/policy-dispatch.ts +21 -0
  32. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  33. package/src/input/commands/qrcode-runtime.ts +3 -3
  34. package/src/input/commands/recall-review.ts +35 -0
  35. package/src/input/commands/remote-runtime.ts +3 -3
  36. package/src/input/commands/runtime-services.ts +54 -13
  37. package/src/input/commands/services-runtime.ts +1 -1
  38. package/src/input/commands/session-workflow.ts +16 -1
  39. package/src/input/commands/settings-sync-runtime.ts +1 -1
  40. package/src/input/commands/shell-core.ts +15 -7
  41. package/src/input/commands/skills-runtime.ts +2 -8
  42. package/src/input/commands/subscription-runtime.ts +2 -2
  43. package/src/input/commands/test-runtime.ts +277 -0
  44. package/src/input/commands/websearch-runtime.ts +101 -0
  45. package/src/input/commands/work-plan-runtime.ts +36 -10
  46. package/src/input/commands/workstream-runtime.ts +338 -0
  47. package/src/input/commands.ts +12 -0
  48. package/src/input/config-modal-types.ts +161 -0
  49. package/src/input/config-modal.ts +377 -0
  50. package/src/input/feed-context-factory.ts +7 -8
  51. package/src/input/handler-command-route.ts +27 -17
  52. package/src/input/handler-feed-routes.ts +61 -23
  53. package/src/input/handler-feed.ts +47 -23
  54. package/src/input/handler-interactions.ts +1 -3
  55. package/src/input/handler-modal-routes.ts +65 -0
  56. package/src/input/handler-modal-stack.ts +3 -5
  57. package/src/input/handler-modal-token-routes.ts +16 -49
  58. package/src/input/handler-picker-routes.ts +11 -94
  59. package/src/input/handler-shortcuts.ts +24 -14
  60. package/src/input/handler-types.ts +2 -6
  61. package/src/input/handler-ui-state.ts +10 -22
  62. package/src/input/handler.ts +6 -28
  63. package/src/input/keybindings.ts +22 -8
  64. package/src/input/model-picker-types.ts +24 -6
  65. package/src/input/model-picker.ts +58 -0
  66. package/src/input/panel-integration-actions.ts +9 -170
  67. package/src/input/settings-modal-data.ts +95 -0
  68. package/src/input/settings-modal-mutations.ts +6 -4
  69. package/src/main.ts +24 -27
  70. package/src/panels/agent-inspector-shared.ts +2 -1
  71. package/src/panels/base-panel.ts +22 -1
  72. package/src/panels/builtin/agent.ts +21 -107
  73. package/src/panels/builtin/development.ts +15 -61
  74. package/src/panels/builtin/knowledge.ts +8 -32
  75. package/src/panels/builtin/operations.ts +84 -428
  76. package/src/panels/builtin/session.ts +21 -112
  77. package/src/panels/builtin/shared.ts +9 -43
  78. package/src/panels/builtin-modals.ts +218 -0
  79. package/src/panels/builtin-panels.ts +5 -0
  80. package/src/panels/cost-tracker-panel.ts +36 -10
  81. package/src/panels/diff-panel.ts +12 -43
  82. package/src/panels/eval-registry.ts +60 -0
  83. package/src/panels/fleet-panel.ts +800 -0
  84. package/src/panels/fleet-read-model.ts +477 -0
  85. package/src/panels/fleet-steer.ts +92 -0
  86. package/src/panels/fleet-stop.ts +125 -0
  87. package/src/panels/fleet-tabs.ts +230 -0
  88. package/src/panels/fleet-transcript.ts +356 -0
  89. package/src/panels/index.ts +7 -31
  90. package/src/panels/modals/hooks-modal.ts +187 -0
  91. package/src/panels/modals/keybindings-modal.ts +151 -0
  92. package/src/panels/modals/knowledge-modal.ts +158 -0
  93. package/src/panels/modals/local-auth-modal.ts +132 -0
  94. package/src/panels/modals/marketplace-modal.ts +218 -0
  95. package/src/panels/modals/memory-modal.ts +180 -0
  96. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  97. package/src/panels/modals/modal-theme.ts +36 -0
  98. package/src/panels/modals/pairing-modal.ts +144 -0
  99. package/src/panels/modals/planning-modal.ts +282 -0
  100. package/src/panels/modals/plugins-modal.ts +174 -0
  101. package/src/panels/modals/policy-modal.ts +256 -0
  102. package/src/panels/modals/provider-health-modal.ts +136 -0
  103. package/src/panels/modals/remote-modal.ts +115 -0
  104. package/src/panels/modals/sandbox-modal.ts +150 -0
  105. package/src/panels/modals/security-modal.ts +217 -0
  106. package/src/panels/modals/services-modal.ts +179 -0
  107. package/src/panels/modals/settings-sync-modal.ts +142 -0
  108. package/src/panels/modals/skills-modal.ts +189 -0
  109. package/src/panels/modals/subscription-modal.ts +172 -0
  110. package/src/panels/modals/work-plan-modal.ts +149 -0
  111. package/src/panels/panel-confirm-overlay.ts +72 -0
  112. package/src/panels/panel-manager.ts +108 -5
  113. package/src/panels/project-planning-answer-actions.ts +4 -2
  114. package/src/panels/skills-panel.ts +7 -51
  115. package/src/panels/types.ts +13 -0
  116. package/src/permissions/hunk-selection.ts +179 -0
  117. package/src/permissions/prompt.ts +60 -4
  118. package/src/renderer/compaction-history-modal.ts +19 -3
  119. package/src/renderer/compaction-preview.ts +13 -2
  120. package/src/renderer/compaction-quality.ts +100 -0
  121. package/src/renderer/config-modal.ts +81 -0
  122. package/src/renderer/conversation-overlays.ts +10 -17
  123. package/src/renderer/fleet-tab-strip.ts +56 -0
  124. package/src/renderer/footer-tips.ts +10 -2
  125. package/src/renderer/git-status.ts +40 -0
  126. package/src/renderer/help-overlay.ts +2 -2
  127. package/src/renderer/model-workspace.ts +44 -1
  128. package/src/renderer/panel-workspace-bar.ts +8 -2
  129. package/src/renderer/turn-injection.ts +114 -0
  130. package/src/runtime/bootstrap-command-context.ts +21 -1
  131. package/src/runtime/bootstrap-command-parts.ts +34 -7
  132. package/src/runtime/bootstrap-core.ts +14 -4
  133. package/src/runtime/bootstrap-shell.ts +29 -16
  134. package/src/runtime/bootstrap.ts +11 -17
  135. package/src/runtime/code-index-services.ts +112 -0
  136. package/src/runtime/orchestrator-core-services.ts +49 -0
  137. package/src/runtime/process-lifecycle.ts +3 -1
  138. package/src/runtime/services.ts +91 -43
  139. package/src/runtime/ui-services.ts +8 -0
  140. package/src/runtime/workstream-services.ts +195 -0
  141. package/src/shell/blocking-input.ts +22 -1
  142. package/src/shell/ui-openers.ts +129 -17
  143. package/src/utils/format-duration.ts +8 -18
  144. package/src/utils/splash-lines.ts +1 -1
  145. package/src/version.ts +1 -1
  146. package/src/panels/agent-inspector-panel.ts +0 -786
  147. package/src/panels/approval-panel.ts +0 -252
  148. package/src/panels/automation-control-panel.ts +0 -479
  149. package/src/panels/cockpit-panel.ts +0 -481
  150. package/src/panels/cockpit-read-model.ts +0 -238
  151. package/src/panels/communication-panel.ts +0 -256
  152. package/src/panels/control-plane-panel.ts +0 -470
  153. package/src/panels/debug-panel.ts +0 -615
  154. package/src/panels/docs-panel.ts +0 -384
  155. package/src/panels/eval-panel.ts +0 -627
  156. package/src/panels/file-explorer-panel.ts +0 -673
  157. package/src/panels/file-preview-panel.ts +0 -517
  158. package/src/panels/hooks-panel.ts +0 -401
  159. package/src/panels/incident-review-panel.ts +0 -406
  160. package/src/panels/intelligence-panel.ts +0 -383
  161. package/src/panels/knowledge-graph-panel.ts +0 -515
  162. package/src/panels/marketplace-panel.ts +0 -399
  163. package/src/panels/memory-panel.ts +0 -558
  164. package/src/panels/ops-control-panel.ts +0 -329
  165. package/src/panels/ops-strategy-panel.ts +0 -321
  166. package/src/panels/orchestration-panel.ts +0 -465
  167. package/src/panels/panel-list-panel.ts +0 -566
  168. package/src/panels/plan-dashboard-panel.ts +0 -707
  169. package/src/panels/policy-panel.ts +0 -517
  170. package/src/panels/project-planning-panel.ts +0 -731
  171. package/src/panels/provider-health-panel.ts +0 -678
  172. package/src/panels/provider-health-tracker.ts +0 -310
  173. package/src/panels/provider-health-views.ts +0 -567
  174. package/src/panels/qr-panel.ts +0 -280
  175. package/src/panels/remote-panel.ts +0 -534
  176. package/src/panels/routes-panel.ts +0 -241
  177. package/src/panels/sandbox-panel.ts +0 -456
  178. package/src/panels/security-panel.ts +0 -447
  179. package/src/panels/services-panel.ts +0 -329
  180. package/src/panels/session-browser-panel.ts +0 -496
  181. package/src/panels/settings-sync-panel.ts +0 -398
  182. package/src/panels/subscription-panel.ts +0 -342
  183. package/src/panels/symbol-outline-panel.ts +0 -619
  184. package/src/panels/system-messages-panel.ts +0 -364
  185. package/src/panels/tasks-panel.ts +0 -608
  186. package/src/panels/thinking-panel.ts +0 -333
  187. package/src/panels/tool-inspector-panel.ts +0 -537
  188. package/src/panels/work-plan-panel.ts +0 -540
  189. package/src/panels/worktree-panel.ts +0 -360
  190. package/src/panels/wrfc-panel.ts +0 -790
  191. package/src/renderer/agent-detail-modal.ts +0 -466
  192. package/src/renderer/live-tail-modal.ts +0 -156
  193. package/src/renderer/process-modal.ts +0 -671
  194. 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
+ }