@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +143 -1613
  4. package/docs/README.md +4 -4
  5. package/docs/foundation-artifacts/operator-contract.json +1 -1
  6. package/package.json +7 -7
  7. package/src/cli/ensure-goodvibes-gitignore.ts +16 -4
  8. package/src/cli/entrypoint.ts +5 -1
  9. package/src/cli/launch-auto-update.ts +63 -4
  10. package/src/cli/tui-startup.ts +131 -15
  11. package/src/config/surface.ts +21 -0
  12. package/src/config/tui-extension-settings.ts +9 -1
  13. package/src/core/bookmark-navigation.ts +44 -0
  14. package/src/core/conversation-line-cache.ts +87 -19
  15. package/src/core/conversation-rendering.ts +186 -28
  16. package/src/core/conversation-tool-groups.ts +152 -0
  17. package/src/core/conversation-types.ts +30 -3
  18. package/src/core/conversation.ts +17 -0
  19. package/src/core/rewind-turn-anchors.ts +12 -13
  20. package/src/core/session-recovery.ts +62 -7
  21. package/src/core/session-resume-core.ts +206 -0
  22. package/src/core/transcript-journal.ts +32 -6
  23. package/src/core/turn-event-wiring.ts +55 -12
  24. package/src/input/command-args-hint.ts +11 -2
  25. package/src/input/command-registry.ts +19 -0
  26. package/src/input/commands/checkpoint-runtime.ts +3 -3
  27. package/src/input/commands/health-runtime.ts +1 -3
  28. package/src/input/commands/local-runtime.ts +19 -2
  29. package/src/input/commands/rewind-runtime.ts +132 -1
  30. package/src/input/commands/runtime-services.ts +11 -1
  31. package/src/input/commands/secret-runtime.ts +1 -1
  32. package/src/input/commands/session-content.ts +9 -1
  33. package/src/input/commands/session-picker-filter.ts +28 -0
  34. package/src/input/commands/session-workflow.ts +62 -73
  35. package/src/input/commands/session.ts +36 -1
  36. package/src/input/commands/shell-core.ts +57 -5
  37. package/src/input/commands/update-runtime.ts +120 -4
  38. package/src/input/commands/websearch-runtime.ts +1 -1
  39. package/src/input/handler-content-actions.ts +63 -39
  40. package/src/input/handler-feed-routes.ts +17 -3
  41. package/src/input/handler-feed.ts +3 -0
  42. package/src/input/handler-interactions.ts +19 -23
  43. package/src/input/handler-modal-token-routes.ts +5 -0
  44. package/src/input/handler-types.ts +4 -1
  45. package/src/input/handler-ui-state.ts +6 -2
  46. package/src/input/handler.ts +19 -9
  47. package/src/input/search.ts +94 -2
  48. package/src/input/selection-modal-queue.ts +176 -0
  49. package/src/main.ts +55 -56
  50. package/src/panels/builtin/operations.ts +2 -1
  51. package/src/panels/provider-health-domains.ts +2 -2
  52. package/src/renderer/block-actions-overlay.ts +59 -0
  53. package/src/renderer/block-actions.ts +1 -3
  54. package/src/renderer/context-status-hint.ts +73 -5
  55. package/src/renderer/conversation-layout.ts +16 -0
  56. package/src/renderer/conversation-overlays.ts +6 -0
  57. package/src/renderer/help-overlay.ts +69 -53
  58. package/src/renderer/selection-modal-overlay.ts +66 -10
  59. package/src/renderer/shell-surface.ts +15 -0
  60. package/src/renderer/tool-result-expanded-lines.ts +48 -0
  61. package/src/renderer/ui-factory.ts +1 -1
  62. package/src/runtime/bootstrap-command-context.ts +5 -1
  63. package/src/runtime/bootstrap-command-parts.ts +5 -2
  64. package/src/runtime/bootstrap-core.ts +3 -3
  65. package/src/runtime/bootstrap-hook-bridge.ts +62 -44
  66. package/src/runtime/bootstrap-shell.ts +31 -16
  67. package/src/runtime/bootstrap.ts +13 -18
  68. package/src/runtime/code-index-services.ts +19 -0
  69. package/src/runtime/durability-services.ts +11 -7
  70. package/src/runtime/index.ts +12 -1
  71. package/src/runtime/process-lifecycle.ts +63 -2
  72. package/src/runtime/recovery-autosave.ts +48 -0
  73. package/src/runtime/recovery-offer-wiring.ts +89 -0
  74. package/src/runtime/recovery-prompt.ts +312 -0
  75. package/src/runtime/resume-notice.ts +26 -21
  76. package/src/runtime/services.ts +14 -16
  77. package/src/runtime/session-liveness-marker.ts +120 -0
  78. package/src/runtime/session-pointer-surface.ts +22 -0
  79. package/src/runtime/session-resume-liveness-confirm.ts +84 -0
  80. package/src/runtime/session-storage-services.ts +41 -0
  81. package/src/runtime/workspace-checkpointing.ts +10 -2
  82. package/src/shell/recovery-input-helpers.ts +17 -98
  83. package/src/shell/retry-affordance.ts +123 -0
  84. package/src/shell/ui-openers.ts +9 -0
  85. package/src/utils/splash-lines.ts +1 -1
  86. 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 settings namespaces you add by hand to settings.json: checkpoint root guard, the scriptable statusline, session behavior, and launch-time self-update.
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, and control-plane entrypoints.
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/workflow surfaces that tie them together.
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
- Host prerequisites, generated QEMU setup bundle files, Debian cloud-image bootstrapping, guest runtime installs, validation, and troubleshooting.
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.
@@ -3,7 +3,7 @@
3
3
  "product": {
4
4
  "id": "goodvibes",
5
5
  "surface": "operator",
6
- "version": "1.11.4"
6
+ "version": "1.12.0"
7
7
  },
8
8
  "auth": {
9
9
  "modes": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "1.19.5",
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.11.4",
108
- "@pellux/goodvibes-terminal-shell": "1.11.4",
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.19.5",
135
- "@pellux/goodvibes-tui-linux-arm64": "1.19.5",
136
- "@pellux/goodvibes-tui-darwin-x64": "1.19.5",
137
- "@pellux/goodvibes-tui-darwin-arm64": "1.19.5"
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
- export function ensureGoodvibesGitignore(projectRoot: string): void {
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
  }
@@ -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
- ensureGoodvibesGitignore(bootstrapWorkingDir);
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 { applyUpdate, checkForUpdate, type ApplyUpdateOptions } from '../input/commands/update-runtime.ts';
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: 'just-updated' | 'disabled' | 'not-swappable-install' | 'already-current' | 'check-skipped' | 'update-failed';
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
- options.print('update check skipped: offline');
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
- await apply({
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) {
@@ -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
- const lastId = readLastSessionPointer({
114
- workingDirectory: shellPaths.workingDirectory,
115
- homeDirectory: shellPaths.homeDirectory,
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 commandRegistry.execute('session', ['resume', lastId], commandContext).then(() => render());
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 commandRegistry.execute('session', ['resume', lastId], commandContext).then(() => render());
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
+ }