@pellux/goodvibes-tui 1.19.5 → 1.20.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 +12 -0
- package/LICENSE +21 -0
- package/README.md +143 -1613
- package/docs/README.md +4 -4
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +7 -7
- package/src/cli/ensure-goodvibes-gitignore.ts +16 -4
- package/src/cli/entrypoint.ts +5 -1
- package/src/cli/launch-auto-update.ts +63 -4
- package/src/cli/tui-startup.ts +131 -15
- package/src/config/surface.ts +21 -0
- package/src/config/tui-extension-settings.ts +9 -1
- package/src/core/bookmark-navigation.ts +44 -0
- package/src/core/conversation-line-cache.ts +87 -19
- package/src/core/conversation-rendering.ts +186 -28
- package/src/core/conversation-tool-groups.ts +152 -0
- package/src/core/conversation-types.ts +30 -3
- package/src/core/conversation.ts +17 -0
- package/src/core/rewind-turn-anchors.ts +12 -13
- package/src/core/session-recovery.ts +62 -7
- package/src/core/session-resume-core.ts +206 -0
- package/src/core/transcript-journal.ts +32 -6
- package/src/core/turn-event-wiring.ts +55 -12
- package/src/input/command-args-hint.ts +11 -2
- package/src/input/command-registry.ts +19 -0
- package/src/input/commands/checkpoint-runtime.ts +3 -3
- package/src/input/commands/health-runtime.ts +1 -3
- package/src/input/commands/local-runtime.ts +19 -2
- package/src/input/commands/rewind-runtime.ts +132 -1
- package/src/input/commands/runtime-services.ts +11 -1
- package/src/input/commands/secret-runtime.ts +1 -1
- package/src/input/commands/session-content.ts +9 -1
- package/src/input/commands/session-picker-filter.ts +28 -0
- package/src/input/commands/session-workflow.ts +62 -73
- package/src/input/commands/session.ts +36 -1
- package/src/input/commands/shell-core.ts +57 -5
- package/src/input/commands/update-runtime.ts +120 -4
- package/src/input/commands/websearch-runtime.ts +1 -1
- package/src/input/handler-content-actions.ts +63 -39
- package/src/input/handler-feed-routes.ts +17 -3
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-interactions.ts +19 -23
- package/src/input/handler-modal-token-routes.ts +5 -0
- package/src/input/handler-types.ts +4 -1
- package/src/input/handler-ui-state.ts +6 -2
- package/src/input/handler.ts +19 -9
- package/src/input/search.ts +94 -2
- package/src/input/selection-modal-queue.ts +176 -0
- package/src/main.ts +55 -56
- package/src/panels/builtin/operations.ts +2 -1
- package/src/panels/provider-health-domains.ts +2 -2
- package/src/renderer/block-actions-overlay.ts +59 -0
- package/src/renderer/block-actions.ts +1 -3
- package/src/renderer/context-status-hint.ts +73 -5
- package/src/renderer/conversation-layout.ts +16 -0
- package/src/renderer/conversation-overlays.ts +6 -0
- package/src/renderer/help-overlay.ts +69 -53
- package/src/renderer/selection-modal-overlay.ts +66 -10
- package/src/renderer/shell-surface.ts +15 -0
- package/src/renderer/tool-result-expanded-lines.ts +48 -0
- package/src/renderer/ui-factory.ts +1 -1
- package/src/runtime/bootstrap-command-context.ts +5 -1
- package/src/runtime/bootstrap-command-parts.ts +5 -2
- package/src/runtime/bootstrap-core.ts +3 -3
- package/src/runtime/bootstrap-hook-bridge.ts +62 -44
- package/src/runtime/bootstrap-shell.ts +31 -16
- package/src/runtime/bootstrap.ts +13 -18
- package/src/runtime/code-index-services.ts +19 -0
- package/src/runtime/durability-services.ts +11 -7
- package/src/runtime/index.ts +12 -1
- package/src/runtime/process-lifecycle.ts +63 -2
- package/src/runtime/recovery-autosave.ts +48 -0
- package/src/runtime/recovery-offer-wiring.ts +89 -0
- package/src/runtime/recovery-prompt.ts +312 -0
- package/src/runtime/resume-notice.ts +26 -21
- package/src/runtime/services.ts +14 -16
- package/src/runtime/session-liveness-marker.ts +120 -0
- package/src/runtime/session-pointer-surface.ts +22 -0
- package/src/runtime/session-resume-liveness-confirm.ts +84 -0
- package/src/runtime/session-storage-services.ts +41 -0
- package/src/runtime/workspace-checkpointing.ts +10 -2
- package/src/shell/recovery-input-helpers.ts +17 -98
- package/src/shell/retry-affordance.ts +123 -0
- package/src/shell/ui-openers.ts +9 -0
- package/src/utils/splash-lines.ts +1 -1
- package/src/version.ts +1 -1
package/docs/README.md
CHANGED
|
@@ -8,10 +8,10 @@ This directory contains the current product documentation for `goodvibes-tui`.
|
|
|
8
8
|
Installation, first-run setup, provider configuration, common paths, and basic run/build flows.
|
|
9
9
|
|
|
10
10
|
- [Configuration reference](configuration.md)
|
|
11
|
-
TUI-owned
|
|
11
|
+
The layered settings model, the key-settings table, permission modes and the policy/trust system, plus the TUI-owned namespaces you add by hand to settings.json: checkpoint root guard, the scriptable statusline, session behavior, and launch-time self-update.
|
|
12
12
|
|
|
13
13
|
- [Deployment and services](deployment-and-services.md)
|
|
14
|
-
TUI-only mode, compiled binary behavior, in-process daemon/listener hosting, source-run daemon mode, inbound TLS, outbound trust configuration,
|
|
14
|
+
TUI-only mode, compiled binary behavior, in-process daemon/listener hosting, source-run daemon mode, inbound TLS, outbound trust configuration, control-plane entrypoints, the service registry and secret references, profiles and setup transfer, and integration helpers.
|
|
15
15
|
|
|
16
16
|
- [Remote access: a home server setup](remote-access.md)
|
|
17
17
|
Step-by-step continuity setup: daemon on an always-on box, webui from any browser, TUI over SSH, Tailscale for reachability and TLS, validated by `goodvibes doctor`.
|
|
@@ -47,10 +47,10 @@ This directory contains the current product documentation for `goodvibes-tui`.
|
|
|
47
47
|
Global flags reference: session lifecycle (`--continue`, `--resume`, `--fork`), confirmation bypass (`-y`/`--non-interactive`), output format consolidation, `--host` alias, and all other startup flags.
|
|
48
48
|
|
|
49
49
|
- [Tools and commands](tools-and-commands.md)
|
|
50
|
-
Built-in tool families, slash-command families, and the operator
|
|
50
|
+
Built-in tool families and a per-tool reference, slash-command families, the full keyboard-binding reference, the agent/archetype and hook systems, MCP and plugin extensibility, and the operator, diagnostics, and workflow surfaces that tie them together.
|
|
51
51
|
|
|
52
52
|
- [QEMU sandbox bootstrapping](qemu-sandbox.md)
|
|
53
|
-
|
|
53
|
+
Sandbox isolation modes, host prerequisites, generated QEMU setup bundle files, Debian cloud-image bootstrapping, guest runtime installs, validation, and troubleshooting.
|
|
54
54
|
|
|
55
55
|
- [Release and publishing](release-and-publishing.md)
|
|
56
56
|
Release validation, GitHub CD, compiled binary releases, npm distribution, and the GitHub Packages mirror.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-tui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
4
4
|
"description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.ts",
|
|
@@ -104,8 +104,8 @@
|
|
|
104
104
|
"@anthropic-ai/vertex-sdk": "^0.16.0",
|
|
105
105
|
"@ast-grep/napi": "^0.42.0",
|
|
106
106
|
"@aws/bedrock-token-generator": "^1.1.0",
|
|
107
|
-
"@pellux/goodvibes-sdk": "1.
|
|
108
|
-
"@pellux/goodvibes-terminal-shell": "1.
|
|
107
|
+
"@pellux/goodvibes-sdk": "1.12.0",
|
|
108
|
+
"@pellux/goodvibes-terminal-shell": "1.12.0",
|
|
109
109
|
"bash-language-server": "^5.6.0",
|
|
110
110
|
"fuse.js": "^7.1.0",
|
|
111
111
|
"graphql": "^16.13.2",
|
|
@@ -131,10 +131,10 @@
|
|
|
131
131
|
"typescript": "^5.9.3"
|
|
132
132
|
},
|
|
133
133
|
"optionalDependencies": {
|
|
134
|
-
"@pellux/goodvibes-tui-linux-x64": "1.
|
|
135
|
-
"@pellux/goodvibes-tui-linux-arm64": "1.
|
|
136
|
-
"@pellux/goodvibes-tui-darwin-x64": "1.
|
|
137
|
-
"@pellux/goodvibes-tui-darwin-arm64": "1.
|
|
134
|
+
"@pellux/goodvibes-tui-linux-x64": "1.20.0",
|
|
135
|
+
"@pellux/goodvibes-tui-linux-arm64": "1.20.0",
|
|
136
|
+
"@pellux/goodvibes-tui-darwin-x64": "1.20.0",
|
|
137
|
+
"@pellux/goodvibes-tui-darwin-arm64": "1.20.0"
|
|
138
138
|
},
|
|
139
139
|
"trustedDependencies": [],
|
|
140
140
|
"overrides": {
|
|
@@ -13,20 +13,32 @@ import { join } from 'node:path';
|
|
|
13
13
|
*/
|
|
14
14
|
const GOODVIBES_IGNORE_PATTERN = /(^|\n)[ \t]*\/?\.goodvibes\/?\*?[ \t]*(\n|$)/;
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Ensures the project's .gitignore excludes .goodvibes/, appending the rule
|
|
18
|
+
* exactly once if it is verifiably absent.
|
|
19
|
+
*
|
|
20
|
+
* @returns true only the FIRST time the rule is actually written this call —
|
|
21
|
+
* i.e. this specific invocation just appended/created it. Every subsequent
|
|
22
|
+
* launch (rule already present) and every no-op case (not a git repo, or the
|
|
23
|
+
* write failed) return false. The caller uses this to print a one-time
|
|
24
|
+
* notice instead of staying silent about an edit to the user's own project file.
|
|
25
|
+
*/
|
|
26
|
+
export function ensureGoodvibesGitignore(projectRoot: string): boolean {
|
|
17
27
|
try {
|
|
18
|
-
if (!existsSync(join(projectRoot, '.git'))) return; // only matters for git projects
|
|
28
|
+
if (!existsSync(join(projectRoot, '.git'))) return false; // only matters for git projects
|
|
19
29
|
const gitignorePath = join(projectRoot, '.gitignore');
|
|
20
30
|
const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : '';
|
|
21
|
-
if (GOODVIBES_IGNORE_PATTERN.test(existing)) return; // already excluded
|
|
31
|
+
if (GOODVIBES_IGNORE_PATTERN.test(existing)) return false; // already excluded
|
|
22
32
|
const rule = '# goodvibes-tui transient state\n.goodvibes/\n';
|
|
23
33
|
if (existing.length === 0) {
|
|
24
34
|
writeFileSync(gitignorePath, rule);
|
|
25
|
-
return;
|
|
35
|
+
return true;
|
|
26
36
|
}
|
|
27
37
|
const separator = existing.endsWith('\n') ? '\n' : '\n\n';
|
|
28
38
|
appendFileSync(gitignorePath, separator + rule);
|
|
39
|
+
return true;
|
|
29
40
|
} catch {
|
|
30
41
|
// Best-effort only — never block startup on a gitignore write failure.
|
|
42
|
+
return false;
|
|
31
43
|
}
|
|
32
44
|
}
|
package/src/cli/entrypoint.ts
CHANGED
|
@@ -104,7 +104,11 @@ export async function prepareShellCliRuntime(
|
|
|
104
104
|
homeDirectory: bootstrapHomeDirectory,
|
|
105
105
|
} = resolveShellEntrypointOwnership(roots, cli.flags.workingDir ?? (cli.command === 'tui' ? cli.commandArgs[0] : undefined));
|
|
106
106
|
configureActivityLogger(join(bootstrapWorkingDir, '.goodvibes', 'logs'));
|
|
107
|
-
|
|
107
|
+
// Only prints the first time the rule is actually appended (not on every
|
|
108
|
+
// launch) — see ensureGoodvibesGitignore's return-value doc.
|
|
109
|
+
if (ensureGoodvibesGitignore(bootstrapWorkingDir)) {
|
|
110
|
+
console.log("[goodvibes] added '.goodvibes/' to .gitignore — this directory holds transient TUI state (logs, session cache, exec output), not project source.");
|
|
111
|
+
}
|
|
108
112
|
const configManager = new ConfigManager({
|
|
109
113
|
workingDir: bootstrapWorkingDir,
|
|
110
114
|
homeDir: bootstrapHomeDirectory,
|
|
@@ -14,6 +14,14 @@
|
|
|
14
14
|
* - a successful update restarts into the new binary and the restarted
|
|
15
15
|
* process prints a receipt naming both versions, so the swap is never
|
|
16
16
|
* silent.
|
|
17
|
+
* - the install gets a budget too, and running out of it is reported for
|
|
18
|
+
* what actually happened rather than assumed. The budget cancels the work
|
|
19
|
+
* through a real AbortSignal, so a download that is still in flight is
|
|
20
|
+
* genuinely stopped and "deferred — will retry next launch" is true. Once
|
|
21
|
+
* the swap has begun it is never interrupted, so that outcome says the
|
|
22
|
+
* binary WAS replaced and asks for a restart instead. The two are told
|
|
23
|
+
* apart by the swap state the install reports back (UpdateSwapProgress),
|
|
24
|
+
* never by guessing.
|
|
17
25
|
* - every swap keeps the outgoing file at `<path>.previous`; rollback is
|
|
18
26
|
* one command (`/update rollback`).
|
|
19
27
|
*
|
|
@@ -29,7 +37,12 @@
|
|
|
29
37
|
*/
|
|
30
38
|
import { spawnSync } from 'node:child_process';
|
|
31
39
|
import { detectInstallKind, normalizeVersion, type UpdateFetchLike } from '../runtime/update-check.ts';
|
|
32
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
applyUpdate,
|
|
42
|
+
checkForUpdate,
|
|
43
|
+
createUpdateSwapProgress,
|
|
44
|
+
type ApplyUpdateOptions,
|
|
45
|
+
} from '../input/commands/update-runtime.ts';
|
|
33
46
|
import { readUpdateSettings, type UpdateSettings } from '../config/tui-extension-settings.ts';
|
|
34
47
|
import type { ConfigManager } from '../config/index.ts';
|
|
35
48
|
import { VERSION } from '../version.ts';
|
|
@@ -46,10 +59,23 @@ export const LAUNCH_UPDATED_FROM_ENV = 'GOODVIBES_LAUNCH_UPDATED_FROM';
|
|
|
46
59
|
/** Default budget for the launch-time version check; user-tunable via update.launchCheckTimeoutMs. */
|
|
47
60
|
export const LAUNCH_UPDATE_CHECK_TIMEOUT_MS = 2500;
|
|
48
61
|
|
|
62
|
+
/** Default budget for the launch-time download+verify+swap; user-tunable via update.applyTimeoutMs. Generous — a binary download over a slow connection genuinely takes a while — but bounded so launch is never held hostage. */
|
|
63
|
+
export const LAUNCH_APPLY_TIMEOUT_MS = 45_000;
|
|
64
|
+
|
|
49
65
|
export type LaunchAutoUpdateOutcome =
|
|
50
66
|
| {
|
|
51
67
|
readonly action: 'continue';
|
|
52
|
-
readonly reason:
|
|
68
|
+
readonly reason:
|
|
69
|
+
| 'just-updated'
|
|
70
|
+
| 'disabled'
|
|
71
|
+
| 'not-swappable-install'
|
|
72
|
+
| 'already-current'
|
|
73
|
+
| 'check-skipped'
|
|
74
|
+
| 'update-failed'
|
|
75
|
+
/** The budget ran out before anything was written, and the work was cancelled. */
|
|
76
|
+
| 'update-deferred'
|
|
77
|
+
/** The budget ran out after the swap had begun; the swap finished, so a restart picks up the new version. */
|
|
78
|
+
| 'update-in-background';
|
|
53
79
|
}
|
|
54
80
|
| { readonly action: 'restart'; readonly latestTag: string };
|
|
55
81
|
|
|
@@ -66,6 +92,8 @@ export interface RunLaunchAutoUpdateOptions {
|
|
|
66
92
|
/** Injectable so tests observe the install step instead of swapping real files. */
|
|
67
93
|
readonly apply?: (options: ApplyUpdateOptions) => Promise<void>;
|
|
68
94
|
readonly timeoutMs?: number;
|
|
95
|
+
/** Overrides the download+verify+swap budget; defaults to settings.applyTimeoutMs ?? LAUNCH_APPLY_TIMEOUT_MS. */
|
|
96
|
+
readonly applyTimeoutMs?: number;
|
|
69
97
|
}
|
|
70
98
|
|
|
71
99
|
/** Resolves to 'timeout' when `promise` does not settle within `ms`; the timer never keeps the process alive. */
|
|
@@ -114,16 +142,24 @@ export async function runLaunchAutoUpdate(options: RunLaunchAutoUpdateOptions):
|
|
|
114
142
|
check = 'timeout';
|
|
115
143
|
}
|
|
116
144
|
if (check === 'timeout') {
|
|
117
|
-
|
|
145
|
+
// Honest about WHY the check was skipped — a timeout most commonly means
|
|
146
|
+
// the update server could not be reached in time, not necessarily that
|
|
147
|
+
// the whole machine is offline (a slow/flaky connection is just as
|
|
148
|
+
// common), so this no longer overclaims "offline".
|
|
149
|
+
options.print("couldn't reach the update server — check skipped");
|
|
118
150
|
return { action: 'continue', reason: 'check-skipped' };
|
|
119
151
|
}
|
|
120
152
|
if (check.isCurrent) {
|
|
121
153
|
return { action: 'continue', reason: 'already-current' };
|
|
122
154
|
}
|
|
123
155
|
|
|
156
|
+
const applyTimeoutMs = options.applyTimeoutMs ?? options.settings.applyTimeoutMs ?? LAUNCH_APPLY_TIMEOUT_MS;
|
|
157
|
+
options.print(`downloading ${check.latestTag}…`);
|
|
158
|
+
const controller = new AbortController();
|
|
159
|
+
const progress = createUpdateSwapProgress();
|
|
124
160
|
try {
|
|
125
161
|
const apply = options.apply ?? applyUpdate;
|
|
126
|
-
|
|
162
|
+
const applyPromise = apply({
|
|
127
163
|
fetchImpl: options.fetchImpl,
|
|
128
164
|
execPath: options.execPath,
|
|
129
165
|
platform: options.platform,
|
|
@@ -131,7 +167,30 @@ export async function runLaunchAutoUpdate(options: RunLaunchAutoUpdateOptions):
|
|
|
131
167
|
currentVersion: options.currentVersion,
|
|
132
168
|
print: options.print,
|
|
133
169
|
configManager: options.configManager,
|
|
170
|
+
signal: controller.signal,
|
|
171
|
+
progress,
|
|
134
172
|
});
|
|
173
|
+
// Losing the race abandons this promise, and a cancelled download rejects
|
|
174
|
+
// shortly after: that rejection is the expected end of the work, not an
|
|
175
|
+
// unhandled failure, so it is absorbed here. The race below still sees
|
|
176
|
+
// every rejection that arrives before the budget runs out.
|
|
177
|
+
applyPromise.catch(() => {});
|
|
178
|
+
const applyResult = await withTimeout(applyPromise, applyTimeoutMs);
|
|
179
|
+
if (applyResult === 'timeout') {
|
|
180
|
+
if (!progress.begun) {
|
|
181
|
+
// Nothing has been written yet, so the work can genuinely be called
|
|
182
|
+
// off — cancel the download in flight, then say it was deferred.
|
|
183
|
+
controller.abort();
|
|
184
|
+
options.print('update deferred — will retry next launch');
|
|
185
|
+
return { action: 'continue', reason: 'update-deferred' };
|
|
186
|
+
}
|
|
187
|
+
// The swap had already started when the budget ran out. A swap is never
|
|
188
|
+
// interrupted, so the binary on disk is the new one — cancelling here
|
|
189
|
+
// would be a lie in the other direction. Report the replacement and the
|
|
190
|
+
// restart it needs instead of claiming a deferral that did not happen.
|
|
191
|
+
options.print(`updated in background; restart to use v${normalizeVersion(progress.targetTag ?? check.latestTag)}`);
|
|
192
|
+
return { action: 'continue', reason: 'update-in-background' };
|
|
193
|
+
}
|
|
135
194
|
options.print(`auto-update: ${check.latestTag} installed — restarting onto the new version`);
|
|
136
195
|
return { action: 'restart', latestTag: check.latestTag };
|
|
137
196
|
} catch (error) {
|
package/src/cli/tui-startup.ts
CHANGED
|
@@ -4,8 +4,12 @@ import type { SelectionItem } from '../input/selection-modal.ts';
|
|
|
4
4
|
import type { WorkspaceRegistrationManager } from '../runtime/trust/workspace-registration.ts';
|
|
5
5
|
import { hasResumableWizardProgress, readOnboardingCheckMarker, readWizardProgress } from '../runtime/onboarding/index.ts';
|
|
6
6
|
import { startOnboardingFastPath } from '../runtime/onboarding/fast-path.ts';
|
|
7
|
-
import { readLastSessionPointer } from '@/runtime/index.ts';
|
|
7
|
+
import { checkRecoveryForSession, readLastSessionPointer, type SessionSurface } from '@/runtime/index.ts';
|
|
8
8
|
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
9
|
+
import { offerRecoverySnapshot } from '../runtime/recovery-prompt.ts';
|
|
10
|
+
import { buildRecoveryOfferWiring } from '../runtime/recovery-offer-wiring.ts';
|
|
11
|
+
import type { ConversationManager } from '../core/conversation.ts';
|
|
12
|
+
import type { SessionManager } from '@pellux/goodvibes-sdk/platform/sessions';
|
|
9
13
|
import type { GoodVibesCliParseResult } from './types.ts';
|
|
10
14
|
|
|
11
15
|
export type TuiStartupShellPaths = Parameters<typeof readOnboardingCheckMarker>[0] & {
|
|
@@ -15,6 +19,109 @@ export type TuiStartupShellPaths = Parameters<typeof readOnboardingCheckMarker>[
|
|
|
15
19
|
|
|
16
20
|
export type FirstOpenTrustLevel = 'trusted' | 'restricted';
|
|
17
21
|
|
|
22
|
+
/**
|
|
23
|
+
* What `resumeNamedSessionWithRecoveryCheck` needs to actually APPLY an
|
|
24
|
+
* accepted crash-recovery snapshot — the same shape `buildRecoveryOfferWiring`
|
|
25
|
+
* (runtime/recovery-offer-wiring.ts) takes, minus `surface` and
|
|
26
|
+
* `commandContext` which this module already has in scope. Optional: a caller
|
|
27
|
+
* that omits it (or a snapshot-free boot) gets exactly today's behavior —
|
|
28
|
+
* straight resume from the durable store, no modal.
|
|
29
|
+
*/
|
|
30
|
+
export interface TuiStartupRecoveryDeps {
|
|
31
|
+
readonly sessionManager: Pick<SessionManager, 'save'>;
|
|
32
|
+
readonly runtime: { sessionId: string; model: string; provider: string };
|
|
33
|
+
readonly conversation: ConversationManager;
|
|
34
|
+
readonly writeLastSessionPointer: (sessionId: string) => void;
|
|
35
|
+
readonly receipt: (line: string) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* resumeNamedSessionWithRecoveryCheck — the pre-resume guard for `--continue`
|
|
40
|
+
* and the bare `--resume` (pointer) path.
|
|
41
|
+
*
|
|
42
|
+
* Resuming a named session straight from its durable store is exactly the
|
|
43
|
+
* post-crash reflex that silently drops an autosave tail: if that session
|
|
44
|
+
* also has a crash-recovery snapshot NEWER than its store, the snapshot (not
|
|
45
|
+
* the shorter store copy) is the live state. `checkRecoveryForSession` is the
|
|
46
|
+
* read-only probe for that; when it finds one, this routes through the same
|
|
47
|
+
* ask-then-retire modal flow the general boot offer uses
|
|
48
|
+
* (runtime/recovery-prompt.ts), scoped to this one session via
|
|
49
|
+
* `targetSessionId` — never auto-applied.
|
|
50
|
+
*
|
|
51
|
+
* - No live snapshot (or `recovery` deps not wired): straight resume, same
|
|
52
|
+
* as before this check existed.
|
|
53
|
+
* - Resume chosen: the snapshot is applied (via `buildRecoveryOfferWiring`'s
|
|
54
|
+
* `applySnapshot`, which rebinds the runtime session id and replays any
|
|
55
|
+
* journal tail) and the plain store resume is skipped — the snapshot IS
|
|
56
|
+
* the more complete copy.
|
|
57
|
+
* - "Not now" (or a dismissal, or a failed load): falls through to the
|
|
58
|
+
* plain store resume, after the follow-up Keep/Remove question the
|
|
59
|
+
* established flow always asks on decline.
|
|
60
|
+
*
|
|
61
|
+
* The modal itself is deferred to the next macrotask, mirroring
|
|
62
|
+
* `scheduleRecoveryOffer`'s own reasoning: `applyInitialTuiCliState` runs
|
|
63
|
+
* before the shell's first render, so asking a question here synchronously
|
|
64
|
+
* would draw it at a blank terminal. The synchronous `checkRecoveryForSession`
|
|
65
|
+
* probe above needs no such deferral — only opening the modal does.
|
|
66
|
+
*/
|
|
67
|
+
async function resumeNamedSessionWithRecoveryCheck(options: {
|
|
68
|
+
readonly sessionId: string;
|
|
69
|
+
readonly surface: SessionSurface;
|
|
70
|
+
readonly commandRegistry: CommandRegistry;
|
|
71
|
+
readonly commandContext: CommandContext;
|
|
72
|
+
readonly render: () => void;
|
|
73
|
+
readonly recovery: TuiStartupRecoveryDeps | undefined;
|
|
74
|
+
}): Promise<void> {
|
|
75
|
+
const { sessionId, surface, commandRegistry, commandContext, render, recovery } = options;
|
|
76
|
+
const plainResume = (): Promise<void> =>
|
|
77
|
+
commandRegistry.execute('session', ['resume', sessionId], commandContext).then(() => render());
|
|
78
|
+
|
|
79
|
+
if (!recovery) {
|
|
80
|
+
await plainResume();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const info = checkRecoveryForSession(surface, sessionId);
|
|
85
|
+
if (!info) {
|
|
86
|
+
await plainResume();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await new Promise<void>((resolve) => {
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
void (async () => {
|
|
93
|
+
try {
|
|
94
|
+
const deps = buildRecoveryOfferWiring({
|
|
95
|
+
surface,
|
|
96
|
+
sessionManager: recovery.sessionManager,
|
|
97
|
+
runtime: recovery.runtime,
|
|
98
|
+
conversation: recovery.conversation,
|
|
99
|
+
commandContext,
|
|
100
|
+
writeLastSessionPointer: recovery.writeLastSessionPointer,
|
|
101
|
+
receipt: recovery.receipt,
|
|
102
|
+
render,
|
|
103
|
+
});
|
|
104
|
+
const outcome = await offerRecoverySnapshot({ ...deps, targetSessionId: sessionId });
|
|
105
|
+
if (outcome !== 'resumed') {
|
|
106
|
+
await plainResume();
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// Best-effort by construction (mirrors offerRecoverySnapshot's own
|
|
110
|
+
// guarantee): a failure here must fall back to the plain resume
|
|
111
|
+
// rather than leave the boot with nothing resumed at all.
|
|
112
|
+
await plainResume().catch(() => {
|
|
113
|
+
// A failing plain resume here is no worse than the pre-existing
|
|
114
|
+
// behavior when session resume itself fails.
|
|
115
|
+
});
|
|
116
|
+
} finally {
|
|
117
|
+
resolve();
|
|
118
|
+
}
|
|
119
|
+
})();
|
|
120
|
+
}, 0);
|
|
121
|
+
timer.unref?.();
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
18
125
|
/**
|
|
19
126
|
* decodeFirstOpenChoice — pure mapping from a chosen selection-item id (or
|
|
20
127
|
* null on Escape/enter-through) to the trust decision. Kept pure so the
|
|
@@ -79,9 +186,24 @@ export function applyInitialTuiCliState(options: {
|
|
|
79
186
|
readonly commandRegistry: CommandRegistry;
|
|
80
187
|
readonly commandContext: CommandContext;
|
|
81
188
|
readonly shellPaths: TuiStartupShellPaths;
|
|
189
|
+
/**
|
|
190
|
+
* The app's declare-once session-storage handle — the SAME one the runtime
|
|
191
|
+
* writes the last-session pointer through. `--continue` and bare `--resume`
|
|
192
|
+
* read that pointer below; deriving its path independently here is exactly
|
|
193
|
+
* how this read used to land on a file nothing ever wrote.
|
|
194
|
+
*/
|
|
195
|
+
readonly surface: SessionSurface;
|
|
82
196
|
readonly render: () => void;
|
|
197
|
+
/**
|
|
198
|
+
* Enables the crash-recovery check ahead of `--continue` / bare `--resume`
|
|
199
|
+
* (see `resumeNamedSessionWithRecoveryCheck` above). Omitted, the two flags
|
|
200
|
+
* resume straight from the durable store exactly as before this check
|
|
201
|
+
* existed — main.ts always wires this; only tests that don't care about
|
|
202
|
+
* recovery snapshots leave it out.
|
|
203
|
+
*/
|
|
204
|
+
readonly continueRecovery?: TuiStartupRecoveryDeps;
|
|
83
205
|
}): Promise<void> | undefined {
|
|
84
|
-
const { cli, input, commandRegistry, commandContext, shellPaths, render } = options;
|
|
206
|
+
const { cli, input, commandRegistry, commandContext, shellPaths, surface, render, continueRecovery } = options;
|
|
85
207
|
const globalOnboardingMarker = readOnboardingCheckMarker(shellPaths, 'user');
|
|
86
208
|
|
|
87
209
|
// Registration self-records on every launch of an already-trusted
|
|
@@ -109,27 +231,21 @@ export function applyInitialTuiCliState(options: {
|
|
|
109
231
|
return commandRegistry.execute('session', ['resume', target], commandContext).then(() => render());
|
|
110
232
|
}
|
|
111
233
|
} else if (cli.flags.continueLast) {
|
|
112
|
-
// --continue: resume the last session tracked by the pointer file
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
surfaceRoot: 'tui',
|
|
117
|
-
});
|
|
234
|
+
// --continue: resume the last session tracked by the pointer file. Checked
|
|
235
|
+
// for a live crash-recovery snapshot first — see
|
|
236
|
+
// resumeNamedSessionWithRecoveryCheck's doc comment.
|
|
237
|
+
const lastId = readLastSessionPointer({ surface });
|
|
118
238
|
if (lastId) {
|
|
119
|
-
return
|
|
239
|
+
return resumeNamedSessionWithRecoveryCheck({ sessionId: lastId, surface, commandRegistry, commandContext, render, recovery: continueRecovery });
|
|
120
240
|
}
|
|
121
241
|
} else if (cli.flags.resume !== undefined) {
|
|
122
242
|
// --resume [id]: explicit id dispatches directly; bare form (sentinel 'latest') resolves via pointer
|
|
123
243
|
if (cli.flags.resume !== 'latest') {
|
|
124
244
|
return commandRegistry.execute('session', ['resume', cli.flags.resume], commandContext).then(() => render());
|
|
125
245
|
} else {
|
|
126
|
-
const lastId = readLastSessionPointer({
|
|
127
|
-
workingDirectory: shellPaths.workingDirectory,
|
|
128
|
-
homeDirectory: shellPaths.homeDirectory,
|
|
129
|
-
surfaceRoot: 'tui',
|
|
130
|
-
});
|
|
246
|
+
const lastId = readLastSessionPointer({ surface });
|
|
131
247
|
if (lastId) {
|
|
132
|
-
return
|
|
248
|
+
return resumeNamedSessionWithRecoveryCheck({ sessionId: lastId, surface, commandRegistry, commandContext, render, recovery: continueRecovery });
|
|
133
249
|
}
|
|
134
250
|
}
|
|
135
251
|
} else if (cli.flags.fork !== undefined) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* surface.ts — the TUI's single surface-root identifier.
|
|
3
|
+
*
|
|
4
|
+
* Every piece of the TUI's own on-disk state (sessions, the last-session
|
|
5
|
+
* pointer, recovery snapshots, checkpoints, the transcript journal, the
|
|
6
|
+
* multi-instance liveness markers) lives under `.goodvibes/tui/`. That scope
|
|
7
|
+
* segment used to be spelled as a bare `'tui'` string literal at each call
|
|
8
|
+
* site, which is how a writer and a reader ended up disagreeing about where
|
|
9
|
+
* the last-session pointer lives: one call passed the scope, another omitted
|
|
10
|
+
* it and silently resolved to the shared, unscoped `.goodvibes/` directory,
|
|
11
|
+
* so `--continue` looked at an empty file forever.
|
|
12
|
+
*
|
|
13
|
+
* The constant below is the one place that name is written for the session /
|
|
14
|
+
* recovery / checkpoint domain. It feeds `createSessionSurface` in
|
|
15
|
+
* runtime/services.ts, which resolves every path once at startup into the
|
|
16
|
+
* `SessionSurface` handle threaded through the rest of the runtime. Other
|
|
17
|
+
* subsystems (config storage, sandbox scaffolding, keybindings, and so on)
|
|
18
|
+
* still write their own `'tui'` surface-root segment at their own call
|
|
19
|
+
* sites; this constant is not (yet) the single source for all of them.
|
|
20
|
+
*/
|
|
21
|
+
export const GOODVIBES_TUI_SURFACE_ROOT = 'tui';
|
|
@@ -113,22 +113,30 @@ export interface UpdateSettings {
|
|
|
113
113
|
readonly autoUpdateAtLaunch?: boolean;
|
|
114
114
|
/** How long the launch-time version check may take before it is skipped. Defaults to 2500; clamped to [250, 30000]. */
|
|
115
115
|
readonly launchCheckTimeoutMs?: number;
|
|
116
|
+
/** How long the launch-time download+verify+swap may take before it is deferred to the next launch. Defaults to 45000; clamped to [5000, 300000]. */
|
|
117
|
+
readonly applyTimeoutMs?: number;
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
const LAUNCH_CHECK_MIN_TIMEOUT_MS = 250;
|
|
119
121
|
const LAUNCH_CHECK_MAX_TIMEOUT_MS = 30_000;
|
|
122
|
+
const LAUNCH_APPLY_MIN_TIMEOUT_MS = 5_000;
|
|
123
|
+
const LAUNCH_APPLY_MAX_TIMEOUT_MS = 300_000;
|
|
120
124
|
|
|
121
125
|
/** Read `update.*` from settings.json, validating and clamping the timeout. */
|
|
122
126
|
export function readUpdateSettings(configManager: Pick<ConfigManager, 'getRaw'>): UpdateSettings {
|
|
123
127
|
const src = readNamespace(configManager, 'update');
|
|
124
128
|
if (!src) return {};
|
|
125
|
-
const out: { autoUpdateAtLaunch?: boolean; launchCheckTimeoutMs?: number } = {};
|
|
129
|
+
const out: { autoUpdateAtLaunch?: boolean; launchCheckTimeoutMs?: number; applyTimeoutMs?: number } = {};
|
|
126
130
|
const autoUpdateAtLaunch = readBoolean(src, 'autoUpdateAtLaunch');
|
|
127
131
|
if (autoUpdateAtLaunch !== undefined) out.autoUpdateAtLaunch = autoUpdateAtLaunch;
|
|
128
132
|
const launchCheckTimeoutMs = readPositiveInt(src, 'launchCheckTimeoutMs');
|
|
129
133
|
if (launchCheckTimeoutMs !== undefined) {
|
|
130
134
|
out.launchCheckTimeoutMs = Math.min(LAUNCH_CHECK_MAX_TIMEOUT_MS, Math.max(LAUNCH_CHECK_MIN_TIMEOUT_MS, launchCheckTimeoutMs));
|
|
131
135
|
}
|
|
136
|
+
const applyTimeoutMs = readPositiveInt(src, 'applyTimeoutMs');
|
|
137
|
+
if (applyTimeoutMs !== undefined) {
|
|
138
|
+
out.applyTimeoutMs = Math.min(LAUNCH_APPLY_MAX_TIMEOUT_MS, Math.max(LAUNCH_APPLY_MIN_TIMEOUT_MS, applyTimeoutMs));
|
|
139
|
+
}
|
|
132
140
|
return out;
|
|
133
141
|
}
|
|
134
142
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bookmark-navigation.ts — fallback resolution for a bookmark whose direct
|
|
3
|
+
* BlockMeta lookup misses.
|
|
4
|
+
*
|
|
5
|
+
* A bookmark stores a block's collapseKey at the time it was set (see
|
|
6
|
+
* handler-content-actions.ts's handleBookmark). A tool-result message that is
|
|
7
|
+
* a non-owning member of a folded tool-result group (see
|
|
8
|
+
* conversation-tool-groups.ts) pushes no BlockMeta of its own while the group
|
|
9
|
+
* stays collapsed — its own collapseKey (`msg_<absoluteIdx>`) never appears
|
|
10
|
+
* in getBlockRegistry() until the group is expanded. A direct
|
|
11
|
+
* find(entry => entry.collapseKey === key) then reports "not found", even
|
|
12
|
+
* though the message is still present in the transcript, just folded under
|
|
13
|
+
* its group's header.
|
|
14
|
+
*
|
|
15
|
+
* Resolution: parse the message's absolute index out of the `msg_<idx>` key
|
|
16
|
+
* and resolve it through ConversationManager.getMessageLine(idx) instead of
|
|
17
|
+
* the block registry. That registry is keyed by absolute message index and,
|
|
18
|
+
* for a folded group member, already resolves to the group's own header line
|
|
19
|
+
* rather than the position of whatever renders next (see getMessageLine's
|
|
20
|
+
* doc and conversation-rendering.ts / conversation-line-cache.ts, which
|
|
21
|
+
* anchor a folded member's registered line at its group's header) — landing
|
|
22
|
+
* on the header is an honest, reachable result rather than a false
|
|
23
|
+
* "not found".
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { ConversationManager } from './conversation.ts';
|
|
27
|
+
|
|
28
|
+
const MESSAGE_BOOKMARK_KEY = /^msg_(\d+)$/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a bookmark key that missed a direct BlockMeta lookup. Returns the
|
|
32
|
+
* line to scroll to, or null when the key isn't a message-scoped bookmark
|
|
33
|
+
* (`msg_<idx>`) or that message index has never been rendered.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveFoldedBookmarkLine(
|
|
36
|
+
conversation: Pick<ConversationManager, 'getMessageLine'>,
|
|
37
|
+
key: string,
|
|
38
|
+
): number | null {
|
|
39
|
+
const match = MESSAGE_BOOKMARK_KEY.exec(key);
|
|
40
|
+
if (!match) return null;
|
|
41
|
+
const absoluteIdx = Number(match[1]);
|
|
42
|
+
const line = conversation.getMessageLine(absoluteIdx);
|
|
43
|
+
return line === undefined ? null : line;
|
|
44
|
+
}
|