@pellux/goodvibes-tui 1.13.1 → 1.15.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 (158) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +6 -6
  3. package/bin/goodvibes +6 -0
  4. package/bin/goodvibes-daemon +6 -0
  5. package/bin/launcher-support.js +34 -1
  6. package/docs/foundation-artifacts/operator-contract.json +8076 -2047
  7. package/package.json +15 -11
  8. package/scripts/postinstall.js +73 -48
  9. package/src/cli/bundle-command.ts +5 -5
  10. package/src/cli/completion.ts +1 -1
  11. package/src/cli/completions/generate.ts +15 -3
  12. package/src/cli/doctor.ts +385 -0
  13. package/src/cli/entrypoint.ts +72 -1
  14. package/src/cli/help.ts +29 -9
  15. package/src/cli/hooks-command.ts +74 -0
  16. package/src/cli/hooks-report.ts +167 -0
  17. package/src/cli/index.ts +3 -0
  18. package/src/cli/management.ts +13 -1
  19. package/src/cli/parser.ts +10 -2
  20. package/src/cli/plugin-bundles-command.ts +228 -0
  21. package/src/cli/plugin-command.ts +179 -0
  22. package/src/cli/service-posture.ts +10 -0
  23. package/src/cli/status.ts +275 -2
  24. package/src/cli/tui-startup.ts +119 -1
  25. package/src/cli/types.ts +3 -1
  26. package/src/config/index.ts +16 -0
  27. package/src/config/tui-extension-settings.ts +169 -0
  28. package/src/core/approval-alert.ts +15 -2
  29. package/src/core/compaction-receipt.ts +127 -0
  30. package/src/core/model-routing-chip.ts +82 -0
  31. package/src/core/permission-mode.ts +89 -0
  32. package/src/core/rewind-receipt.ts +83 -0
  33. package/src/core/rewind-turn-anchors.ts +160 -0
  34. package/src/core/scriptable-statusline.ts +141 -0
  35. package/src/core/session-auto-titler.ts +110 -0
  36. package/src/core/stream-event-wiring.ts +20 -0
  37. package/src/core/system-message-router.ts +1 -1
  38. package/src/core/terminal-notifier.ts +102 -0
  39. package/src/core/turn-event-wiring.ts +80 -1
  40. package/src/input/command-reference.ts +96 -0
  41. package/src/input/command-registry.ts +25 -0
  42. package/src/input/commands/channel-pairing.ts +245 -0
  43. package/src/input/commands/channel-runtime.ts +152 -6
  44. package/src/input/commands/checkin-runtime.ts +89 -0
  45. package/src/input/commands/checkpoint-runtime.ts +7 -179
  46. package/src/input/commands/ci-runtime.ts +207 -0
  47. package/src/input/commands/cost-attribution-format.ts +52 -0
  48. package/src/input/commands/cost-runtime.ts +44 -4
  49. package/src/input/commands/editor-runtime.ts +22 -0
  50. package/src/input/commands/flags-runtime.ts +328 -0
  51. package/src/input/commands/health-metrics-format.ts +67 -0
  52. package/src/input/commands/health-runtime.ts +55 -1
  53. package/src/input/commands/memory.ts +23 -0
  54. package/src/input/commands/operator-rpc.ts +95 -0
  55. package/src/input/commands/palette-runtime.ts +22 -0
  56. package/src/input/commands/permissions-provenance.ts +264 -0
  57. package/src/input/commands/permissions-runtime.ts +21 -0
  58. package/src/input/commands/planning-runtime.ts +28 -1
  59. package/src/input/commands/platform-access-runtime.ts +2 -97
  60. package/src/input/commands/principals-runtime.ts +212 -0
  61. package/src/input/commands/product-runtime.ts +43 -3
  62. package/src/input/commands/recall-files-config.ts +54 -0
  63. package/src/input/commands/recall-files-git.ts +57 -0
  64. package/src/input/commands/recall-files.ts +185 -0
  65. package/src/input/commands/recall-query.ts +8 -3
  66. package/src/input/commands/recall-review.ts +2 -2
  67. package/src/input/commands/recall-shared.ts +13 -0
  68. package/src/input/commands/relay-runtime.ts +86 -0
  69. package/src/input/commands/review-runtime.ts +160 -0
  70. package/src/input/commands/rewind-runtime.ts +362 -0
  71. package/src/input/commands/schedule-nl.ts +147 -0
  72. package/src/input/commands/schedule-runtime.ts +29 -10
  73. package/src/input/commands/secret-runtime.ts +55 -0
  74. package/src/input/commands/session-content.ts +19 -8
  75. package/src/input/commands/session-workflow.ts +10 -0
  76. package/src/input/commands/update-runtime.ts +412 -0
  77. package/src/input/commands/workstream-attempt-dependents.ts +103 -0
  78. package/src/input/commands/workstream-attempts.ts +244 -0
  79. package/src/input/commands/workstream-runtime.ts +164 -26
  80. package/src/input/commands/worktree-runtime.ts +56 -2
  81. package/src/input/commands.ts +135 -80
  82. package/src/input/composer-capture.ts +67 -0
  83. package/src/input/composer-editor.ts +134 -0
  84. package/src/input/concealed-input.ts +94 -0
  85. package/src/input/exec-env-scrub-config.ts +84 -0
  86. package/src/input/feature-unit-layout.ts +142 -0
  87. package/src/input/feed-context-factory.ts +2 -0
  88. package/src/input/handler-feed-routes.ts +17 -0
  89. package/src/input/handler-feed.ts +3 -0
  90. package/src/input/handler-modal-routes.ts +5 -2
  91. package/src/input/handler-shortcuts.ts +37 -0
  92. package/src/input/handler.ts +24 -2
  93. package/src/input/keybindings.ts +11 -2
  94. package/src/input/onboarding/onboarding-feature-units.ts +536 -0
  95. package/src/input/onboarding/onboarding-wizard-apply.ts +40 -6
  96. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +324 -629
  97. package/src/input/onboarding/onboarding-wizard-steps.ts +5 -5
  98. package/src/input/onboarding/onboarding-wizard-types.ts +6 -0
  99. package/src/input/relay-settings-descriptions.ts +35 -0
  100. package/src/input/sandbox-exec-config.ts +107 -0
  101. package/src/input/settings-modal-adjustment.ts +4 -7
  102. package/src/input/settings-modal-data.ts +178 -8
  103. package/src/input/settings-modal-mutations.ts +41 -13
  104. package/src/input/settings-modal-types.ts +42 -5
  105. package/src/input/settings-modal.ts +67 -15
  106. package/src/input/submission-router.ts +4 -1
  107. package/src/input/worktree-setup-config.ts +93 -0
  108. package/src/main.ts +43 -43
  109. package/src/panels/builtin/development.ts +14 -0
  110. package/src/panels/diff-review-model.ts +189 -0
  111. package/src/panels/diff-review-panel.ts +395 -0
  112. package/src/panels/fleet-panel-format.ts +114 -2
  113. package/src/panels/fleet-panel.ts +65 -78
  114. package/src/panels/fleet-read-model.ts +91 -11
  115. package/src/panels/fleet-stop.ts +19 -4
  116. package/src/panels/modals/memory-modal.ts +11 -2
  117. package/src/permissions/hunk-selection.ts +86 -5
  118. package/src/permissions/prompt.ts +108 -7
  119. package/src/permissions/sandbox-exec-gate.ts +162 -0
  120. package/src/renderer/settings-modal-helpers.ts +21 -1
  121. package/src/renderer/settings-modal.ts +70 -44
  122. package/src/renderer/shell-surface.ts +23 -1
  123. package/src/renderer/system-message.ts +4 -0
  124. package/src/renderer/tool-result-summary.ts +95 -5
  125. package/src/renderer/ui-factory.ts +22 -0
  126. package/src/runtime/bootstrap-command-context.ts +15 -1
  127. package/src/runtime/bootstrap-command-parts.ts +14 -2
  128. package/src/runtime/bootstrap-core.ts +38 -38
  129. package/src/runtime/bootstrap-notifier-sync.ts +41 -0
  130. package/src/runtime/bootstrap-shell.ts +8 -0
  131. package/src/runtime/bootstrap.ts +21 -23
  132. package/src/runtime/context-accounting-source.ts +103 -0
  133. package/src/runtime/conversation-rewind-port.ts +130 -0
  134. package/src/runtime/fleet-needs-input-push.ts +70 -0
  135. package/src/runtime/install-self-check-startup.ts +36 -0
  136. package/src/runtime/install-self-check.ts +149 -0
  137. package/src/runtime/onboarding/fast-path.ts +93 -0
  138. package/src/runtime/process-lifecycle.ts +32 -24
  139. package/src/runtime/relay-reachability-bridge.ts +53 -0
  140. package/src/runtime/release-artifacts.ts +109 -0
  141. package/src/runtime/services.ts +33 -33
  142. package/src/runtime/trust/workspace-registration.ts +184 -0
  143. package/src/runtime/trust/workspace-trust.ts +202 -0
  144. package/src/runtime/ui-services.ts +2 -1
  145. package/src/runtime/update-check.ts +113 -0
  146. package/src/runtime/workspace-checkpointing.ts +32 -0
  147. package/src/runtime/workstream-attempts-validation.ts +77 -0
  148. package/src/runtime/workstream-draft-edits.ts +95 -0
  149. package/src/runtime/workstream-draft-store.ts +101 -0
  150. package/src/runtime/workstream-draft-types.ts +42 -0
  151. package/src/runtime/workstream-services.ts +70 -39
  152. package/src/shell/blocking-input.ts +29 -0
  153. package/src/shell/ui-openers.ts +65 -0
  154. package/src/verification/verification-ledger.ts +56 -2
  155. package/src/version.ts +1 -1
  156. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +0 -130
  157. package/src/runtime/gateway-verbs.ts +0 -47
  158. package/src/runtime/render-scheduler.ts +0 -90
@@ -0,0 +1,362 @@
1
+ // ---------------------------------------------------------------------------
2
+ // rewind-runtime.ts — /rewind (message-anchored, unified files + conversation),
3
+ // plus the /undo rewind and /redo rewind reversals over the last receipt.
4
+ //
5
+ // The rewind engine is the SDK's UnifiedRewindService (platform/rewind:
6
+ // plan() previews + issues a single-use confirm token; apply() is token-gated
7
+ // and returns a receipt carrying an undo block). It JOINS the platform's three
8
+ // existing history stores — it never adds a fourth. This module wires that
9
+ // service to the TUI's in-process stores through the two ports the service
10
+ // defines:
11
+ // • RewindWorkspacePort ← the in-process WorkspaceCheckpointManager (the
12
+ // same store /checkpoints and the daemon's own files-rewind use). The port
13
+ // resolves an anchor's checkpoint by turnId across the session's turn
14
+ // checkpoints; the TUI does not stamp checkpoints with a sessionId, so the
15
+ // adapter returns the full checkpoint list and lets the shared turnId do the
16
+ // join (see rewind-turn-anchors.ts).
17
+ // • RewindConversationPort ← the in-process ConversationManager. preview()
18
+ // reports how many messages truncate to the recorded turn boundary;
19
+ // rewind() truncates and captures the pre- and post-truncation snapshots so
20
+ // /undo rewind and /redo rewind can reverse and re-apply it.
21
+ // The undo/redo stacks stay TUI-side: the service reports how to reverse a
22
+ // rewind (its receipt's undo block), and this module performs the reversal.
23
+ //
24
+ // The confirm step reuses the DiffPanel confirm idiom (PanelConfirmOverlay),
25
+ // exactly like the former checkpoint-id /rewind this replaces (see the
26
+ // architecture note in checkpoint-runtime.ts): the handler resolves the
27
+ // anchor, previews the change, and arms the panel's confirm overlay; the actual
28
+ // y/n handling lives in DiffPanel.handleInput().
29
+ // ---------------------------------------------------------------------------
30
+
31
+ import {
32
+ UnifiedRewindService,
33
+ type RewindAnchor,
34
+ type RewindCheckpointDiff,
35
+ type RewindCheckpointView,
36
+ type RewindReceipt,
37
+ type RewindRestoreResult,
38
+ type RewindScope as SdkRewindScope,
39
+ type RewindWorkspacePort,
40
+ } from '@pellux/goodvibes-sdk/platform/rewind';
41
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
42
+ import type { WorkspaceCheckpointManager } from '@pellux/goodvibes-sdk/platform/workspace';
43
+ import type { CommandContext, CommandRegistry } from '../command-registry.ts';
44
+ import { buildRewindReceiptBlock } from '../../core/rewind-receipt.ts';
45
+ import { getTurnAnchors, type TurnAnchor } from '../../core/rewind-turn-anchors.ts';
46
+ import { createConversationRewindPort, type ConversationRewindPort } from '../../runtime/conversation-rewind-port.ts';
47
+ import { requirePanelManager } from './runtime-services.ts';
48
+
49
+ export type RewindScope = SdkRewindScope;
50
+ const REWIND_SCOPES: readonly RewindScope[] = ['files', 'conversation', 'both'];
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Ports — bridge the SDK rewind service onto the TUI's in-process stores.
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Workspace port over the in-process WorkspaceCheckpointManager. The manager's
58
+ * own list()/diff()/restore() satisfy the port shape; list() drops the
59
+ * service's sessionId filter because TUI turn checkpoints are joined by the
60
+ * shared turnId, not a stamped sessionId.
61
+ */
62
+ function makeWorkspacePort(mgr: WorkspaceCheckpointManager): RewindWorkspacePort {
63
+ return {
64
+ async list(): Promise<readonly RewindCheckpointView[]> {
65
+ return mgr.list();
66
+ },
67
+ async diff(id: string): Promise<RewindCheckpointDiff> {
68
+ return mgr.diff(id);
69
+ },
70
+ async restore(id: string, opts?: { readonly safetyCheckpoint?: boolean | undefined }): Promise<RewindRestoreResult> {
71
+ return mgr.restore(id, opts);
72
+ },
73
+ };
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Per-session rewind state — the SDK service (with its wired ports) plus the
78
+ // TUI-owned undo/redo stacks of applied receipts.
79
+ // ---------------------------------------------------------------------------
80
+
81
+ interface RewindSessionState {
82
+ readonly service: UnifiedRewindService;
83
+ readonly convPort: ConversationRewindPort;
84
+ readonly undo: RewindReceipt[];
85
+ readonly redo: RewindReceipt[];
86
+ }
87
+
88
+ const sessions = new Map<string, RewindSessionState>();
89
+
90
+ /** Get or build the per-session service, capturing the session's live stores. */
91
+ function stateFor(ctx: CommandContext): RewindSessionState {
92
+ const sessionId = ctx.session.runtime.sessionId;
93
+ let state = sessions.get(sessionId);
94
+ if (!state) {
95
+ const mgr = ctx.workspace.workspaceCheckpointManager;
96
+ const conv = ctx.session.conversationManager;
97
+ const convPort = createConversationRewindPort(() => conv);
98
+ const service = new UnifiedRewindService({
99
+ workspace: mgr ? makeWorkspacePort(mgr) : null,
100
+ conversation: convPort,
101
+ });
102
+ state = { service, convPort, undo: [], redo: [] };
103
+ sessions.set(sessionId, state);
104
+ }
105
+ return state;
106
+ }
107
+
108
+ /** Reset a session's rewind state. Exposed for tests and session reset. */
109
+ export function resetRewindState(sessionId: string): void {
110
+ sessions.delete(sessionId);
111
+ }
112
+
113
+ function wants(scope: RewindScope, part: 'files' | 'conversation'): boolean {
114
+ return scope === 'both' || scope === part;
115
+ }
116
+
117
+ function undoAvailable(receipt: RewindReceipt): boolean {
118
+ return receipt.undo.files !== null || receipt.undo.conversation !== null;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // /undo rewind & /redo rewind — reversal over the last receipt's undo points.
123
+ // Called from session-content.ts so the /undo and /redo commands stay single.
124
+ // ---------------------------------------------------------------------------
125
+
126
+ export interface RewindReversalResult {
127
+ readonly handled: boolean;
128
+ readonly message: string;
129
+ }
130
+
131
+ export async function undoLastRewind(ctx: CommandContext): Promise<RewindReversalResult> {
132
+ const state = stateFor(ctx);
133
+ const receipt = state.undo.pop();
134
+ if (!receipt) return { handled: false, message: 'No applied rewind to undo. (Bare /undo removes the last conversation turn.)' };
135
+
136
+ const mgr = ctx.workspace.workspaceCheckpointManager;
137
+ const parts: string[] = [];
138
+ try {
139
+ if (receipt.undo.files && mgr) {
140
+ await mgr.restore(receipt.undo.files.restoreCheckpointId, { safetyCheckpoint: false });
141
+ parts.push('files restored to pre-rewind state');
142
+ }
143
+ if (receipt.undo.conversation && state.convPort.restoreBefore(receipt.undo.conversation.undoSnapshotId)) {
144
+ parts.push('conversation restored to pre-rewind state');
145
+ }
146
+ } catch (err) {
147
+ state.undo.push(receipt);
148
+ return { handled: true, message: `Undo rewind failed: ${summarizeError(err)}` };
149
+ }
150
+ state.redo.push(receipt);
151
+ ctx.renderRequest();
152
+ return { handled: true, message: `Rewind undone: ${parts.join(', ') || 'nothing to reverse'}. Use /redo rewind to re-apply.` };
153
+ }
154
+
155
+ export async function redoLastRewind(ctx: CommandContext): Promise<RewindReversalResult> {
156
+ const state = stateFor(ctx);
157
+ const receipt = state.redo.pop();
158
+ if (!receipt) return { handled: false, message: 'No undone rewind to redo. (Bare /redo restores the last conversation turn.)' };
159
+
160
+ const mgr = ctx.workspace.workspaceCheckpointManager;
161
+ const parts: string[] = [];
162
+ try {
163
+ if (receipt.files?.checkpointId && mgr) {
164
+ await mgr.restore(receipt.files.checkpointId, { safetyCheckpoint: false });
165
+ parts.push('files re-applied');
166
+ }
167
+ if (receipt.undo.conversation && state.convPort.restoreAfter(receipt.undo.conversation.undoSnapshotId)) {
168
+ parts.push('conversation re-applied');
169
+ }
170
+ } catch (err) {
171
+ state.redo.push(receipt);
172
+ return { handled: true, message: `Redo rewind failed: ${summarizeError(err)}` };
173
+ }
174
+ state.undo.push(receipt);
175
+ ctx.renderRequest();
176
+ return { handled: true, message: `Rewind re-applied: ${parts.join(', ') || 'nothing to re-apply'}.` };
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Recent-turns picker + anchor resolution
181
+ // ---------------------------------------------------------------------------
182
+
183
+ function formatAge(atMs: number, nowMs = Date.now()): string {
184
+ const s = Math.max(0, Math.floor((nowMs - atMs) / 1000));
185
+ if (s < 60) return `${s}s ago`;
186
+ const m = Math.floor(s / 60);
187
+ if (m < 60) return `${m}m ago`;
188
+ const h = Math.floor(m / 60);
189
+ if (h < 24) return `${h}h ago`;
190
+ return `${Math.floor(h / 24)}d ago`;
191
+ }
192
+
193
+ /** Recent turns, NEWEST first, numbered 1..N for the picker. */
194
+ function recentTurnsNewestFirst(sessionId: string): TurnAnchor[] {
195
+ return [...getTurnAnchors(sessionId)].reverse();
196
+ }
197
+
198
+ function renderTurnList(sessionId: string): string {
199
+ const turns = recentTurnsNewestFirst(sessionId);
200
+ if (turns.length === 0) {
201
+ return 'No completed turns recorded this run yet. /rewind targets turns completed in the current session run.';
202
+ }
203
+ const lines = ['Recent turns (newest first) — /rewind <n> [files|conversation|both]:'];
204
+ turns.slice(0, 20).forEach((t, i) => {
205
+ lines.push(` ${String(i + 1).padStart(2)}. ${t.label} (${formatAge(t.at)}, ${t.messageCount} msgs)`);
206
+ });
207
+ lines.push('Default scope is "both" (files + conversation). Files resolve only for turns that changed files.');
208
+ return lines.join('\n');
209
+ }
210
+
211
+ /** Resolve a user ref ('1'..'N' newest-first, or a turnId / turnId prefix) to an anchor turn. */
212
+ function resolveRef(sessionId: string, ref: string): TurnAnchor | { error: string } {
213
+ const turns = recentTurnsNewestFirst(sessionId);
214
+ if (turns.length === 0) return { error: 'No completed turns recorded this run yet.' };
215
+ if (/^\d+$/.test(ref)) {
216
+ const idx = Number(ref) - 1;
217
+ const turn = turns[idx];
218
+ return turn ? turn : { error: `No turn #${ref}. Run /rewind to list recent turns (1–${turns.length}).` };
219
+ }
220
+ const exact = turns.find((t) => t.turnId === ref);
221
+ if (exact) return exact;
222
+ const prefix = turns.filter((t) => t.turnId.startsWith(ref));
223
+ if (prefix.length === 1) return prefix[0]!;
224
+ if (prefix.length > 1) return { error: `Turn ref "${ref}" is ambiguous (${prefix.length} matches). Use the list number instead.` };
225
+ return { error: `Unknown turn ref "${ref}". Run /rewind to list recent turns.` };
226
+ }
227
+
228
+ function parseScope(token: string | undefined): RewindScope | null {
229
+ if (token === undefined) return 'both';
230
+ const lower = token.toLowerCase();
231
+ return (REWIND_SCOPES as readonly string[]).includes(lower) ? (lower as RewindScope) : null;
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Command registration
236
+ // ---------------------------------------------------------------------------
237
+
238
+ export function registerRewindRuntimeCommands(registry: CommandRegistry): void {
239
+ registry.register({
240
+ name: 'rewind',
241
+ description: 'Rewind files, conversation, or both to a completed turn (preview + confirm)',
242
+ usage: '[<n|turnId> [files|conversation|both]]',
243
+ argsHint: '[<n> [files|conversation|both]]',
244
+ handler: async (args, ctx: CommandContext) => {
245
+ const sessionId = ctx.session.runtime.sessionId;
246
+
247
+ if (args.length === 0) {
248
+ ctx.print(renderTurnList(sessionId));
249
+ return;
250
+ }
251
+
252
+ const scope = parseScope(args[1]);
253
+ if (scope === null) {
254
+ ctx.print(`Unknown scope "${args[1]}". Use one of: files | conversation | both.`);
255
+ return;
256
+ }
257
+ const resolved = resolveRef(sessionId, args[0]!);
258
+ if ('error' in resolved) {
259
+ ctx.print(resolved.error);
260
+ return;
261
+ }
262
+ const anchor: RewindAnchor = { sessionId, turnId: resolved.turnId };
263
+
264
+ if (wants(scope, 'files') && !ctx.workspace.workspaceCheckpointManager) {
265
+ ctx.print('Checkpoints are not available in this session — files rewind cannot run. Try /rewind <n> conversation.');
266
+ return;
267
+ }
268
+
269
+ const state = stateFor(ctx);
270
+ const plan = await state.service.plan(anchor, scope).catch((err) => {
271
+ ctx.print(`Could not plan rewind: ${summarizeError(err)}`);
272
+ return null;
273
+ });
274
+ if (!plan) return;
275
+
276
+ const filesAvailable = plan.files?.available ?? false;
277
+ const conversationAvailable = plan.conversation?.available ?? false;
278
+ if (!filesAvailable && !conversationAvailable) {
279
+ ctx.print(`Nothing to rewind for turn "${resolved.label}" at scope ${scope}.`);
280
+ for (const w of plan.warnings) ctx.print(` note: ${w}`);
281
+ return;
282
+ }
283
+
284
+ // Preview in the DiffPanel + arm its confirm overlay (same idiom the
285
+ // former checkpoint-id /rewind used — see checkpoint-runtime.ts).
286
+ const { DiffPanel } = await import('../../panels/diff-panel.ts');
287
+ const pm = requirePanelManager(ctx);
288
+ let panel = pm.getAllOpen().find((p) => p.id === 'diff');
289
+ if (!panel) {
290
+ try { panel = pm.open('diff'); } catch { ctx.print('Could not open diff panel.'); return; }
291
+ }
292
+ pm.activateById('diff');
293
+ if (!pm.isVisible()) pm.show();
294
+ ctx.focusPanels?.();
295
+ const diffPanel = panel as InstanceType<typeof DiffPanel>;
296
+
297
+ const checkpointId = plan.files?.checkpointId ?? null;
298
+ if (filesAvailable && checkpointId && ctx.workspace.workspaceCheckpointManager) {
299
+ try {
300
+ const diff = await ctx.workspace.workspaceCheckpointManager.diff(checkpointId);
301
+ if (diff.unifiedDiff.trim()) diffPanel.loadRawDiff(diff.unifiedDiff);
302
+ else diffPanel.showDiff('(no file changes)', '@@ -0,0 +0,0 @@\n Working tree already matches this checkpoint.');
303
+ } catch {
304
+ diffPanel.showDiff('(preview unavailable)', '@@ -0,0 +0,0 @@\n Could not load the checkpoint diff.');
305
+ }
306
+ } else {
307
+ const drop = plan.conversation?.messagesToDrop ?? 0;
308
+ const remaining = plan.conversation?.messagesRemaining ?? 0;
309
+ diffPanel.showDiff(
310
+ `Conversation rewind — drop ${drop} message(s)`,
311
+ `@@ rewind @@\n Keep ${remaining} message(s), drop ${drop} after this turn. No files change.`,
312
+ );
313
+ }
314
+
315
+ const summaryParts: string[] = [];
316
+ if (wants(scope, 'files')) summaryParts.push(filesAvailable ? `${plan.files?.affectedFileCount ?? 0} file(s)` : 'files: none');
317
+ if (wants(scope, 'conversation')) summaryParts.push(conversationAvailable ? `${plan.conversation?.messagesToDrop ?? 0} message(s)` : 'conversation: unavailable');
318
+
319
+ diffPanel.confirmOverlay.arm({
320
+ id: anchor.turnId ?? sessionId,
321
+ label: `${resolved.label} — rewind ${scope} (${summaryParts.join(', ')})`,
322
+ verb: 'Rewind',
323
+ onConfirm: async () => {
324
+ try {
325
+ const result = await state.service.apply(anchor, scope, { confirmToken: plan.token });
326
+ const receipt = result.receipt;
327
+ if (!receipt) {
328
+ ctx.print(`Rewind not applied: ${result.refusal?.reason ?? 'confirmation was refused.'}`);
329
+ return;
330
+ }
331
+ if (undoAvailable(receipt)) {
332
+ state.undo.push(receipt);
333
+ state.redo.length = 0; // a fresh apply invalidates the redo stack
334
+ }
335
+ // addTypedSystemMessage persists the receipt into real session
336
+ // history (indexed by getTranscriptEventIndex + save/load), and the
337
+ // [Rewind] prefix is force-surfaced inline (system-message-router.ts).
338
+ ctx.session.conversationManager.addTypedSystemMessage(
339
+ buildRewindReceiptBlock({ ...receipt, undoAvailable: undoAvailable(receipt) }),
340
+ 'operational',
341
+ );
342
+ pm.close('diff');
343
+ ctx.focusPrompt?.();
344
+ ctx.renderRequest();
345
+ } catch (err) {
346
+ ctx.print(`Rewind failed: ${summarizeError(err)}`);
347
+ }
348
+ },
349
+ onCancel: () => {
350
+ ctx.print('Rewind cancelled — nothing changed.');
351
+ pm.close('diff');
352
+ ctx.focusPrompt?.();
353
+ ctx.renderRequest();
354
+ },
355
+ });
356
+
357
+ ctx.print(`Previewing rewind of turn "${resolved.label}" (${scope}: ${summaryParts.join(', ')}). Confirm in the diff panel: Enter/y to rewind, n/Esc to cancel.`);
358
+ for (const w of plan.warnings) ctx.print(` note: ${w}`);
359
+ ctx.renderRequest();
360
+ },
361
+ });
362
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Local natural-language schedule parsing for `/schedule add when "<phrase>"`.
3
+ *
4
+ * Parses common English scheduling phrases entirely offline into one of the
5
+ * three concrete schedule shapes the automation manager already understands —
6
+ * `cron`, `every` (fixed interval), or `at` (one-shot timestamp) — and returns
7
+ * a plain-English description of exactly what it decided, so the caller can
8
+ * ALWAYS echo the concrete interpretation back to the user before anything is
9
+ * saved.
10
+ *
11
+ * This is intentionally a bounded, predictable grammar rather than a fuzzy
12
+ * date library: it recognizes a fixed set of shapes and reports an honest error
13
+ * for anything it does not understand, so the user is never silently given a
14
+ * schedule that does not match what they typed.
15
+ */
16
+
17
+ export type ParsedSchedule =
18
+ | { readonly kind: 'cron'; readonly expression: string; readonly description: string }
19
+ | { readonly kind: 'every'; readonly interval: string; readonly description: string }
20
+ | { readonly kind: 'at'; readonly at: number; readonly description: string }
21
+ | { readonly kind: 'error'; readonly error: string };
22
+
23
+ const DOW: Record<string, number> = {
24
+ sunday: 0, sun: 0,
25
+ monday: 1, mon: 1,
26
+ tuesday: 2, tue: 2, tues: 2,
27
+ wednesday: 3, wed: 3,
28
+ thursday: 4, thu: 4, thurs: 4,
29
+ friday: 5, fri: 5,
30
+ saturday: 6, sat: 6,
31
+ };
32
+
33
+ const DOW_NAME = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
34
+
35
+ /** Parse "9am", "9:30pm", "14:00", "9" → {hour, minute} in 24h, or null. */
36
+ function parseTimeOfDay(raw: string): { hour: number; minute: number } | null {
37
+ const m = raw.trim().toLowerCase().match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/);
38
+ if (!m) return null;
39
+ let hour = Number(m[1]);
40
+ const minute = m[2] ? Number(m[2]) : 0;
41
+ const meridiem = m[3];
42
+ if (minute > 59) return null;
43
+ if (meridiem === 'am') {
44
+ if (hour < 1 || hour > 12) return null;
45
+ if (hour === 12) hour = 0;
46
+ } else if (meridiem === 'pm') {
47
+ if (hour < 1 || hour > 12) return null;
48
+ if (hour !== 12) hour += 12;
49
+ } else if (hour > 23) {
50
+ return null;
51
+ }
52
+ return { hour, minute };
53
+ }
54
+
55
+ function fmtTime(hour: number, minute: number): string {
56
+ return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
57
+ }
58
+
59
+ const UNIT_MS: Record<string, number> = {
60
+ second: 1000, seconds: 1000, sec: 1000, secs: 1000,
61
+ minute: 60_000, minutes: 60_000, min: 60_000, mins: 60_000,
62
+ hour: 3_600_000, hours: 3_600_000, hr: 3_600_000, hrs: 3_600_000,
63
+ day: 86_400_000, days: 86_400_000,
64
+ };
65
+
66
+ /** Parse a natural-language scheduling phrase into a concrete schedule. */
67
+ export function parseNaturalLanguageSchedule(phrase: string, now: number = Date.now()): ParsedSchedule {
68
+ const text = phrase.trim().toLowerCase().replace(/\s+/g, ' ');
69
+ if (!text) return { kind: 'error', error: 'Empty schedule phrase.' };
70
+
71
+ // "at HH..." with an optional day anchor → one-shot `at`.
72
+ const relIn = text.match(/^in (\d+) (second|seconds|sec|secs|minute|minutes|min|mins|hour|hours|hr|hrs|day|days)$/);
73
+ if (relIn) {
74
+ const unitMs = UNIT_MS[relIn[2]!]!;
75
+ const at = now + Number(relIn[1]) * unitMs;
76
+ return { kind: 'at', at, description: `once at ${new Date(at).toLocaleString()}` };
77
+ }
78
+
79
+ // Shorthand words.
80
+ if (text === 'hourly') return { kind: 'cron', expression: '0 * * * *', description: 'every hour, on the hour' };
81
+ if (text === 'daily') return { kind: 'cron', expression: '0 0 * * *', description: 'every day at 00:00' };
82
+ if (text === 'weekly') return { kind: 'cron', expression: '0 0 * * 0', description: 'every week on Sunday at 00:00' };
83
+
84
+ // "every <n> <unit>" → fixed interval.
85
+ const everyN = text.match(/^every (\d+) (second|seconds|sec|secs|minute|minutes|min|mins|hour|hours|hr|hrs|day|days)$/);
86
+ if (everyN) {
87
+ const n = everyN[1]!;
88
+ const unit = everyN[2]!;
89
+ const short = unit.startsWith('sec') ? 's' : unit.startsWith('min') ? 'm' : unit.startsWith('h') ? 'h' : 'd';
90
+ return { kind: 'every', interval: `${n}${short}`, description: `every ${n} ${unit}` };
91
+ }
92
+
93
+ // "every <unit>" (singular) → interval of 1 unit.
94
+ const everyUnit = text.match(/^every (second|minute|hour|day)$/);
95
+ if (everyUnit) {
96
+ const unit = everyUnit[1]!;
97
+ const short = unit === 'second' ? 's' : unit === 'minute' ? 'm' : unit === 'hour' ? 'h' : 'd';
98
+ return { kind: 'every', interval: `1${short}`, description: `every ${unit}` };
99
+ }
100
+
101
+ // Time-of-day driven cron forms. Extract a trailing "at <time>", default midnight.
102
+ const atMatch = text.match(/(?:^|\s)at (\d{1,2}(?::\d{2})?\s*(?:am|pm)?)$/);
103
+ const timeStr = atMatch ? atMatch[1]! : null;
104
+ const time = timeStr ? parseTimeOfDay(timeStr) : { hour: 0, minute: 0 };
105
+ if (timeStr && !time) return { kind: 'error', error: `Could not understand the time "${timeStr}".` };
106
+ const head = atMatch ? text.slice(0, text.length - atMatch[0].length).trim() : text;
107
+ const { hour, minute } = time!;
108
+
109
+ // "every day" / "everyday" / "daily at <time>".
110
+ if (head === 'every day' || head === 'everyday' || head === 'daily') {
111
+ return { kind: 'cron', expression: `${minute} ${hour} * * *`, description: `every day at ${fmtTime(hour, minute)}` };
112
+ }
113
+
114
+ // "every weekday" / "weekdays".
115
+ if (head === 'every weekday' || head === 'weekdays' || head === 'every weekdays') {
116
+ return { kind: 'cron', expression: `${minute} ${hour} * * 1-5`, description: `every weekday (Mon-Fri) at ${fmtTime(hour, minute)}` };
117
+ }
118
+
119
+ // "every weekend" / "weekends".
120
+ if (head === 'every weekend' || head === 'weekends' || head === 'every weekends') {
121
+ return { kind: 'cron', expression: `${minute} ${hour} * * 0,6`, description: `every weekend (Sat-Sun) at ${fmtTime(hour, minute)}` };
122
+ }
123
+
124
+ // "every <weekday>" / "every monday" etc.
125
+ const everyDow = head.match(/^every (\w+)$/) ?? head.match(/^(\w+)s$/);
126
+ if (everyDow) {
127
+ const day = everyDow[1]!;
128
+ const dow = DOW[day] ?? DOW[day.replace(/s$/, '')];
129
+ if (dow !== undefined) {
130
+ return { kind: 'cron', expression: `${minute} ${hour} * * ${dow}`, description: `every ${DOW_NAME[dow]} at ${fmtTime(hour, minute)}` };
131
+ }
132
+ }
133
+
134
+ // Bare "at <time>" → one-shot at the next occurrence of that time today/tomorrow.
135
+ if (timeStr && head === '') {
136
+ const next = new Date(now);
137
+ next.setSeconds(0, 0);
138
+ next.setHours(hour, minute, 0, 0);
139
+ if (next.getTime() <= now) next.setDate(next.getDate() + 1);
140
+ return { kind: 'at', at: next.getTime(), description: `once at ${next.toLocaleString()}` };
141
+ }
142
+
143
+ return {
144
+ kind: 'error',
145
+ error: `Could not parse "${phrase}". Try phrases like "every weekday at 9am", "every 30 minutes", "daily at 6pm", "every monday at 08:00", or "in 2 hours".`,
146
+ };
147
+ }
@@ -10,6 +10,7 @@ import type { AutomationManager } from '@pellux/goodvibes-sdk/platform/automatio
10
10
  import type { AutomationJob } from '@pellux/goodvibes-sdk/platform/automation';
11
11
  import type { AutomationScheduleDefinition } from '@pellux/goodvibes-sdk/platform/automation';
12
12
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
13
+ import { parseNaturalLanguageSchedule } from './schedule-nl.ts';
13
14
  import type {
14
15
  AutomationExecutionPolicy,
15
16
  AutomationExternalContentSource,
@@ -91,8 +92,8 @@ export function registerScheduleRuntimeCommands(registry: CommandRegistry): void
91
92
  name: 'schedule',
92
93
  aliases: ['sched'],
93
94
  description: 'Manage automation jobs and scheduled runs',
94
- usage: 'add <cron|every|at> <value> <prompt...> | list | remove <id> | enable <id> | disable <id> | run <id>',
95
- argsHint: 'add cron <expr> | add every <interval> | add at <timestamp> | list | remove | enable | disable | run',
95
+ usage: 'add <cron|every|at|when> <value> <prompt...> | list | remove <id> | enable <id> | disable <id> | run <id>',
96
+ argsHint: 'add cron <expr> | add every <interval> | add at <timestamp> | add when "<natural language>" | list | remove | enable | disable | run',
96
97
  async handler(args, ctx) {
97
98
  const manager = ctx.ops.automationManager;
98
99
  if (!manager) {
@@ -134,12 +135,13 @@ export function registerScheduleRuntimeCommands(registry: CommandRegistry): void
134
135
  'Usage:\n'
135
136
  + ' /schedule add cron "<expr>" <prompt...> [--name <name>] [--model <model>] [--provider <provider>] [--template <tmpl>] [--tz <timezone>] [--stagger <ms>]\n'
136
137
  + ' /schedule add every <interval> <prompt...> [--name <name>] [--model <model>] [--provider <provider>] [--template <tmpl>]\n'
137
- + ' /schedule add at <timestamp> <prompt...> [--name <name>] [--model <model>] [--provider <provider>] [--template <tmpl>]'
138
+ + ' /schedule add at <timestamp> <prompt...> [--name <name>] [--model <model>] [--provider <provider>] [--template <tmpl>]\n'
139
+ + ' /schedule add when "<natural language, e.g. every weekday at 9am>" <prompt...>'
138
140
  );
139
141
  return;
140
142
  }
141
143
 
142
- const legacyCronMode = scheduleKind !== 'cron' && scheduleKind !== 'every' && scheduleKind !== 'at';
144
+ const legacyCronMode = scheduleKind !== 'cron' && scheduleKind !== 'every' && scheduleKind !== 'at' && scheduleKind !== 'when';
143
145
  const scheduleArg = legacyCronMode ? args[1] : args[2];
144
146
  const valueStartIndex = legacyCronMode ? 2 : 3;
145
147
  if (!scheduleArg) {
@@ -215,13 +217,30 @@ export function registerScheduleRuntimeCommands(registry: CommandRegistry): void
215
217
  }
216
218
 
217
219
  try {
218
- const schedule = legacyCronMode
219
- ? normalizeCronSchedule(scheduleArg, timezone, staggerMs)
220
- : scheduleKind === 'cron'
220
+ let schedule: AutomationScheduleDefinition;
221
+ if (scheduleKind === 'when') {
222
+ // Natural-language phrase: parse locally, ALWAYS echo the concrete
223
+ // interpretation before saving so nothing is scheduled silently.
224
+ const parsed = parseNaturalLanguageSchedule(scheduleArg);
225
+ if (parsed.kind === 'error') {
226
+ ctx.print(`Error: ${parsed.error}`);
227
+ return;
228
+ }
229
+ schedule = parsed.kind === 'cron'
230
+ ? normalizeCronSchedule(parsed.expression, timezone, staggerMs)
231
+ : parsed.kind === 'every'
232
+ ? normalizeEverySchedule(parsed.interval)
233
+ : normalizeAtSchedule(parsed.at);
234
+ ctx.print(`Interpreted "${scheduleArg}" as: ${parsed.description}\n → ${formatSchedule(schedule)}`);
235
+ } else {
236
+ schedule = legacyCronMode
221
237
  ? normalizeCronSchedule(scheduleArg, timezone, staggerMs)
222
- : scheduleKind === 'every'
223
- ? normalizeEverySchedule(scheduleArg)
224
- : normalizeAtSchedule(parseAtValue(scheduleArg));
238
+ : scheduleKind === 'cron'
239
+ ? normalizeCronSchedule(scheduleArg, timezone, staggerMs)
240
+ : scheduleKind === 'every'
241
+ ? normalizeEverySchedule(scheduleArg)
242
+ : normalizeAtSchedule(parseAtValue(scheduleArg));
243
+ }
225
244
  const job = await manager.createJob({
226
245
  name: name ?? prompt.slice(0, 40),
227
246
  prompt,
@@ -0,0 +1,55 @@
1
+ /**
2
+ * /secret — capture a password-like value through the composer's concealed
3
+ * input mode, without the plaintext ever appearing in the transcript or input
4
+ * history.
5
+ *
6
+ * This is the user-facing demonstration of the generalized concealed-input
7
+ * affordance (see src/input/concealed-input.ts): the same masking that used to
8
+ * live only on the local-auth and onboarding surfaces, now reachable for any
9
+ * password-like prompt. The captured value is written to a process environment
10
+ * variable so a subsequent shell command / tool run can read it, while the
11
+ * transcript only ever shows a redacted confirmation.
12
+ */
13
+
14
+ import type { CommandRegistry } from '../command-registry.ts';
15
+
16
+ /** Normalize a requested name into a conventional env-var identifier. */
17
+ function toEnvVarName(raw: string): string {
18
+ return raw.trim().replace(/[^A-Za-z0-9_]/g, '_').replace(/^_+|_+$/g, '').toUpperCase();
19
+ }
20
+
21
+ export function registerSecretRuntimeCommands(registry: CommandRegistry): void {
22
+ registry.register({
23
+ name: 'secret',
24
+ description: 'Enter a secret with concealed (masked) input and store it as an environment variable — plaintext never enters the transcript',
25
+ usage: '/secret <NAME>',
26
+ argsHint: '<NAME>',
27
+ handler(args, ctx) {
28
+ const name = toEnvVarName(args[0] ?? '');
29
+ if (!name) {
30
+ ctx.print('[Secret] Usage: /secret <NAME> — the value is typed on the next line with the keystrokes masked.');
31
+ return;
32
+ }
33
+ if (!ctx.beginConcealedInput) {
34
+ ctx.print('[Secret] Concealed input is not available on this surface.');
35
+ return;
36
+ }
37
+ ctx.print(`[Secret] Enter value for ${name} (input is masked; press Enter to store, Esc to cancel).`);
38
+ ctx.beginConcealedInput({
39
+ label: name,
40
+ onSubmit: (value) => {
41
+ if (value.length === 0) {
42
+ ctx.print(`[Secret] ${name} not set (empty value).`);
43
+ return;
44
+ }
45
+ process.env[name] = value;
46
+ // Never echo the value — only a redacted confirmation of length.
47
+ ctx.print(`[Secret] Stored ${name} (${value.length} character${value.length === 1 ? '' : 's'}, value hidden). Available to shell/tool runs as $${name}.`);
48
+ },
49
+ onCancel: () => {
50
+ ctx.print(`[Secret] ${name} cancelled.`);
51
+ },
52
+ });
53
+ },
54
+ });
55
+ }