@pellux/goodvibes-tui 1.13.0 → 1.14.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.
- package/CHANGELOG.md +101 -0
- package/README.md +6 -6
- package/bin/goodvibes +6 -0
- package/bin/goodvibes-daemon +6 -0
- package/bin/launcher-support.js +34 -1
- package/docs/foundation-artifacts/operator-contract.json +8076 -2047
- package/package.json +15 -11
- package/scripts/postinstall.js +73 -48
- package/src/cli/bundle-command.ts +5 -5
- package/src/cli/completion.ts +1 -1
- package/src/cli/completions/generate.ts +15 -3
- package/src/cli/doctor.ts +385 -0
- package/src/cli/entrypoint.ts +72 -1
- package/src/cli/help.ts +29 -9
- package/src/cli/hooks-command.ts +74 -0
- package/src/cli/hooks-report.ts +167 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/management.ts +13 -1
- package/src/cli/parser.ts +10 -2
- package/src/cli/plugin-bundles-command.ts +228 -0
- package/src/cli/plugin-command.ts +179 -0
- package/src/cli/service-posture.ts +10 -0
- package/src/cli/status.ts +275 -2
- package/src/cli/tui-startup.ts +119 -1
- package/src/cli/types.ts +3 -1
- package/src/config/index.ts +16 -0
- package/src/config/tui-extension-settings.ts +169 -0
- package/src/core/approval-alert.ts +15 -2
- package/src/core/compaction-receipt.ts +127 -0
- package/src/core/model-routing-chip.ts +82 -0
- package/src/core/permission-mode.ts +89 -0
- package/src/core/rewind-receipt.ts +83 -0
- package/src/core/rewind-turn-anchors.ts +160 -0
- package/src/core/scriptable-statusline.ts +141 -0
- package/src/core/session-auto-titler.ts +110 -0
- package/src/core/stream-event-wiring.ts +20 -0
- package/src/core/system-message-router.ts +1 -1
- package/src/core/terminal-notifier.ts +102 -0
- package/src/core/turn-event-wiring.ts +80 -1
- package/src/input/command-reference.ts +96 -0
- package/src/input/command-registry.ts +25 -0
- package/src/input/commands/channel-pairing.ts +245 -0
- package/src/input/commands/channel-runtime.ts +152 -6
- package/src/input/commands/checkin-runtime.ts +89 -0
- package/src/input/commands/checkpoint-runtime.ts +7 -179
- package/src/input/commands/ci-runtime.ts +207 -0
- package/src/input/commands/cost-attribution-format.ts +52 -0
- package/src/input/commands/cost-runtime.ts +44 -4
- package/src/input/commands/editor-runtime.ts +22 -0
- package/src/input/commands/flags-runtime.ts +328 -0
- package/src/input/commands/health-metrics-format.ts +67 -0
- package/src/input/commands/health-runtime.ts +55 -1
- package/src/input/commands/memory.ts +23 -0
- package/src/input/commands/operator-rpc.ts +95 -0
- package/src/input/commands/palette-runtime.ts +22 -0
- package/src/input/commands/permissions-provenance.ts +264 -0
- package/src/input/commands/permissions-runtime.ts +21 -0
- package/src/input/commands/planning-runtime.ts +28 -1
- package/src/input/commands/platform-access-runtime.ts +2 -97
- package/src/input/commands/principals-runtime.ts +212 -0
- package/src/input/commands/product-runtime.ts +43 -3
- package/src/input/commands/recall-files-config.ts +54 -0
- package/src/input/commands/recall-files-git.ts +57 -0
- package/src/input/commands/recall-files.ts +185 -0
- package/src/input/commands/recall-query.ts +8 -3
- package/src/input/commands/recall-review.ts +2 -2
- package/src/input/commands/recall-shared.ts +13 -0
- package/src/input/commands/relay-runtime.ts +86 -0
- package/src/input/commands/review-runtime.ts +160 -0
- package/src/input/commands/rewind-runtime.ts +362 -0
- package/src/input/commands/schedule-nl.ts +147 -0
- package/src/input/commands/schedule-runtime.ts +29 -10
- package/src/input/commands/secret-runtime.ts +55 -0
- package/src/input/commands/session-content.ts +19 -8
- package/src/input/commands/session-workflow.ts +10 -0
- package/src/input/commands/update-runtime.ts +363 -0
- package/src/input/commands/workstream-attempt-dependents.ts +103 -0
- package/src/input/commands/workstream-attempts.ts +244 -0
- package/src/input/commands/workstream-runtime.ts +164 -26
- package/src/input/commands/worktree-runtime.ts +56 -2
- package/src/input/commands.ts +135 -80
- package/src/input/composer-capture.ts +67 -0
- package/src/input/composer-editor.ts +134 -0
- package/src/input/concealed-input.ts +94 -0
- package/src/input/exec-env-scrub-config.ts +84 -0
- package/src/input/feature-unit-layout.ts +142 -0
- package/src/input/feed-context-factory.ts +2 -0
- package/src/input/handler-feed-routes.ts +17 -0
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-modal-routes.ts +5 -2
- package/src/input/handler-shortcuts.ts +37 -0
- package/src/input/handler.ts +24 -2
- package/src/input/keybindings.ts +11 -2
- package/src/input/onboarding/onboarding-feature-units.ts +536 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +40 -6
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +324 -629
- package/src/input/onboarding/onboarding-wizard-steps.ts +5 -5
- package/src/input/onboarding/onboarding-wizard-types.ts +6 -0
- package/src/input/relay-settings-descriptions.ts +35 -0
- package/src/input/sandbox-exec-config.ts +107 -0
- package/src/input/settings-modal-adjustment.ts +4 -7
- package/src/input/settings-modal-data.ts +178 -8
- package/src/input/settings-modal-mutations.ts +41 -13
- package/src/input/settings-modal-types.ts +42 -5
- package/src/input/settings-modal.ts +67 -15
- package/src/input/submission-router.ts +4 -1
- package/src/input/worktree-setup-config.ts +93 -0
- package/src/main.ts +43 -43
- package/src/panels/builtin/development.ts +14 -0
- package/src/panels/diff-review-model.ts +189 -0
- package/src/panels/diff-review-panel.ts +395 -0
- package/src/panels/fleet-panel-format.ts +114 -2
- package/src/panels/fleet-panel.ts +65 -78
- package/src/panels/fleet-read-model.ts +91 -11
- package/src/panels/fleet-stop.ts +19 -4
- package/src/panels/modals/memory-modal.ts +11 -2
- package/src/permissions/hunk-selection.ts +86 -5
- package/src/permissions/prompt.ts +108 -7
- package/src/permissions/sandbox-exec-gate.ts +162 -0
- package/src/renderer/settings-modal-helpers.ts +21 -1
- package/src/renderer/settings-modal.ts +70 -44
- package/src/renderer/shell-surface.ts +23 -1
- package/src/renderer/system-message.ts +4 -0
- package/src/renderer/tool-result-summary.ts +95 -5
- package/src/renderer/ui-factory.ts +22 -0
- package/src/runtime/bootstrap-command-context.ts +15 -1
- package/src/runtime/bootstrap-command-parts.ts +14 -2
- package/src/runtime/bootstrap-core.ts +38 -38
- package/src/runtime/bootstrap-notifier-sync.ts +41 -0
- package/src/runtime/bootstrap-shell.ts +8 -0
- package/src/runtime/bootstrap.ts +21 -23
- package/src/runtime/context-accounting-source.ts +103 -0
- package/src/runtime/conversation-rewind-port.ts +130 -0
- package/src/runtime/fleet-needs-input-push.ts +70 -0
- package/src/runtime/install-self-check-startup.ts +36 -0
- package/src/runtime/install-self-check.ts +149 -0
- package/src/runtime/onboarding/fast-path.ts +93 -0
- package/src/runtime/process-lifecycle.ts +32 -24
- package/src/runtime/relay-reachability-bridge.ts +53 -0
- package/src/runtime/release-artifacts.ts +79 -0
- package/src/runtime/services.ts +38 -37
- package/src/runtime/trust/workspace-registration.ts +184 -0
- package/src/runtime/trust/workspace-trust.ts +202 -0
- package/src/runtime/ui-services.ts +2 -1
- package/src/runtime/update-check.ts +113 -0
- package/src/runtime/workspace-checkpointing.ts +32 -0
- package/src/runtime/workstream-attempts-validation.ts +77 -0
- package/src/runtime/workstream-draft-edits.ts +95 -0
- package/src/runtime/workstream-draft-store.ts +101 -0
- package/src/runtime/workstream-draft-types.ts +42 -0
- package/src/runtime/workstream-services.ts +70 -39
- package/src/shell/blocking-input.ts +29 -0
- package/src/shell/ui-openers.ts +65 -0
- package/src/verification/verification-ledger.ts +56 -2
- package/src/version.ts +1 -1
- package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +0 -130
- package/src/runtime/render-scheduler.ts +0 -90
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI-extension settings — config namespaces the TUI reads that are NOT part
|
|
3
|
+
* of the SDK's ConfigManager schema.
|
|
4
|
+
*
|
|
5
|
+
* The SDK owns settings.json's typed schema (its `ConfigKey` union), but its
|
|
6
|
+
* loader deep-merges user JSON over the defaults and only strips unknown
|
|
7
|
+
* `permissions.tools` keys — every other unknown top-level key survives load
|
|
8
|
+
* and round-trips through `save()`. That lets the TUI keep its own namespaces
|
|
9
|
+
* (e.g. `checkpoints.*`) in the same settings.json file and read them back
|
|
10
|
+
* here via `getRaw()`.
|
|
11
|
+
*
|
|
12
|
+
* Every reader hand-validates each field and returns a PARTIAL object holding
|
|
13
|
+
* only the keys the user actually set to a well-typed value. A missing or
|
|
14
|
+
* malformed block yields an empty object rather than throwing, so a bad edit
|
|
15
|
+
* degrades to "use the built-in defaults", never a crash. Returning a partial
|
|
16
|
+
* (rather than filling in defaults here) keeps the underlying default values
|
|
17
|
+
* owned by their real consumer instead of being duplicated in the TUI.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ConfigManager } from './index.ts';
|
|
21
|
+
|
|
22
|
+
type RawRecord = Record<string, unknown>;
|
|
23
|
+
|
|
24
|
+
function readNamespace(configManager: Pick<ConfigManager, 'getRaw'>, namespace: string): RawRecord | null {
|
|
25
|
+
const block = (configManager.getRaw() as RawRecord)[namespace];
|
|
26
|
+
if (!block || typeof block !== 'object' || Array.isArray(block)) return null;
|
|
27
|
+
return block as RawRecord;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readBoolean(src: RawRecord, key: string): boolean | undefined {
|
|
31
|
+
const value = src[key];
|
|
32
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readPositiveInt(src: RawRecord, key: string): number | undefined {
|
|
36
|
+
const value = src[key];
|
|
37
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined;
|
|
38
|
+
return Math.floor(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readNonEmptyString(src: RawRecord, key: string): string | undefined {
|
|
42
|
+
const value = src[key];
|
|
43
|
+
if (typeof value !== 'string') return undefined;
|
|
44
|
+
const trimmed = value.trim();
|
|
45
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── statusline.* — scriptable status line ───────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* User-settable scriptable status line. When `command` is present it is run as
|
|
52
|
+
* a POSIX shell command at each turn boundary and its first stdout line renders
|
|
53
|
+
* in the status area. `timeoutMs` bounds each run.
|
|
54
|
+
*/
|
|
55
|
+
export interface StatuslineSettings {
|
|
56
|
+
/** Shell command whose first stdout line renders in the status area. Absent/empty disables the feature. */
|
|
57
|
+
readonly command?: string;
|
|
58
|
+
/** Per-run timeout in milliseconds. Defaults to 2000; clamped to [100, 15000]. */
|
|
59
|
+
readonly timeoutMs?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const STATUSLINE_DEFAULT_TIMEOUT_MS = 2000;
|
|
63
|
+
const STATUSLINE_MIN_TIMEOUT_MS = 100;
|
|
64
|
+
const STATUSLINE_MAX_TIMEOUT_MS = 15_000;
|
|
65
|
+
|
|
66
|
+
// ─── session.* — session behavior ────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/** User-settable session behavior. */
|
|
69
|
+
export interface SessionSettings {
|
|
70
|
+
/**
|
|
71
|
+
* Auto-title an untitled session using the configured tool/helper (weak/fast)
|
|
72
|
+
* model after the first turn. Off by default because it costs a small model
|
|
73
|
+
* call. Only ever sets a system-sourced title; a user-chosen title is left alone.
|
|
74
|
+
*/
|
|
75
|
+
readonly autoTitle?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Read `session.*` from settings.json. */
|
|
79
|
+
export function readSessionSettings(configManager: Pick<ConfigManager, 'getRaw'>): SessionSettings {
|
|
80
|
+
const src = readNamespace(configManager, 'session');
|
|
81
|
+
if (!src) return {};
|
|
82
|
+
const out: { autoTitle?: boolean } = {};
|
|
83
|
+
const autoTitle = readBoolean(src, 'autoTitle');
|
|
84
|
+
if (autoTitle !== undefined) out.autoTitle = autoTitle;
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Read `statusline.*` from settings.json, validating and clamping the timeout. */
|
|
89
|
+
export function readStatuslineSettings(configManager: Pick<ConfigManager, 'getRaw'>): StatuslineSettings {
|
|
90
|
+
const src = readNamespace(configManager, 'statusline');
|
|
91
|
+
if (!src) return {};
|
|
92
|
+
const out: { command?: string; timeoutMs?: number } = {};
|
|
93
|
+
const command = readNonEmptyString(src, 'command');
|
|
94
|
+
if (command !== undefined) out.command = command;
|
|
95
|
+
const timeoutMs = readPositiveInt(src, 'timeoutMs');
|
|
96
|
+
if (timeoutMs !== undefined) {
|
|
97
|
+
out.timeoutMs = Math.min(STATUSLINE_MAX_TIMEOUT_MS, Math.max(STATUSLINE_MIN_TIMEOUT_MS, timeoutMs));
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── checkpoints.* — workspace checkpoint root-guard options ─────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* User-settable root-guard options for the SDK's WorkspaceCheckpointManager.
|
|
106
|
+
* All optional: an omitted key defers to the manager's own default. Field
|
|
107
|
+
* names and semantics mirror the SDK's `WorkspaceCheckpointManagerOptions`.
|
|
108
|
+
*/
|
|
109
|
+
export interface CheckpointGuardSettings {
|
|
110
|
+
/** Prefer the enclosing git repo's top level over the raw workspace root. SDK default: true. */
|
|
111
|
+
readonly preferGitRoot?: boolean;
|
|
112
|
+
/** Opt in to snapshotting a broad root (filesystem root / home / ~/.goodvibes). SDK default: false. */
|
|
113
|
+
readonly allowBroadRoot?: boolean;
|
|
114
|
+
/** Opt in to a first snapshot whose full sweep exceeds maxFirstSnapshotFiles. SDK default: false. */
|
|
115
|
+
readonly allowLargeFirstSnapshot?: boolean;
|
|
116
|
+
/** Ceiling for the first-ever snapshot's file sweep. SDK default: 50000. */
|
|
117
|
+
readonly maxFirstSnapshotFiles?: number;
|
|
118
|
+
/** Run a retention sweep automatically after each create() and once at init. SDK default: true. */
|
|
119
|
+
readonly autoRetention?: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Merge the user's `checkpoints.*` root-guard settings onto a base
|
|
124
|
+
* WorkspaceCheckpointManager options object.
|
|
125
|
+
*
|
|
126
|
+
* NOTE: the pinned platform SDK's WorkspaceCheckpointManager may predate these
|
|
127
|
+
* root-guard options — an older constructor reads only
|
|
128
|
+
* workspaceRoot/checkpointDir/runtimeBus/retention/now and silently ignores the
|
|
129
|
+
* rest, making the guard keys INERT. They become effective the moment the SDK
|
|
130
|
+
* is upgraded to a build whose options type declares them (preferGitRoot,
|
|
131
|
+
* allowBroadRoot, allowLargeFirstSnapshot, maxFirstSnapshotFiles,
|
|
132
|
+
* autoRetention) — no code change needed here. The return type intersects the
|
|
133
|
+
* base with CheckpointGuardSettings so the extra keys flow through structurally
|
|
134
|
+
* without an unsafe cast.
|
|
135
|
+
*/
|
|
136
|
+
export function withCheckpointGuardSettings<T extends object>(
|
|
137
|
+
base: T,
|
|
138
|
+
configManager: Pick<ConfigManager, 'getRaw'>,
|
|
139
|
+
): T & CheckpointGuardSettings {
|
|
140
|
+
return { ...base, ...readCheckpointGuardSettings(configManager) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Read `checkpoints.*` from settings.json. Returns only the keys present and
|
|
145
|
+
* well-typed; unknown or malformed values are dropped so the SDK's own
|
|
146
|
+
* defaults apply for them.
|
|
147
|
+
*/
|
|
148
|
+
export function readCheckpointGuardSettings(configManager: Pick<ConfigManager, 'getRaw'>): CheckpointGuardSettings {
|
|
149
|
+
const src = readNamespace(configManager, 'checkpoints');
|
|
150
|
+
if (!src) return {};
|
|
151
|
+
const out: {
|
|
152
|
+
preferGitRoot?: boolean;
|
|
153
|
+
allowBroadRoot?: boolean;
|
|
154
|
+
allowLargeFirstSnapshot?: boolean;
|
|
155
|
+
maxFirstSnapshotFiles?: number;
|
|
156
|
+
autoRetention?: boolean;
|
|
157
|
+
} = {};
|
|
158
|
+
const preferGitRoot = readBoolean(src, 'preferGitRoot');
|
|
159
|
+
if (preferGitRoot !== undefined) out.preferGitRoot = preferGitRoot;
|
|
160
|
+
const allowBroadRoot = readBoolean(src, 'allowBroadRoot');
|
|
161
|
+
if (allowBroadRoot !== undefined) out.allowBroadRoot = allowBroadRoot;
|
|
162
|
+
const allowLargeFirstSnapshot = readBoolean(src, 'allowLargeFirstSnapshot');
|
|
163
|
+
if (allowLargeFirstSnapshot !== undefined) out.allowLargeFirstSnapshot = allowLargeFirstSnapshot;
|
|
164
|
+
const maxFirstSnapshotFiles = readPositiveInt(src, 'maxFirstSnapshotFiles');
|
|
165
|
+
if (maxFirstSnapshotFiles !== undefined) out.maxFirstSnapshotFiles = maxFirstSnapshotFiles;
|
|
166
|
+
const autoRetention = readBoolean(src, 'autoRetention');
|
|
167
|
+
if (autoRetention !== undefined) out.autoRetention = autoRetention;
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
@@ -24,11 +24,19 @@ import type { WebhookNotifier } from '@pellux/goodvibes-sdk/platform/integration
|
|
|
24
24
|
import type { PermissionRequestHandler, PermissionPromptRequest } from '@pellux/goodvibes-sdk/platform/permissions';
|
|
25
25
|
import type { FocusTracker } from './focus-tracker.ts';
|
|
26
26
|
import { shouldFireAlert, FORCE_NOTIFY_DURATION_MS, type ConfigGet } from './alert-gating.ts';
|
|
27
|
+
import type { TerminalNotifier } from './terminal-notifier.ts';
|
|
27
28
|
|
|
28
29
|
export interface ApprovalAlertDeps {
|
|
29
30
|
readonly focusTracker: Pick<FocusTracker, 'shouldAlertWhenUnfocused'>;
|
|
30
31
|
readonly configGet: ConfigGet;
|
|
31
32
|
readonly webhookNotifier?: WebhookNotifier | null;
|
|
33
|
+
/**
|
|
34
|
+
* Optional in-terminal (OSC 9) notifier. When present, a permission prompt
|
|
35
|
+
* appearing also emits an in-terminal notification, gated independently by
|
|
36
|
+
* the notifier's own per-signal config + focus rule (separate from the
|
|
37
|
+
* desktop-alert gate below). Absent in tests/headless.
|
|
38
|
+
*/
|
|
39
|
+
readonly terminalNotifier?: TerminalNotifier | null;
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
/**
|
|
@@ -48,11 +56,16 @@ export function wrapRequestPermissionWithAlert(
|
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
function fireApprovalAlert(request: PermissionPromptRequest, deps: ApprovalAlertDeps): void {
|
|
59
|
+
// PRIVACY: tool name + category only. Never request.args.
|
|
60
|
+
const message = `${request.tool} (${request.category}) is waiting for approval`;
|
|
61
|
+
|
|
62
|
+
// In-terminal (OSC 9) notification fires on its OWN gating (independent of the
|
|
63
|
+
// desktop-alert gate below), so it is emitted before the early return.
|
|
64
|
+
deps.terminalNotifier?.notify('approval-wait', message);
|
|
65
|
+
|
|
51
66
|
if (!shouldFireAlert(deps.focusTracker, deps.configGet, 'behavior.notifyOnApprovalPending')) return;
|
|
52
67
|
|
|
53
68
|
const title = 'GoodVibes — approval needed';
|
|
54
|
-
// PRIVACY: tool name + category only. Never request.args.
|
|
55
|
-
const message = `${request.tool} (${request.category}) is waiting for approval`;
|
|
56
69
|
|
|
57
70
|
try {
|
|
58
71
|
notifyCompletion(title, message, FORCE_NOTIFY_DURATION_MS);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-compaction receipt rendering.
|
|
3
|
+
*
|
|
4
|
+
* The SDK emits a mandatory COMPACTION_RECEIPT event after every automatic (and
|
|
5
|
+
* the manual) compaction path, so a compaction is never silent. This module
|
|
6
|
+
* turns that receipt into a distinct, multi-line `[Compaction]` block for the
|
|
7
|
+
* transcript. It is a pure formatter — no state, no I/O — so it is unit-testable
|
|
8
|
+
* and the wiring layer only has to route the returned string.
|
|
9
|
+
*
|
|
10
|
+
* Why a visible receipt: automatic behavior (the orchestrator's post-turn
|
|
11
|
+
* auto-compaction) previously ran without any transcript trace. The receipt
|
|
12
|
+
* makes the audit trail visible: what was compacted, the quality the guard
|
|
13
|
+
* computed, whether the standing-instruction chain was re-injected, and the
|
|
14
|
+
* outcome — applied, kept-original (quality guard rejected it), or failed.
|
|
15
|
+
*
|
|
16
|
+
* The `[Compaction]` prefix is one of the FORCE_CONVERSATION_PREFIXES the
|
|
17
|
+
* system-message router always surfaces inline (see system-message-router.ts),
|
|
18
|
+
* so the receipt cannot be routed away into a panel and vanish.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The shape of the SDK's COMPACTION_RECEIPT event payload we render from. */
|
|
22
|
+
export interface CompactionReceiptInput {
|
|
23
|
+
readonly trigger: 'auto' | 'manual';
|
|
24
|
+
readonly strategy: string;
|
|
25
|
+
readonly tokensBefore: number;
|
|
26
|
+
readonly tokensAfter: number;
|
|
27
|
+
readonly messagesBefore: number;
|
|
28
|
+
readonly messagesAfter: number;
|
|
29
|
+
readonly qualityScore: number;
|
|
30
|
+
readonly qualityGrade: string;
|
|
31
|
+
readonly lowQuality: boolean;
|
|
32
|
+
readonly instructionsReinjected: boolean;
|
|
33
|
+
readonly validationPassed: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* IDs of the sections the compactor actually carried into the compacted
|
|
36
|
+
* output. Rendered plainly so the receipt says which parts of context
|
|
37
|
+
* survived; an empty list is narrated as "none recorded" rather than hidden.
|
|
38
|
+
*/
|
|
39
|
+
readonly sectionsIncluded: readonly string[];
|
|
40
|
+
readonly outcome: 'applied' | 'kept-original' | 'failed';
|
|
41
|
+
readonly detail?: string | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* The strategy the caller REQUESTED (from `behavior.compactionStrategy`),
|
|
44
|
+
* present only when it differs from `strategy` — i.e. a distiller→structured
|
|
45
|
+
* fallback happened. `strategy` always names what actually ran.
|
|
46
|
+
*/
|
|
47
|
+
readonly requestedStrategy?: string | undefined;
|
|
48
|
+
/** Why the requested strategy fell back to `strategy`. Present only alongside requestedStrategy. */
|
|
49
|
+
readonly strategyFallbackReason?: string | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fmtN(n: number): string {
|
|
53
|
+
return Math.max(0, Math.round(n)).toLocaleString();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Human-readable outcome clause naming exactly what happened. */
|
|
57
|
+
function describeOutcome(outcome: CompactionReceiptInput['outcome']): string {
|
|
58
|
+
switch (outcome) {
|
|
59
|
+
case 'applied':
|
|
60
|
+
return 'applied — compacted context committed';
|
|
61
|
+
case 'kept-original':
|
|
62
|
+
return 'kept original — quality guard rejected the summary, conversation retained';
|
|
63
|
+
case 'failed':
|
|
64
|
+
return 'failed — compaction did not produce a usable result';
|
|
65
|
+
default:
|
|
66
|
+
return outcome;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the distinct multi-line receipt block. Lines after the header are
|
|
72
|
+
* indented two spaces so the transcript renders them as one grouped block under
|
|
73
|
+
* the `[Compaction] Receipt` heading.
|
|
74
|
+
*/
|
|
75
|
+
export function buildCompactionReceiptBlock(receipt: CompactionReceiptInput): string {
|
|
76
|
+
const triggerWord = receipt.trigger === 'auto' ? 'Automatic' : 'Manual';
|
|
77
|
+
const savings = Math.max(0, receipt.tokensBefore - receipt.tokensAfter);
|
|
78
|
+
const savingsPct = receipt.tokensBefore > 0
|
|
79
|
+
? Math.round((savings / receipt.tokensBefore) * 100)
|
|
80
|
+
: 0;
|
|
81
|
+
|
|
82
|
+
const lines: string[] = [];
|
|
83
|
+
lines.push(`[Compaction] Receipt — ${triggerWord.toLowerCase()} compaction, outcome: ${describeOutcome(receipt.outcome)}`);
|
|
84
|
+
|
|
85
|
+
// Only report the size delta when the summary was actually applied; on
|
|
86
|
+
// kept-original / failed the "after" counts describe the retained original,
|
|
87
|
+
// so a savings figure would be misleading.
|
|
88
|
+
if (receipt.outcome === 'applied') {
|
|
89
|
+
lines.push(
|
|
90
|
+
` ${receipt.messagesBefore} → ${receipt.messagesAfter} messages, ` +
|
|
91
|
+
`~${fmtN(receipt.tokensBefore)} → ~${fmtN(receipt.tokensAfter)} tokens ` +
|
|
92
|
+
`(saved ~${fmtN(savings)}, ${savingsPct}%).`,
|
|
93
|
+
);
|
|
94
|
+
} else {
|
|
95
|
+
lines.push(` Context retained at ~${fmtN(receipt.tokensBefore)} tokens across ${receipt.messagesBefore} messages.`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const gradeStr = receipt.qualityGrade ? `${receipt.qualityGrade} ` : '';
|
|
99
|
+
const qualitySuffix = receipt.lowQuality ? ' (below the quality bar)' : '';
|
|
100
|
+
lines.push(` Quality: ${gradeStr}${Math.round(receipt.qualityScore)}/100${qualitySuffix} · strategy: ${receipt.strategy || 'unknown'}.`);
|
|
101
|
+
|
|
102
|
+
// Only present on a distiller→structured fallback — say plainly what was
|
|
103
|
+
// requested, what actually ran, and why, so the fallback is never silent.
|
|
104
|
+
if (receipt.requestedStrategy && receipt.requestedStrategy !== receipt.strategy) {
|
|
105
|
+
const reasonSuffix = receipt.strategyFallbackReason ? `: ${receipt.strategyFallbackReason}` : '';
|
|
106
|
+
lines.push(` Requested strategy "${receipt.requestedStrategy}" fell back to "${receipt.strategy}"${reasonSuffix}.`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const reinjected = receipt.instructionsReinjected ? 'standing instructions re-injected' : 'no standing instructions to re-inject';
|
|
110
|
+
const validated = receipt.validationPassed ? 'validation passed' : 'validation did NOT pass';
|
|
111
|
+
lines.push(` ${reinjected}; ${validated}.`);
|
|
112
|
+
|
|
113
|
+
// Name the sections carried into the compacted output. Only meaningful when a
|
|
114
|
+
// summary was actually applied; on kept-original / failed nothing new was
|
|
115
|
+
// committed, so an empty list there is expected, not a fault.
|
|
116
|
+
const sections = (receipt.sectionsIncluded ?? []).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
117
|
+
if (receipt.outcome === 'applied') {
|
|
118
|
+
const sectionsText = sections.length > 0 ? sections.join(', ') : 'none recorded';
|
|
119
|
+
lines.push(` Sections included: ${sectionsText}.`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (receipt.detail && receipt.detail.trim().length > 0) {
|
|
123
|
+
lines.push(` ${receipt.detail.trim()}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return lines.join('\n');
|
|
127
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-routing-chip — turns a mid-session model change into a visible one-line
|
|
3
|
+
* conversation notice so a route/fallback/downgrade is NEVER silent.
|
|
4
|
+
*
|
|
5
|
+
* Driven by the provider registry's `MODEL_CHANGED` runtime event (fired on
|
|
6
|
+
* every setCurrentModel — user command, automatic capability route, or
|
|
7
|
+
* SDK-internal downgrade). The chip's job is the OTHERWISE-SILENT changes: the
|
|
8
|
+
* SDK-internal auto-route/downgrade paths call setCurrentModel with no TUI-side
|
|
9
|
+
* narration of their own.
|
|
10
|
+
*
|
|
11
|
+
* Division of labour with the failover path: the optimizer-gated failover in
|
|
12
|
+
* stream-event-wiring.ts already renders a richer `[Failover] from → to
|
|
13
|
+
* (reason)(+cost)` line AND records the transition into the optimizer's
|
|
14
|
+
* fallback log. So when a MODEL_CHANGED correlates to a just-recorded fallback
|
|
15
|
+
* transition, this chip stays silent (buildRoutingChip returns null) — the
|
|
16
|
+
* [Failover] line already narrated that exact change with its real reason.
|
|
17
|
+
* Every other change is surfaced here.
|
|
18
|
+
*
|
|
19
|
+
* Reason honesty: for the paths this chip surfaces the routing layer exposes no
|
|
20
|
+
* machine-readable reason (a deliberate user switch has none; an SDK-internal
|
|
21
|
+
* downgrade does not thread one to the TUI), so the notice states "reason
|
|
22
|
+
* unknown" rather than omitting the change — per the honesty rule that a change
|
|
23
|
+
* with no explanation is still reported, just marked unknown.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The subset of a FallbackTransition this module reads (structural, SDK-agnostic). */
|
|
27
|
+
export interface FallbackTransitionLike {
|
|
28
|
+
readonly from: string;
|
|
29
|
+
readonly to: string;
|
|
30
|
+
readonly reason: string;
|
|
31
|
+
readonly ts: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The subset of a MODEL_CHANGED event this module reads. */
|
|
35
|
+
export interface ModelChangedLike {
|
|
36
|
+
readonly registryKey: string;
|
|
37
|
+
readonly provider: string;
|
|
38
|
+
readonly previous?: { readonly registryKey: string; readonly provider: string } | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** How long after a recorded fallback transition a MODEL_CHANGED still counts as "that failover". */
|
|
42
|
+
export const FALLBACK_CORRELATION_WINDOW_MS = 4_000;
|
|
43
|
+
|
|
44
|
+
/** The forced conversation prefix so the notice is never gated as noise or routed away. */
|
|
45
|
+
export const ROUTING_CHIP_PREFIX = '[Routing]';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Most recent fallback-log reason for a from→to provider transition within the
|
|
49
|
+
* correlation window, or null. Scans newest-first so the freshest transition wins.
|
|
50
|
+
*/
|
|
51
|
+
export function findRecentFallbackReason(
|
|
52
|
+
fallbackLog: readonly FallbackTransitionLike[],
|
|
53
|
+
fromProvider: string,
|
|
54
|
+
toProvider: string,
|
|
55
|
+
now: number,
|
|
56
|
+
windowMs: number = FALLBACK_CORRELATION_WINDOW_MS,
|
|
57
|
+
): string | null {
|
|
58
|
+
for (let i = fallbackLog.length - 1; i >= 0; i--) {
|
|
59
|
+
const t = fallbackLog[i]!;
|
|
60
|
+
if (t.from === fromProvider && t.to === toProvider && now - t.ts <= windowMs) return t.reason;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The routing-chip line to emit for a MODEL_CHANGED event, or null to stay
|
|
67
|
+
* silent. Null only when there is no real change (no/same previous model) or
|
|
68
|
+
* when the change was a failover already narrated by its own [Failover] line
|
|
69
|
+
* (correlated via the fallback log). Otherwise returns the honest
|
|
70
|
+
* `[Routing] model changed: old → new (reason unknown)` notice.
|
|
71
|
+
*/
|
|
72
|
+
export function buildRoutingChip(
|
|
73
|
+
change: ModelChangedLike,
|
|
74
|
+
fallbackLog: readonly FallbackTransitionLike[],
|
|
75
|
+
now: number,
|
|
76
|
+
): string | null {
|
|
77
|
+
const prev = change.previous;
|
|
78
|
+
if (!prev || prev.registryKey === change.registryKey) return null;
|
|
79
|
+
const failoverReason = findRecentFallbackReason(fallbackLog, prev.provider, change.provider, now);
|
|
80
|
+
if (failoverReason !== null) return null; // [Failover] already narrated this transition
|
|
81
|
+
return `${ROUTING_CHIP_PREFIX} model changed: ${prev.registryKey} → ${change.registryKey} (reason unknown)`;
|
|
82
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session permission mode — the small vocabulary and cycle logic shared by the
|
|
3
|
+
* Shift+Tab mode-cycle key, the `/plan` toggle, and the footer mode pill.
|
|
4
|
+
*
|
|
5
|
+
* The SDK owns the permission mode: it lives at config key `permissions.mode`
|
|
6
|
+
* and is read by the SDK's PermissionManager.getMode() (which the orchestrator
|
|
7
|
+
* consults for the standing plan-mode instruction). Setting the config value is
|
|
8
|
+
* the SDK-sanctioned way to change the mode — the SDK's mode-change emitter then
|
|
9
|
+
* broadcasts PERMISSION_MODE_CHANGED on the runtime bus so every attached
|
|
10
|
+
* surface can reflect it. This module only maps and cycles the values; it never
|
|
11
|
+
* owns the state.
|
|
12
|
+
*
|
|
13
|
+
* Two vocabularies exist and must not be confused:
|
|
14
|
+
* - the CONFIG values ('prompt' | 'allow-all' | 'custom' | 'plan' |
|
|
15
|
+
* 'accept-edits') — what is stored and what this module operates on.
|
|
16
|
+
* - the user-facing LABELS ('normal' | 'auto' | 'custom' | 'plan' |
|
|
17
|
+
* 'accept-edits') — what the pill shows.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The config `permissions.mode` values, exactly as the SDK schema defines them. */
|
|
21
|
+
export type PermissionModeValue = 'prompt' | 'allow-all' | 'custom' | 'plan' | 'accept-edits';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The Shift+Tab cycle order over the four named SESSION modes. `custom` is a
|
|
25
|
+
* per-rule policy, not a session posture, so it is deliberately excluded from
|
|
26
|
+
* the cycle — cycling FROM custom starts at the first entry (normal).
|
|
27
|
+
*
|
|
28
|
+
* Order (matches the settled convention: escalating autonomy, then wrap):
|
|
29
|
+
* normal → accept-edits → plan → auto → normal
|
|
30
|
+
*/
|
|
31
|
+
export const PERMISSION_MODE_CYCLE: readonly PermissionModeValue[] = [
|
|
32
|
+
'prompt',
|
|
33
|
+
'accept-edits',
|
|
34
|
+
'plan',
|
|
35
|
+
'allow-all',
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
/** User-facing label for a mode value (what the pill and messages show). */
|
|
39
|
+
export function permissionModeLabel(mode: PermissionModeValue | string | undefined): string {
|
|
40
|
+
switch (mode) {
|
|
41
|
+
case 'prompt': return 'normal';
|
|
42
|
+
case 'allow-all': return 'auto';
|
|
43
|
+
case 'plan': return 'plan';
|
|
44
|
+
case 'accept-edits': return 'accept-edits';
|
|
45
|
+
case 'custom': return 'custom';
|
|
46
|
+
default: return 'normal';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A coarse tone key for the pill, so the renderer can color modes without
|
|
52
|
+
* importing the value vocabulary. `neutral` for normal, `caution` for the
|
|
53
|
+
* autonomy-raising modes, `info` for plan (read-only, safe).
|
|
54
|
+
*/
|
|
55
|
+
export type PermissionModeTone = 'neutral' | 'info' | 'caution';
|
|
56
|
+
|
|
57
|
+
export function permissionModeTone(mode: PermissionModeValue | string | undefined): PermissionModeTone {
|
|
58
|
+
switch (mode) {
|
|
59
|
+
case 'plan': return 'info';
|
|
60
|
+
case 'accept-edits': return 'caution';
|
|
61
|
+
case 'allow-all': return 'caution';
|
|
62
|
+
case 'custom': return 'caution';
|
|
63
|
+
default: return 'neutral';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Next mode in the Shift+Tab cycle. An unknown or `custom` current value maps
|
|
69
|
+
* to the first cycle entry (normal), so the cycle is always well-defined.
|
|
70
|
+
*/
|
|
71
|
+
export function nextPermissionMode(current: PermissionModeValue | string | undefined): PermissionModeValue {
|
|
72
|
+
const idx = PERMISSION_MODE_CYCLE.indexOf(current as PermissionModeValue);
|
|
73
|
+
if (idx < 0) return PERMISSION_MODE_CYCLE[0];
|
|
74
|
+
return PERMISSION_MODE_CYCLE[(idx + 1) % PERMISSION_MODE_CYCLE.length];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True when the value is plan mode. */
|
|
78
|
+
export function isPlanMode(mode: PermissionModeValue | string | undefined): boolean {
|
|
79
|
+
return mode === 'plan';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The result of a `/plan` toggle: entering plan mode from any non-plan mode,
|
|
84
|
+
* or leaving it back to normal. Kept here so the command and any future caller
|
|
85
|
+
* agree on the toggle target.
|
|
86
|
+
*/
|
|
87
|
+
export function togglePlanMode(current: PermissionModeValue | string | undefined): PermissionModeValue {
|
|
88
|
+
return isPlanMode(current) ? 'prompt' : 'plan';
|
|
89
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rewind-receipt.ts — transcript rendering for an applied unified rewind.
|
|
3
|
+
*
|
|
4
|
+
* The SDK's UnifiedRewindService returns a RewindReceipt from rewind.apply (and
|
|
5
|
+
* emits a matching REWIND_APPLIED workspace event). This module turns that
|
|
6
|
+
* receipt into a distinct, multi-line `[Rewind]` block, mirroring the
|
|
7
|
+
* `[Compaction]` receipt (compaction-receipt.ts) so a rewind is never a silent
|
|
8
|
+
* mutation of files or conversation history — the transcript records exactly
|
|
9
|
+
* what was restored and whether it can be reversed.
|
|
10
|
+
*
|
|
11
|
+
* The `[Rewind]` prefix is one of the FORCE_CONVERSATION_PREFIXES the
|
|
12
|
+
* system-message router always surfaces inline (see core/system-message-router.ts),
|
|
13
|
+
* so the receipt cannot be routed into a panel and vanish.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The subset of the SDK's RewindReceipt this formatter renders. */
|
|
17
|
+
export interface RewindReceiptInput {
|
|
18
|
+
readonly scope: 'files' | 'conversation' | 'both';
|
|
19
|
+
readonly turnId: string | null;
|
|
20
|
+
readonly files: {
|
|
21
|
+
readonly restored: boolean;
|
|
22
|
+
readonly restoredFileCount: number;
|
|
23
|
+
readonly removedFileCount: number;
|
|
24
|
+
readonly safetyCheckpointId: string | null;
|
|
25
|
+
} | null;
|
|
26
|
+
readonly conversation: {
|
|
27
|
+
readonly rewound: boolean;
|
|
28
|
+
readonly droppedMessages: number;
|
|
29
|
+
readonly undoSnapshotId: string | null;
|
|
30
|
+
} | null;
|
|
31
|
+
readonly undoAvailable: boolean;
|
|
32
|
+
readonly warnings: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function plural(n: number, word: string): string {
|
|
36
|
+
return `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Short, human turn reference for the receipt heading. */
|
|
40
|
+
function describeAnchor(turnId: string | null): string {
|
|
41
|
+
return turnId ? `turn ${turnId.length > 12 ? `${turnId.slice(0, 12)}…` : turnId}` : 'the most recent checkpoint';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the distinct multi-line `[Rewind]` receipt block. Pure — no state, no
|
|
46
|
+
* I/O. Lines after the header are indented two spaces so the transcript renders
|
|
47
|
+
* them as one grouped block under the `[Rewind] Receipt` heading.
|
|
48
|
+
*/
|
|
49
|
+
export function buildRewindReceiptBlock(receipt: RewindReceiptInput): string {
|
|
50
|
+
const lines: string[] = [];
|
|
51
|
+
lines.push(`[Rewind] Receipt — rewound ${receipt.scope} to ${describeAnchor(receipt.turnId)}.`);
|
|
52
|
+
|
|
53
|
+
if (receipt.files) {
|
|
54
|
+
if (receipt.files.restored) {
|
|
55
|
+
lines.push(
|
|
56
|
+
` Files: restored ${plural(receipt.files.restoredFileCount, 'file')}, ` +
|
|
57
|
+
`removed ${plural(receipt.files.removedFileCount, 'file')}.`,
|
|
58
|
+
);
|
|
59
|
+
} else {
|
|
60
|
+
lines.push(' Files: not restored — no workspace checkpoint matched this anchor.');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (receipt.conversation) {
|
|
65
|
+
if (receipt.conversation.rewound) {
|
|
66
|
+
lines.push(` Conversation: dropped ${plural(receipt.conversation.droppedMessages, 'message')} after this turn.`);
|
|
67
|
+
} else {
|
|
68
|
+
lines.push(' Conversation: not rewound — no conversation boundary was recorded for this anchor.');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
lines.push(
|
|
73
|
+
receipt.undoAvailable
|
|
74
|
+
? ' Reversible: run /undo rewind to restore the pre-rewind state (/redo rewind re-applies).'
|
|
75
|
+
: ' Reversible: no undo point was recorded for this rewind.',
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
for (const warning of receipt.warnings) {
|
|
79
|
+
lines.push(` note: ${warning}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return lines.join('\n');
|
|
83
|
+
}
|