@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
@@ -23,6 +23,7 @@ import type { FeatureFlagManager } from '@/runtime/index.ts';
23
23
  import type { OperatorClient } from '@/runtime/index.ts';
24
24
  import type { PeerClient } from '@/runtime/index.ts';
25
25
  import type { DirectTransport } from '@/runtime/index.ts';
26
+ import type { SessionSurface } from '@/runtime/index.ts';
26
27
  import type { VoiceProviderRegistry, VoiceService } from '@pellux/goodvibes-sdk/platform/voice';
27
28
  import type { UiPlatformServices } from '../runtime/ui-services.ts';
28
29
  import type {
@@ -80,6 +81,14 @@ export interface CommandUiActions {
80
81
  kind: 'image' | 'text' | 'none';
81
82
  marker?: string;
82
83
  };
84
+ /**
85
+ * Read-only view of pending `[TEXT: pN, M lines]` fold markers currently
86
+ * sitting in the composer (registerPaste in handler-content-actions.ts) —
87
+ * backs /pastes, which previews a folded paste's actual content before the
88
+ * user submits it. A paste over the fold threshold is otherwise invisible
89
+ * until the LLM sees it.
90
+ */
91
+ getPendingPastes?: () => ReadonlyMap<string, string>;
83
92
  executeCommand?: (name: string, args: string[]) => Promise<boolean>;
84
93
  /** Arm the one-key jump to a spawned CI fix-session (the surface attaches on the jump key; the user never retypes an id). */
85
94
  armFixSessionAttach?: (fixSessionId: string) => void;
@@ -152,6 +161,8 @@ export interface CommandShellUiOpeners {
152
161
  jumpToBookmark?: (key: string) => void;
153
162
  scrollToLine?: (line: number) => void;
154
163
  openHelpOverlay?: () => void;
164
+ /** Toggle the transcript search overlay (same action as Ctrl+F) — backs /find. */
165
+ openTranscriptSearch?: () => void;
155
166
  openSelection?: (
156
167
  title: string,
157
168
  items: SelectionItem[],
@@ -270,6 +281,14 @@ export interface CommandProviderServices {
270
281
  * runtime surfaces exported from runtime/shell-command-services.ts.
271
282
  */
272
283
  export interface CommandWorkspaceUiServices {
284
+ /**
285
+ * The app's declare-once session-storage handle (runtime/services.ts). Every
286
+ * command that reads or writes session state — resume, save, the liveness
287
+ * markers, the rewind anchor sidecar — takes its paths from here instead of
288
+ * re-deriving a scope from shellPaths, so a command can never land on a
289
+ * different directory than the runtime that wrote the file.
290
+ */
291
+ surface?: SessionSurface;
273
292
  keybindingsManager?: KeybindingsManager;
274
293
  fileUndoManager?: FileUndoManager;
275
294
  workspaceCheckpointManager?: WorkspaceCheckpointManager;
@@ -19,8 +19,8 @@ import type { CommandContext, CommandRegistry } from '../command-registry.ts';
19
19
  // Formatting helpers
20
20
  // ---------------------------------------------------------------------------
21
21
 
22
- /** Drop the redundant 'wcp_' prefix and cap length for compact listing. Full ids (and any unambiguous prefix of one) are still accepted by /rewind. */
23
- function shortId(id: string): string {
22
+ /** Drop the redundant 'wcp_' prefix and cap length for compact listing. Full ids (and any unambiguous prefix of one) are still accepted by /rewind. Exported: /rewind's no-anchors checkpoint fallback (rewind-runtime.ts) reuses this same formatting. */
23
+ export function shortId(id: string): string {
24
24
  const stripped = id.startsWith('wcp_') ? id.slice(4) : id;
25
25
  return stripped.length > 12 ? stripped.slice(0, 12) : stripped;
26
26
  }
@@ -77,7 +77,7 @@ export function registerCheckpointRuntimeCommands(registry: CommandRegistry): vo
77
77
  // them would cost one diff spawn per checkpoint (O(checkpoints)), which
78
78
  // stops being cheap once there are more than a handful. /rewind <id>
79
79
  // loads exactly one diff, so the exact file list is available there.
80
- ctx.print('Use /rewind to preview and restore a completed turn (files and/or conversation) — it reuses these checkpoints for the files half.');
80
+ ctx.print('Use /rewind to preview and restore a completed turn (files and/or conversation) — it reuses these checkpoints for the files half. With no completed turns recorded this run (e.g. right after a restart), /rewind falls back to restoring one of these checkpoints directly, by number or id — files only, no conversation state.');
81
81
  },
82
82
  });
83
83
 
@@ -1,4 +1,3 @@
1
- import { ServiceRegistry } from '@pellux/goodvibes-sdk/platform/config';
2
1
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
3
2
  import { probeTermCaps } from '../../renderer/term-caps.ts';
4
3
  import { evaluateSessionMaintenance, formatSessionMaintenanceLines } from '@/runtime/index.ts';
@@ -8,7 +7,6 @@ import { buildSetupReviewSnapshot } from './local-setup-review.ts';
8
7
  import { buildProviderAccountSnapshot } from '@/runtime/index.ts';
9
8
  import { getSettingsControlPlaneSnapshot } from '@/runtime/index.ts';
10
9
  import { listPersistedWorktreeMeta, summarizeWorktreeOwnership } from '@/runtime/index.ts';
11
- import { checkRecoveryFile, readLastSessionPointer } from '@/runtime/index.ts';
12
10
  import {
13
11
  openCommandPanel,
14
12
  requireLocalUserAuthManager,
@@ -425,7 +423,7 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
425
423
  } else if (domain === 'continuity') {
426
424
  lines.push(' domain: continuity');
427
425
  lines.push(' /session list');
428
- lines.push(' /session resume <id>');
426
+ lines.push(' /resume <id>');
429
427
  lines.push(' /session hotspots');
430
428
  lines.push(' verify: /health continuity');
431
429
  } else if (domain === 'maintenance') {
@@ -34,14 +34,31 @@ function toggleBlocks(typeFilter: string, collapsed: boolean, ctx: CommandContex
34
34
  let count = 0;
35
35
  for (let i = 0; i < blockRegistry.length; i++) {
36
36
  const block = blockRegistry[i];
37
+ // A folded tool-result group (type 'tool_group', see conversation-tool-groups.ts)
38
+ // is a run of tool results under one header, so '/expand tool'/'collapse tool'
39
+ // treats it the same as a plain 'tool' block — toggling the header's collapse
40
+ // key expands/collapses every member underneath it.
37
41
  const matchesType = typeFilter === 'all'
38
- || (typeFilter === 'tool' && block.type === 'tool')
42
+ || (typeFilter === 'tool' && (block.type === 'tool' || block.type === 'tool_group'))
39
43
  || (typeFilter === 'code' && block.type === 'code')
40
44
  || (typeFilter === 'thinking' && block.type === 'thinking');
41
45
  if (!matchesType) continue;
42
46
  const isCurrentlyCollapsed = ctx.session.conversationManager.isCollapsed(i);
43
47
  if (collapsed ? !isCurrentlyCollapsed : isCurrentlyCollapsed) {
44
48
  ctx.session.conversationManager.toggleCollapseAtLine(block.startLine);
49
+ // Expanding a folded tool-result group also expands every member's own
50
+ // collapse key in the same pass. A folded member pushes no BlockMeta of
51
+ // its own while the group is collapsed, so it never surfaces from this
52
+ // loop to be toggled individually — without this, '/expand tool' only
53
+ // opens the group header and each member still renders at whatever its
54
+ // own default collapse state is, needing a second '/expand tool' pass.
55
+ // '/collapse tool' needs no matching step: collapsing the group hides
56
+ // every member regardless of its own key.
57
+ if (!collapsed && block.type === 'tool_group' && block.groupMemberIndexes) {
58
+ for (const memberIdx of block.groupMemberIndexes) {
59
+ ctx.session.conversationManager.setCollapsed(`msg_${memberIdx}`, false);
60
+ }
61
+ }
45
62
  count++;
46
63
  }
47
64
  }
@@ -148,7 +165,7 @@ export function registerLocalRuntimeCommands(registry: CommandRegistry): void {
148
165
 
149
166
  registry.register({
150
167
  name: 'secrets',
151
- description: 'Manage hierarchy-aware secrets, external secret refs, and secure/plaintext storage policy controls',
168
+ description: 'Persistent secret storage: hierarchy-aware, external secret refs, providers (a quick one-off masked value instead: /secret)',
152
169
  usage: 'set <KEY> <value> [--user|--project] [--secure|--plaintext] | link <KEY> <secret-ref> [--user|--project] [--secure|--plaintext] | get <KEY> | test <secret-ref> | providers | list | delete <KEY> [--user|--project] [--secure|--plaintext]',
153
170
  argsHint: '<set|link|get|test|providers|list|delete> [KEY]',
154
171
  async handler(args, ctx) {
@@ -39,12 +39,13 @@ import {
39
39
  type RewindWorkspacePort,
40
40
  } from '@pellux/goodvibes-sdk/platform/rewind';
41
41
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
42
- import type { WorkspaceCheckpointManager } from '@pellux/goodvibes-sdk/platform/workspace';
42
+ import type { WorkspaceCheckpoint, WorkspaceCheckpointManager } from '@pellux/goodvibes-sdk/platform/workspace';
43
43
  import type { CommandContext, CommandRegistry } from '../command-registry.ts';
44
44
  import { buildRewindReceiptBlock } from '../../core/rewind-receipt.ts';
45
45
  import { getTurnAnchors, type TurnAnchor } from '../../core/rewind-turn-anchors.ts';
46
46
  import { createConversationRewindPort, type ConversationRewindPort } from '../../runtime/conversation-rewind-port.ts';
47
47
  import { requirePanelManager } from './runtime-services.ts';
48
+ import { shortId } from './checkpoint-runtime.ts';
48
49
 
49
50
  export type RewindScope = SdkRewindScope;
50
51
  const REWIND_SCOPES: readonly RewindScope[] = ['files', 'conversation', 'both'];
@@ -231,6 +232,127 @@ function parseScope(token: string | undefined): RewindScope | null {
231
232
  return (REWIND_SCOPES as readonly string[]).includes(lower) ? (lower as RewindScope) : null;
232
233
  }
233
234
 
235
+ // ---------------------------------------------------------------------------
236
+ // Checkpoint-only fallback — no completed turns recorded THIS run (e.g. right
237
+ // after a restart) means the message-anchored picker above has nothing to
238
+ // list, even though real workspace checkpoints exist on disk. Rather than
239
+ // dead-ending on "no completed turns" while /checkpoints and the boot notice
240
+ // both point back here, /rewind falls back to listing and restoring
241
+ // checkpoints directly — a FILES-ONLY restore (there is no turn anchor to
242
+ // join a conversation boundary against), reusing the same diff-panel
243
+ // preview/confirm idiom as the turn-anchored path above.
244
+ // ---------------------------------------------------------------------------
245
+
246
+ const CHECKPOINT_FALLBACK_NO_ANCHORS_MESSAGE = 'No completed turns recorded this run yet. /rewind targets turns completed in the current session run.';
247
+
248
+ function renderCheckpointFallbackList(checkpoints: readonly WorkspaceCheckpoint[]): string {
249
+ const lines = [
250
+ 'No completed turns recorded this run yet — falling back to workspace checkpoints (FILES ONLY, no conversation state):',
251
+ ...checkpoints.slice(0, 20).map((cp, i) => ` ${String(i + 1).padStart(2)}. ${shortId(cp.id).padEnd(12)} ${cp.kind.padEnd(9)} ${cp.label} (${formatAge(cp.createdAt)})`),
252
+ 'Restore one: /rewind <n|id> — files only; conversation history is untouched.',
253
+ ];
254
+ return lines.join('\n');
255
+ }
256
+
257
+ /** Resolve a user ref ('1'..'N' newest-first, or a checkpoint id / id prefix) to a checkpoint. */
258
+ function resolveCheckpointRef(checkpoints: readonly WorkspaceCheckpoint[], ref: string): WorkspaceCheckpoint | { error: string } {
259
+ if (/^\d+$/.test(ref)) {
260
+ const idx = Number(ref) - 1;
261
+ const cp = checkpoints[idx];
262
+ return cp ? cp : { error: `No checkpoint #${ref}. Run /rewind to list checkpoints (1–${checkpoints.length}).` };
263
+ }
264
+ const exact = checkpoints.find((cp) => cp.id === ref);
265
+ if (exact) return exact;
266
+ const prefix = checkpoints.filter((cp) => cp.id.startsWith(ref) || shortId(cp.id).startsWith(ref));
267
+ if (prefix.length === 1) return prefix[0]!;
268
+ if (prefix.length > 1) return { error: `Checkpoint ref "${ref}" is ambiguous (${prefix.length} matches). Use the list number instead.` };
269
+ return { error: `Unknown checkpoint ref "${ref}". Run /rewind to list checkpoints.` };
270
+ }
271
+
272
+ /**
273
+ * Handles bare `/rewind` and `/rewind <ref>` when this run has recorded no
274
+ * turn anchors at all. Returns true if it handled the invocation (always,
275
+ * once called), false is never returned — kept boolean-shaped only to mirror
276
+ * the calling convention at the call site.
277
+ */
278
+ async function handleCheckpointOnlyRewind(args: string[], ctx: CommandContext): Promise<void> {
279
+ const mgr = ctx.workspace.workspaceCheckpointManager;
280
+ if (!mgr) {
281
+ ctx.print(CHECKPOINT_FALLBACK_NO_ANCHORS_MESSAGE);
282
+ return;
283
+ }
284
+ let checkpoints: WorkspaceCheckpoint[];
285
+ try {
286
+ checkpoints = await mgr.list();
287
+ } catch {
288
+ checkpoints = [];
289
+ }
290
+ if (checkpoints.length === 0) {
291
+ ctx.print(CHECKPOINT_FALLBACK_NO_ANCHORS_MESSAGE);
292
+ return;
293
+ }
294
+
295
+ if (args.length === 0) {
296
+ ctx.print(renderCheckpointFallbackList(checkpoints));
297
+ return;
298
+ }
299
+
300
+ const resolved = resolveCheckpointRef(checkpoints, args[0]!);
301
+ if ('error' in resolved) {
302
+ ctx.print(resolved.error);
303
+ return;
304
+ }
305
+ const checkpoint = resolved;
306
+
307
+ const { DiffPanel } = await import('../../panels/diff-panel.ts');
308
+ const pm = requirePanelManager(ctx);
309
+ let panel = pm.getAllOpen().find((p) => p.id === 'diff');
310
+ if (!panel) {
311
+ try { panel = pm.open('diff'); } catch { ctx.print('Could not open diff panel.'); return; }
312
+ }
313
+ pm.activateById('diff');
314
+ if (!pm.isVisible()) pm.show();
315
+ ctx.focusPanels?.();
316
+ const diffPanel = panel as InstanceType<typeof DiffPanel>;
317
+
318
+ try {
319
+ const diff = await mgr.diff(checkpoint.id);
320
+ if (diff.unifiedDiff.trim()) diffPanel.loadRawDiff(diff.unifiedDiff);
321
+ else diffPanel.showDiff('(no file changes)', '@@ -0,0 +0,0 @@\n Working tree already matches this checkpoint.');
322
+ } catch {
323
+ diffPanel.showDiff('(preview unavailable)', '@@ -0,0 +0,0 @@\n Could not load the checkpoint diff.');
324
+ }
325
+
326
+ diffPanel.confirmOverlay.arm({
327
+ id: checkpoint.id,
328
+ label: `${checkpoint.label} — restore FILES ONLY (no conversation state this run)`,
329
+ verb: 'Restore',
330
+ onConfirm: async () => {
331
+ try {
332
+ await mgr.restore(checkpoint.id, { safetyCheckpoint: true });
333
+ ctx.session.conversationManager.addTypedSystemMessage(
334
+ `[Rewind] Restored files from checkpoint ${shortId(checkpoint.id)} ("${checkpoint.label}"). Files only — no turn anchors exist this run, so conversation state is unchanged.`,
335
+ 'operational',
336
+ );
337
+ pm.close('diff');
338
+ ctx.focusPrompt?.();
339
+ ctx.renderRequest();
340
+ } catch (err) {
341
+ ctx.print(`Checkpoint restore failed: ${summarizeError(err)}`);
342
+ }
343
+ },
344
+ onCancel: () => {
345
+ ctx.print('Restore cancelled — nothing changed.');
346
+ pm.close('diff');
347
+ ctx.focusPrompt?.();
348
+ ctx.renderRequest();
349
+ },
350
+ });
351
+
352
+ ctx.print(`Previewing checkpoint restore: "${checkpoint.label}" (FILES ONLY — no conversation state this run). Confirm in the diff panel: Enter/y to restore, n/Esc to cancel.`);
353
+ ctx.renderRequest();
354
+ }
355
+
234
356
  // ---------------------------------------------------------------------------
235
357
  // Command registration
236
358
  // ---------------------------------------------------------------------------
@@ -244,6 +366,15 @@ export function registerRewindRuntimeCommands(registry: CommandRegistry): void {
244
366
  handler: async (args, ctx: CommandContext) => {
245
367
  const sessionId = ctx.session.runtime.sessionId;
246
368
 
369
+ // No turn anchors recorded this run (most commonly: right after a
370
+ // restart, before /session resume has restored any) — fall back to a
371
+ // files-only restore over the real workspace checkpoints instead of a
372
+ // dead end (see handleCheckpointOnlyRewind's header doc).
373
+ if (recentTurnsNewestFirst(sessionId).length === 0) {
374
+ await handleCheckpointOnlyRewind(args, ctx);
375
+ return;
376
+ }
377
+
247
378
  if (args.length === 0) {
248
379
  ctx.print(renderTurnList(sessionId));
249
380
  return;
@@ -12,7 +12,7 @@ import type { CompactionContext, CompactionEvent } from '@pellux/goodvibes-sdk/p
12
12
  import { recordCompactionQualityScore, scoreCompactionRun } from '../../renderer/compaction-quality.ts';
13
13
  import type { CompactionQualityScore } from '../../renderer/compaction-quality.ts';
14
14
  import type { UiReadModels } from '../../runtime/ui-read-models.ts';
15
- import type { ShellPathService } from '@/runtime/index.ts';
15
+ import type { ShellPathService, SessionSurface } from '@/runtime/index.ts';
16
16
  import type { EcosystemCatalogPathOptions } from '@/runtime/index.ts';
17
17
  import type { PluginPathOptions } from '../../plugins/loader';
18
18
  import type { DirectTransport } from '@/runtime/index.ts';
@@ -72,6 +72,16 @@ export function requireShellPaths(context: Pick<CommandContext, 'workspace'>): S
72
72
  return requireContextValue(context.workspace.shellPaths, 'workspace.shellPaths');
73
73
  }
74
74
 
75
+ /**
76
+ * The runtime's declare-once session-storage handle. Commands that touch
77
+ * session state take their paths from here rather than rebuilding a scope out
78
+ * of shellPaths, so a command and the runtime that wrote the file always agree
79
+ * on where it lives.
80
+ */
81
+ export function requireSurface(context: Pick<CommandContext, 'workspace'>): SessionSurface {
82
+ return requireContextValue(context.workspace.surface, 'workspace.surface');
83
+ }
84
+
75
85
  export function requireEcosystemCatalogPaths(
76
86
  context: CommandContext,
77
87
  ): EcosystemCatalogPathOptions {
@@ -21,7 +21,7 @@ function toEnvVarName(raw: string): string {
21
21
  export function registerSecretRuntimeCommands(registry: CommandRegistry): void {
22
22
  registry.register({
23
23
  name: 'secret',
24
- description: 'Enter a secret with concealed (masked) input and store it as an environment variable — plaintext never enters the transcript',
24
+ description: 'Quick one-off: type a value with masked input, stored as an env var for THIS session only (persistent storage + providers: /secrets)',
25
25
  usage: '/secret <NAME>',
26
26
  argsHint: '<NAME>',
27
27
  handler(args, ctx) {
@@ -9,6 +9,7 @@ import { requireSessionManager, requireSessionMemoryStore, requireShellPaths } f
9
9
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
10
10
  import { sessionCommand } from './session.ts';
11
11
  import { undoLastRewind, redoLastRewind } from './rewind-runtime.ts';
12
+ import { filterUserFacingSessions } from './session-picker-filter.ts';
12
13
 
13
14
  export function registerSessionContentCommands(registry: CommandRegistry): void {
14
15
  registry.register({
@@ -113,6 +114,10 @@ export function registerSessionContentCommands(registry: CommandRegistry): void
113
114
  model: ctx.session.runtime.model,
114
115
  provider: ctx.session.runtime.provider,
115
116
  timestamp: Date.now(),
117
+ // `/save` is the user asking to keep this conversation. Stamped so
118
+ // the session-conversations retention sweep never expires it, unlike
119
+ // the automatic per-turn snapshot (turn-event-wiring.ts, 'auto').
120
+ saveSource: 'user' as const,
116
121
  };
117
122
  try {
118
123
  const agentManager = ctx.ops.agentManager;
@@ -263,7 +268,10 @@ export function registerSessionContentCommands(registry: CommandRegistry): void
263
268
  return;
264
269
  }
265
270
  const sessionManager = requireSessionManager(ctx);
266
- const sessions = sessionManager.list();
271
+ // Excludes subagent-shaped transcripts (see session-picker-filter.ts) —
272
+ // an unfiltered list here drowns real user sessions in spawned-agent
273
+ // noise. A direct `/sessions resume agent-xxxxxxxx` still reaches them.
274
+ const sessions = filterUserFacingSessions(sessionManager.list());
267
275
  if (ctx.openSelection) {
268
276
  const deleteAction = new Map([['d', 'delete' as const]]);
269
277
  const items: SelectionItem[] = sessions.length === 0
@@ -0,0 +1,28 @@
1
+ /**
2
+ * session-picker-filter.ts — keep subagent transcripts out of user-facing
3
+ * session pickers.
4
+ *
5
+ * Subagents spawned during a session get their own SessionManager-persisted
6
+ * transcript, named `agent-<id>` (see the SDK's agent manager, which mints
7
+ * ids as `agent-${uuid.slice(0, 8)}`). Those files are real, loadable
8
+ * sessions — useful for forensic/debugging access — but they are not
9
+ * sessions a user ever started, and in a workspace with many spawned agents
10
+ * they drown the handful of sessions the user actually wants to pick from
11
+ * in `/resume` and `/sessions`.
12
+ *
13
+ * Both pickers filter agent-shaped entries out of the LIST/BROWSE view only.
14
+ * Direct resume by exact id/name (`/resume agent-xxxxxxxx`, `/session resume
15
+ * agent-xxxxxxxx`) is untouched and still reaches them — this only affects
16
+ * what shows up when the id isn't already known.
17
+ */
18
+ import type { SessionInfo } from '@pellux/goodvibes-sdk/platform/sessions';
19
+
20
+ /** Whether a saved session's name follows the subagent-transcript naming convention. */
21
+ export function isAgentSessionName(name: string): boolean {
22
+ return name.startsWith('agent-');
23
+ }
24
+
25
+ /** Filter a session list down to user-facing (non-subagent) entries. */
26
+ export function filterUserFacingSessions<T extends Pick<SessionInfo, 'name'>>(sessions: readonly T[]): T[] {
27
+ return sessions.filter((s) => !isAgentSessionName(s.name));
28
+ }
@@ -6,9 +6,9 @@ import type { TranscriptEventKind } from '@pellux/goodvibes-sdk/platform/core';
6
6
  import type { ConversationTitleSource } from '../../core/conversation';
7
7
  import type { SessionReturnContextSummary } from '@/runtime/index.ts';
8
8
  import { formatReturnContextForDisplay, getReturnContextMode, maybeAssistReturnContextSummary } from '@/runtime/index.ts';
9
- import { requirePanelManager, requireProviderApi, requireSessionManager, requireShellPaths } from './runtime-services.ts';
10
- import { replayJournalForSession } from '../../core/session-recovery.ts';
11
- import { restoreTurnAnchors } from '../../core/rewind-turn-anchors.ts';
9
+ import { requirePanelManager, requireProviderApi, requireSessionManager, requireSurface } from './runtime-services.ts';
10
+ import { resumeSessionCore, reopenPanelsWithModalSkip, DEFAULT_PANEL_REOPEN_LIMIT } from '../../core/session-resume-core.ts';
11
+ import { checkSessionLiveness } from '../../runtime/session-liveness-marker.ts';
12
12
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
13
13
 
14
14
  function parseTranscriptKind(raw: string | undefined): TranscriptEventKind | 'all' {
@@ -83,32 +83,20 @@ function buildTranscriptReviewLines(
83
83
  // Exported (test-only concern) so the MIGRATE-TO-MODAL redirect-skip honesty
84
84
  // (W6 review, finding 3: saved-layout restore with 'sessions' must not lie)
85
85
  // can be unit-tested directly instead of through a full /resume harness.
86
+ // Delegates to the shared reopenPanelsWithModalSkip (core/session-resume-core.ts)
87
+ // — the same routine resumeSessionCore uses — so this standalone entry point
88
+ // and the canonical resume sequence can never diverge on panel-reopen behavior.
86
89
  export function reopenPanelsFromReturnContext(ctx: CommandContext, summary: SessionReturnContextSummary | undefined): string[] {
87
90
  if (!summary?.openPanels || summary.openPanels.length === 0) return [];
88
91
  const panelManager = requirePanelManager(ctx);
89
- const reopened: string[] = [];
90
- const movedToModal: string[] = [];
91
- for (const panelId of summary.openPanels.slice(0, 4)) {
92
- // (the purge): a MIGRATE-TO-MODAL id has no panel to restore — a modal
93
- // is not part of the saved panel layout. Skip it (don't pop a modal
94
- // mid-resume) and note it once, rather than firing openModal + revealing an
95
- // empty workspace during resume.
96
- if (panelManager.getModalRedirect(panelId) !== undefined) {
97
- movedToModal.push(panelId);
98
- continue;
99
- }
100
- try {
101
- panelManager.open(panelId);
102
- reopened.push(panelId);
103
- } catch {
104
- // Ignore unknown or currently unavailable panel ids during resume.
105
- }
106
- }
107
- if (reopened.length > 0) panelManager.show();
92
+ const { reopened, movedToModal, notReopened } = reopenPanelsWithModalSkip(panelManager, summary.openPanels, DEFAULT_PANEL_REOPEN_LIMIT);
108
93
  if (movedToModal.length > 0) {
109
94
  ctx.print(`Note: ${movedToModal.join(', ')} moved to a modal — reopen via its command instead of as a panel.`);
110
95
  }
111
- return reopened;
96
+ if (notReopened.length > 0) {
97
+ ctx.print(` …and ${notReopened.length} more not reopened (/panels to open)`);
98
+ }
99
+ return [...reopened];
112
100
  }
113
101
 
114
102
  function printSessionExport(
@@ -198,7 +186,9 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
198
186
  session.returnContext.activeTasks ? `active=${session.returnContext.activeTasks}` : null,
199
187
  session.returnContext.blockedTasks ? `blocked=${session.returnContext.blockedTasks}` : null,
200
188
  session.returnContext.pendingApprovals ? `approvals=${session.returnContext.pendingApprovals}` : null,
201
- session.returnContext.openPanels?.length ? `panels=${session.returnContext.openPanels.slice(0, 3).join(',')}` : null,
189
+ session.returnContext.openPanels?.length
190
+ ? `panels=${session.returnContext.openPanels.slice(0, 3).join(',')}${session.returnContext.openPanels.length > 3 ? ` (+${session.returnContext.openPanels.length - 3} more)` : ''}`
191
+ : null,
202
192
  ].filter(Boolean).join(' ');
203
193
  if (posture) lines.push(` posture: ${posture}`);
204
194
  }
@@ -222,6 +212,10 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
222
212
  model: ctx.session.runtime.model,
223
213
  provider: ctx.session.runtime.provider,
224
214
  timestamp: Date.now(),
215
+ // Naming a session is an act of curation, so the file this
216
+ // materializes for rename to act on is a user save, not machinery —
217
+ // the retention sweep must not expire something the user named.
218
+ saveSource: 'user',
225
219
  });
226
220
  }
227
221
  sm.rename(ctx.session.runtime.sessionId, newName);
@@ -235,9 +229,13 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
235
229
  }
236
230
 
237
231
  if (sub === 'resume') {
238
- const target = args.slice(1).join(' ').trim();
232
+ // '--force' bypasses the multi-instance liveness confirm below — never a
233
+ // session-id/name token itself (session ids never start with '--').
234
+ const rawArgs = args.slice(1);
235
+ const force = rawArgs.includes('--force');
236
+ const target = rawArgs.filter((a) => a !== '--force').join(' ').trim();
239
237
  if (!target) {
240
- ctx.print('Usage: /session resume <session-id-or-name>');
238
+ ctx.print('Usage: /session resume <session-id-or-name> [--force]');
241
239
  return true;
242
240
  }
243
241
  const sessions = sm.list();
@@ -250,56 +248,36 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
250
248
  ctx.print(`Session not found: ${target}\nUse /session list to see available sessions.`);
251
249
  return true;
252
250
  }
253
- try {
254
- const { meta, messages } = sm.load(found.name);
255
- const providerApi = requireProviderApi(ctx);
256
- ctx.session.conversationManager.resetAll();
257
- ctx.session.conversationManager.fromJSON({ messages: messages as never[], title: meta.title, titleSource: meta.titleSource });
258
- ctx.session.conversationManager.rebuildHistory();
259
- ctx.session.runtime.sessionId = found.name;
260
251
 
261
- // Restore the resumed session's message-anchored rewind anchors from its
262
- // sidecar so /rewind works identically before and after the resume. The
263
- // in-memory registry is process-local, so a fresh process would otherwise
264
- // start with no anchors for the loaded session.
265
- const restoredAnchorCount = restoreTurnAnchors(found.name, requireShellPaths(ctx).workingDirectory);
252
+ // Multi-instance safety: warn (and require --force) when this session
253
+ // looks open in another still-running terminal right now — resuming it
254
+ // here would fork its live state out from under that other instance.
255
+ // Best-effort: a missing/stale marker (or this process's OWN marker,
256
+ // e.g. re-resuming the session already active here) never blocks anything.
257
+ const liveness = checkSessionLiveness(requireSurface(ctx), found.name);
258
+ if (liveness.live && liveness.pid !== process.pid && !force) {
259
+ ctx.print(
260
+ `Session appears open in another terminal (pid ${liveness.pid}) — resuming will fork its live state.\n` +
261
+ `Re-run \`/session resume ${found.name} --force\` to proceed anyway.`,
262
+ );
263
+ return true;
264
+ }
266
265
 
267
- // Journal replay: recover turns that post-date the loaded snapshot.
268
- const shellPaths = requireShellPaths(ctx);
269
- const journalReplay = replayJournalForSession({
270
- homeDirectory: shellPaths.homeDirectory,
271
- snapshotTimestamp: meta.timestamp,
266
+ try {
267
+ const providerApi = requireProviderApi(ctx);
268
+ const outcome = await resumeSessionCore(found.name, {
269
+ sessionManager: sm,
272
270
  conversation: ctx.session.conversationManager,
273
- sessionId: found.name,
274
- persistSnapshot: (replayedMessages) => {
275
- sm.save(found.name, replayedMessages as never[], {
276
- title: ctx.session.conversationManager.title || meta.title,
277
- model: meta.model,
278
- provider: meta.provider,
279
- timestamp: Date.now(),
280
- titleSource: meta.titleSource,
281
- returnContext: meta.returnContext,
282
- });
283
- },
271
+ runtime: ctx.session.runtime,
272
+ surface: requireSurface(ctx),
273
+ panelManager: requirePanelManager(ctx),
274
+ selectModel: (model) => providerApi.selectModel(model),
275
+ hydrateSessionUsage: ctx.session.hydrateSessionUsage,
284
276
  });
285
- // Hydrate the footer's token counters from the resumed (+ journal-replayed)
286
- // history now, before ctx.renderRequest() below.
287
- ctx.session.hydrateSessionUsage?.();
277
+ const { meta, journalReplay, restoredAnchorCount, resumedMessageCount, panels } = outcome;
288
278
 
289
- if (meta.model) {
290
- try {
291
- const selected = await providerApi.selectModel(meta.model);
292
- ctx.session.runtime.model = selected.registryKey;
293
- ctx.session.runtime.provider = selected.providerId;
294
- } catch {
295
- ctx.session.runtime.model = meta.model;
296
- // model may not exist locally
297
- }
298
- }
299
- if (meta.provider) ctx.session.runtime.provider = meta.provider;
300
279
  ctx.renderRequest();
301
- const resumedMsgCount = ctx.session.conversationManager.getMessageCount();
302
- ctx.print(`Resumed session: ${found.name}\n Name: ${meta.title || '(untitled)'}\n Messages: ${resumedMsgCount}\n Model: ${meta.model || ctx.session.runtime.model}`);
280
+ ctx.print(`Resumed session: ${found.name}\n Name: ${meta.title || '(untitled)'}\n Messages: ${resumedMessageCount}\n Model: ${meta.model || ctx.session.runtime.model}`);
303
281
  if (restoredAnchorCount > 0) {
304
282
  ctx.print(` Rewind: restored ${restoredAnchorCount} turn anchor(s) — /rewind is available for this resumed session.`);
305
283
  }
@@ -311,14 +289,19 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
311
289
  } else if (journalReplay.hadCorruptTail) {
312
290
  ctx.print(' [Recovery] Journal tail was partially corrupt (quarantined). Replay stopped at last good record.');
313
291
  }
314
- const reopenedPanels = reopenPanelsFromReturnContext(ctx, meta.returnContext);
292
+ if (panels.movedToModal.length > 0) {
293
+ ctx.print(`Note: ${panels.movedToModal.join(', ')} moved to a modal — reopen via its command instead of as a panel.`);
294
+ }
295
+ if (panels.notReopened.length > 0) {
296
+ ctx.print(` …and ${panels.notReopened.length} more not reopened (/panels to open)`);
297
+ }
315
298
  const returnContextMode = getReturnContextMode(ctx.platform.configManager);
316
299
  if (returnContextMode !== 'off' && meta.returnContext) {
317
300
  for (const line of formatReturnContextForDisplay(meta.returnContext)) {
318
301
  ctx.print(` ${line}`);
319
302
  }
320
- if (reopenedPanels.length > 0) {
321
- ctx.print(` Reopened panels: ${reopenedPanels.join(', ')}`);
303
+ if (panels.reopened.length > 0) {
304
+ ctx.print(` Reopened panels: ${panels.reopened.join(', ')}`);
322
305
  }
323
306
  if ((meta.returnContext.remoteRunners?.length ?? 0) > 0) {
324
307
  ctx.print(` Remote re-entry: /remote recover ${meta.returnContext.remoteRunners![0]}`);
@@ -354,6 +337,8 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
354
337
  timestamp: Date.now(),
355
338
  titleSource: exportData.titleSource,
356
339
  returnContext: exportData.returnContext,
340
+ // The operator asked for this fork by name; it is not turn machinery.
341
+ saveSource: 'user',
357
342
  };
358
343
  try {
359
344
  sm.save(newId, messages, meta);
@@ -378,6 +363,10 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
378
363
  timestamp: Date.now(),
379
364
  titleSource: exportData.titleSource,
380
365
  returnContext: exportData.returnContext,
366
+ // The one unambiguous "keep this" act in the app. Marked so the
367
+ // session-conversations retention sweep never expires it, unlike the
368
+ // automatic per-turn snapshot (turn-event-wiring.ts, saveSource 'auto').
369
+ saveSource: 'user',
381
370
  };
382
371
  try {
383
372
  const { filePath, sanitizedName } = sm.save(rawName, messages, meta);