@pellux/goodvibes-tui 1.11.1 → 1.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "1.11.1",
3
+ "version": "1.13.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",
@@ -99,7 +99,7 @@
99
99
  "@anthropic-ai/vertex-sdk": "^0.16.0",
100
100
  "@ast-grep/napi": "^0.42.0",
101
101
  "@aws/bedrock-token-generator": "^1.1.0",
102
- "@pellux/goodvibes-sdk": "1.4.1",
102
+ "@pellux/goodvibes-sdk": "1.6.1",
103
103
  "bash-language-server": "^5.6.0",
104
104
  "fuse.js": "^7.1.0",
105
105
  "graphql": "^16.13.2",
@@ -0,0 +1,97 @@
1
+ /**
2
+ * /context window — view, set, or clear a custom context window for the
3
+ * current model.
4
+ *
5
+ * The override is stored by the SDK's ProviderRegistry (persisted under the
6
+ * control-plane config dir with provenance 'configured_cap'), so it survives
7
+ * restarts, applies to any model (cloud or local), and is honored by every
8
+ * consumer of the same home. Clearing returns the model to its automatic
9
+ * window (catalog / provider API / family fallback).
10
+ */
11
+ import type { CommandContext } from '../command-registry.ts';
12
+ import type { ModelDefinition } from '@pellux/goodvibes-sdk/platform/providers';
13
+ import { MAX_CONTEXT_WINDOW_OVERRIDE } from '@pellux/goodvibes-sdk/platform/providers';
14
+
15
+ /**
16
+ * Parse a user-supplied context window size. Accepts plain token counts
17
+ * ("200000"), thousands ("200k", "12.5k"), and millions ("1m", "2M").
18
+ * Returns null for anything unparseable or outside 1..MAX_CONTEXT_WINDOW_OVERRIDE.
19
+ */
20
+ export function parseContextWindowSize(raw: string): number | null {
21
+ const match = /^(\d+(?:\.\d+)?)\s*([km])?$/i.exec(raw.trim());
22
+ if (!match) return null;
23
+ const base = Number(match[1]);
24
+ const suffix = match[2]?.toLowerCase();
25
+ const multiplier = suffix === 'm' ? 1_000_000 : suffix === 'k' ? 1_000 : 1;
26
+ const value = Math.round(base * multiplier);
27
+ if (!Number.isInteger(value) || value <= 0 || value > MAX_CONTEXT_WINDOW_OVERRIDE) return null;
28
+ return value;
29
+ }
30
+
31
+ function describeProvenance(model: ModelDefinition): string {
32
+ switch (model.contextWindowProvenance) {
33
+ case 'configured_cap': return 'custom override';
34
+ case 'observed_limit': return 'learned from a provider rejection';
35
+ case 'provider_api': return 'reported by the provider';
36
+ case 'fallback': return 'family default (no catalog entry)';
37
+ default: return 'model catalog';
38
+ }
39
+ }
40
+
41
+ /** Status text for the current model's window + override state. */
42
+ export function buildContextWindowStatusText(
43
+ model: ModelDefinition,
44
+ resolvedWindow: number,
45
+ override: number | null,
46
+ observed: number | null = null,
47
+ ): string {
48
+ const lines = [
49
+ `Context window for ${model.displayName} (${model.registryKey}):`,
50
+ ` resolved: ${resolvedWindow.toLocaleString()} tokens (${describeProvenance(model)})`,
51
+ ` override: ${override === null ? 'none (automatic)' : `${override.toLocaleString()} tokens`}`,
52
+ ];
53
+ if (observed !== null) {
54
+ lines.push(` learned limit: ${observed.toLocaleString()} tokens (the provider rejected a longer request; self-corrects as requests succeed)`);
55
+ }
56
+ lines.push(
57
+ '',
58
+ 'Set: /context window <size> (e.g. 120000, 200k, 1m)',
59
+ 'Clear: /context window clear (also forgets the learned limit)',
60
+ );
61
+ return lines.join('\n');
62
+ }
63
+
64
+ /** Handle `/context window [<size>|clear]`. Returns the text it printed (for tests). */
65
+ export function handleContextWindowSubcommand(args: readonly string[], ctx: CommandContext): string {
66
+ const registry = ctx.provider.providerRegistry;
67
+ const model = registry.getCurrentModel();
68
+ const arg = args[0]?.trim().toLowerCase() ?? '';
69
+
70
+ let output: string;
71
+ if (arg === '') {
72
+ output = buildContextWindowStatusText(
73
+ model,
74
+ registry.getContextWindowForModel(model),
75
+ registry.getModelContextCap(model.registryKey),
76
+ registry.getObservedContextWindow(model.registryKey),
77
+ );
78
+ } else if (arg === 'clear' || arg === 'auto' || arg === 'reset') {
79
+ const existed = registry.clearModelContextCap(model.registryKey);
80
+ const resolved = registry.getContextWindowForModel(registry.getCurrentModel());
81
+ output = existed
82
+ ? `Context window settings cleared for ${model.displayName} (custom override and any learned limit). Back to automatic: ${resolved.toLocaleString()} tokens.`
83
+ : `${model.displayName} has no custom context window or learned limit set (automatic: ${resolved.toLocaleString()} tokens).`;
84
+ } else {
85
+ const size = parseContextWindowSize(arg);
86
+ if (size === null) {
87
+ output = `Invalid size '${args[0]}'. Use a token count between 1 and ${MAX_CONTEXT_WINDOW_OVERRIDE.toLocaleString()} — e.g. 120000, 200k, 1m — or 'clear'.`;
88
+ } else {
89
+ registry.setModelContextCap(model.registryKey, size);
90
+ output = `Context window for ${model.displayName} set to ${size.toLocaleString()} tokens (was ${registry.getContextWindowForModel(model).toLocaleString()}). Clear with /context window clear.`;
91
+ }
92
+ }
93
+
94
+ ctx.print(output);
95
+ ctx.renderRequest();
96
+ return output;
97
+ }
@@ -6,6 +6,7 @@ import { registerOperatorPanelCommand } from './operator-panel-runtime.ts';
6
6
  import { requireOpsApi, requireProfileManager, requireReplayEngine } from './runtime-services.ts';
7
7
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
8
8
  import { estimateConversationTokens } from '@pellux/goodvibes-sdk/platform/core';
9
+ import { handleContextWindowSubcommand } from './context-window.ts';
9
10
 
10
11
  export function registerOperatorRuntimeCommands(registry: CommandRegistry): void {
11
12
  registerOperatorPanelCommand(registry);
@@ -23,8 +24,14 @@ export function registerOperatorRuntimeCommands(registry: CommandRegistry): void
23
24
  registry.register({
24
25
  name: 'context',
25
26
  aliases: ['ctx'],
26
- description: 'Inspect context window usage (token breakdown per message)',
27
- handler: (_args, ctx) => {
27
+ description: 'Inspect context usage, or set/clear a custom context window for the current model',
28
+ usage: '[window [<size>|clear]]',
29
+ argsHint: '[window <size|clear>]',
30
+ handler: (args, ctx) => {
31
+ if (args[0]?.toLowerCase() === 'window') {
32
+ handleContextWindowSubcommand(args.slice(1), ctx);
33
+ return;
34
+ }
28
35
  if (ctx.openContextInspector) {
29
36
  ctx.openContextInspector();
30
37
  } else {
@@ -21,7 +21,7 @@ import type { SlashCommand, CommandContext } from '../command-registry.ts';
21
21
  import type { CancellationScope, CrossSessionTaskRef } from '@pellux/goodvibes-sdk/platform/sessions';
22
22
  import { VALID_SCOPES } from '@pellux/goodvibes-sdk/platform/sessions';
23
23
  import { handleSessionWorkflowCommand } from './session-workflow.ts';
24
- import { requireSessionOrchestration } from './runtime-services.ts';
24
+ import { requireSessionOrchestration, requireSessionManager } from './runtime-services.ts';
25
25
 
26
26
  // ── Argument parsing helpers ──────────────────────────────────────────────────
27
27
 
@@ -423,3 +423,48 @@ export const sessionCommand: SlashCommand = {
423
423
  }
424
424
  },
425
425
  };
426
+
427
+ /**
428
+ * /resume — the discoverable front door to session resume.
429
+ *
430
+ * `/session resume <id>` has always existed but is buried behind a
431
+ * subcommand nobody remembers mid-context-switch. `/resume` with no
432
+ * arguments opens a picker over saved sessions (newest first, current
433
+ * session excluded); with an argument it resumes directly via the exact
434
+ * same workflow path (journal replay, model reselection, return-context
435
+ * reveal included).
436
+ */
437
+ export const resumeCommand: SlashCommand = {
438
+ name: 'resume',
439
+ aliases: [],
440
+ description: 'Resume a previous session — pick from a list, or pass an id/name',
441
+ usage: '[session-id-or-name]',
442
+ argsHint: '[id|name]',
443
+ handler: async (args: string[], ctx: CommandContext): Promise<void> => {
444
+ if (args.length > 0 && args.join(' ').trim().length > 0) {
445
+ await handleSessionWorkflowCommand(['resume', ...args], ctx);
446
+ return;
447
+ }
448
+ const sm = requireSessionManager(ctx);
449
+ const sessions = sm.list().filter((s) => s.name !== ctx.session.runtime.sessionId);
450
+ if (sessions.length === 0) {
451
+ ctx.print('No previous sessions to resume yet. /session save [name] stores the current one explicitly.');
452
+ return;
453
+ }
454
+ if (!ctx.openSelection) {
455
+ // Headless/test surface without the picker: honest fallback to the list.
456
+ await handleSessionWorkflowCommand(['list'], ctx);
457
+ ctx.print('Resume one with: /resume <id-or-name>');
458
+ return;
459
+ }
460
+ const items = sessions.map((s) => ({
461
+ id: s.name,
462
+ label: s.title || s.name,
463
+ detail: `${new Date(s.timestamp).toLocaleString()} · ${s.messageCount} msg${s.messageCount === 1 ? '' : 's'}${s.model ? ` · ${s.model}` : ''} · ${s.name}`,
464
+ }));
465
+ ctx.openSelection('Resume session', items, { allowSearch: true, primaryVerbLabel: 'Resume' }, (result) => {
466
+ if (!result) return;
467
+ void handleSessionWorkflowCommand(['resume', result.item.id], ctx);
468
+ });
469
+ },
470
+ };
@@ -120,6 +120,7 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
120
120
  { id: '/model', label: '/model [id]', detail: 'Select LLM model', category: 'Model & Provider' },
121
121
  { id: '/provider', label: '/provider [name]', detail: 'Switch provider', category: 'Model & Provider' },
122
122
  { id: '/effort', label: '/effort [level]', detail: 'Reasoning effort (instant/low/medium/high)', category: 'Model & Provider' },
123
+ { id: '/context window', label: '/context window [<size>|clear]', detail: 'Show, set, or clear a custom context window for the current model', category: 'Model & Provider' },
123
124
  { id: '/config', label: '/config [category|key]', detail: 'Open fullscreen configuration workspace', category: 'Config & Display' },
124
125
  { id: '/debug', label: '/debug', detail: 'Toggle debug mode', category: 'Config & Display' },
125
126
  { id: '/expand', label: '/expand [type]', detail: 'Expand blocks (all|thinking|tool|code)', category: 'Config & Display' },
@@ -136,6 +137,7 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
136
137
  { id: '/session', label: '/session', detail: 'Current session info', category: 'Conversation' },
137
138
  { id: '/session list', label: '/session list', detail: 'List all sessions', category: 'Conversation' },
138
139
  { id: '/session rename', label: '/session rename <name>', detail: 'Rename current session', category: 'Conversation' },
140
+ { id: '/resume', label: '/resume [id]', detail: 'Resume a previous session (picker when no id)', category: 'Conversation' },
139
141
  { id: '/session resume', label: '/session resume <id>', detail: 'Load and resume a session', category: 'Conversation' },
140
142
  { id: '/session fork', label: '/session fork [name]', detail: 'Fork current session to new ID', category: 'Conversation' },
141
143
  { id: '/session save', label: '/session save [name]', detail: 'Force-save current session', category: 'Conversation' },
@@ -2,7 +2,7 @@ import type { CommandRegistry } from './command-registry.ts';
2
2
  import { policyCommand } from './commands/policy.ts';
3
3
  import { providerCommand } from './commands/provider.ts';
4
4
  import { evalCommand } from './commands/eval.ts';
5
- import { sessionCommand } from './commands/session.ts';
5
+ import { sessionCommand, resumeCommand } from './commands/session.ts';
6
6
  import { recallCommand } from './commands/memory.ts';
7
7
  import { knowledgeCommand } from './commands/knowledge.ts';
8
8
  import { registerShellCoreCommands } from './commands/shell-core.ts';
@@ -140,6 +140,9 @@ export function registerBuiltinCommands(registry: CommandRegistry): void {
140
140
  // ── /session ─────────────────────────────────────────────────────────────
141
141
  registry.register(sessionCommand);
142
142
 
143
+ // ── /resume — the discoverable front door to /session resume ─────────────
144
+ registry.register(resumeCommand);
145
+
143
146
  // ── /recall ──────────────────────────────────────────────────────────────
144
147
  registry.register(recallCommand);
145
148
 
package/src/main.ts CHANGED
@@ -643,11 +643,11 @@ async function main() {
643
643
  panelWidth: panelComposite.panelWidth,
644
644
  });
645
645
  };
646
- const renderScheduler = createRenderScheduler(renderNow); // WO-208 same-tick coalescer
646
+ const renderScheduler = createRenderScheduler(renderNow, undefined, () => lifecycle.isTerminalRestored()); // WO-208 coalescer; no frames after terminal restore
647
647
  const render = (): void => renderScheduler.schedule(); // captured direct writes → activity log + quiet /debug counter, not repeated transcript lines (UX-B 1a)
648
648
  const terminalOutputGuard = installTuiTerminalOutputGuard({ stdout, stderr: process.stderr, onCapture: (total) => { commandContext.session.runtime.terminalWritesIntercepted = total; render(); } });
649
649
 
650
- setRenderRequest(renderNow); // bootstrap's 16ms coalescer calls the composite directly
650
+ setRenderRequest(() => renderScheduler.flushNow()); // bootstrap's 16ms coalescer composites via the (restore-gated) scheduler
651
651
  setPanelFrameRequester(render); // live panels repaint when idle (a replay finding: fleet sat stale until keypress)
652
652
  orchestratorRefs.requestRender = render;
653
653
  commandContext.renderRequest = render;
@@ -96,6 +96,8 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
96
96
  private readonly actions: FleetActionCallbacks;
97
97
  private readonly confirmOverlay: PanelConfirmOverlay;
98
98
  private follow = false;
99
+ /** Tree view mode: the live fleet, or the session archive of finished subtrees. */
100
+ private viewMode: 'active' | 'archived' = 'active';
99
101
  private tabsState: FleetTabsState = EMPTY_FLEET_TABS_STATE;
100
102
  private tickTimer: ReturnType<typeof setInterval> | null = null;
101
103
  /**
@@ -221,7 +223,9 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
221
223
  }
222
224
 
223
225
  protected getItems(): readonly FleetTreeRow[] {
224
- return this.readModel.getSnapshot().rows;
226
+ return this.viewMode === 'archived'
227
+ ? this.readModel.getArchivedSnapshot().rows
228
+ : this.readModel.getSnapshot().rows;
225
229
  }
226
230
 
227
231
  protected override getPalette(): PanelPalette {
@@ -229,7 +233,9 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
229
233
  }
230
234
 
231
235
  protected override getEmptyStateMessage(): string {
232
- return ' No processes tracked yet.';
236
+ return this.viewMode === 'archived'
237
+ ? ' Archive is empty — press a on a finished process (or A for all) to move it here.'
238
+ : ' No processes tracked yet.';
233
239
  }
234
240
 
235
241
  /**
@@ -381,6 +387,38 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
381
387
 
382
388
  const selected = this.getSelectedItem();
383
389
 
390
+ // Archive controls: v toggles the live fleet <-> session archive view;
391
+ // a archives the selected finished subtree (or restores it from the
392
+ // archive view); A archives every fully-finished root subtree.
393
+ if (key === 'v') {
394
+ this.viewMode = this.viewMode === 'active' ? 'archived' : 'active';
395
+ this.markDirty();
396
+ return true;
397
+ }
398
+ if (key === 'a') {
399
+ if (!selected) return false;
400
+ if (this.viewMode === 'archived') {
401
+ const restored = this.readModel.unarchive(selected.node.id);
402
+ if (restored === 0) this.setError('Nothing restored for this node.');
403
+ this.markDirty();
404
+ return true;
405
+ }
406
+ if (!isTerminalProcessState(selected.node.state)) {
407
+ this.setError('Only finished processes can be archived.');
408
+ return true;
409
+ }
410
+ const result = this.readModel.archive(selected.node.id);
411
+ if (!result.archived) this.setError(result.reason ?? 'Could not archive this process.');
412
+ this.markDirty();
413
+ return true;
414
+ }
415
+ if (key === 'A' && this.viewMode === 'active') {
416
+ const count = this.readModel.archiveFinished();
417
+ if (count === 0) this.setError('No fully-finished processes to archive.');
418
+ this.markDirty();
419
+ return true;
420
+ }
421
+
384
422
  if (key === 'i') {
385
423
  // Guard: only consume on a real, non-terminal node (cockpit-panel.ts
386
424
  // precedent) so the key falls through when there is nothing to act on.
@@ -736,7 +774,7 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
736
774
  // can actually accept them (most kinds are never interruptible; nothing is
737
775
  // killable once terminal), incl. the d2 state-dependent p pause/resume chip.
738
776
  // Mirrors the handleInput guards above. See buildFleetTreeHints (fleet-stop.ts).
739
- const hints = buildFleetTreeHints(selected?.node, this.follow, this.tabsState.tabs.length > 0);
777
+ const hints = buildFleetTreeHints(selected?.node, this.follow, this.tabsState.tabs.length > 0, this.viewMode);
740
778
 
741
779
  // Tab strip renders only when tabs exist — omitting it entirely with no
742
780
  // tabs attached keeps the pre-session-tab root-tree rendering byte-identical.
@@ -744,7 +782,7 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
744
782
  const header = stripLine ? [stripLine] : undefined;
745
783
 
746
784
  return this.renderList(width, height, {
747
- title: 'Fleet',
785
+ title: this.viewMode === 'archived' ? 'Fleet — Archived' : 'Fleet',
748
786
  header,
749
787
  footer,
750
788
  hints,
@@ -29,6 +29,7 @@
29
29
  // ---------------------------------------------------------------------------
30
30
 
31
31
  import type {
32
+ ArchivableProcessRegistry,
32
33
  ProcessCostState,
33
34
  ProcessKind,
34
35
  ProcessNode,
@@ -459,10 +460,23 @@ export interface FleetReadModel {
459
460
  * the steer()/steerable degrade-without-a-dep convention.
460
461
  */
461
462
  subscribeConsumed(listener: (event: SteerConsumedEvent) => void): () => void;
463
+ /** Rows snapshot of ARCHIVED subtrees (empty when the runtime has no archive layer). */
464
+ getArchivedSnapshot(): FleetSnapshot;
465
+ /** Move a finished subtree to the session archive. Honest refusal reason when it cannot be. */
466
+ archive(id: string): { readonly archived: boolean; readonly count: number; readonly reason?: string };
467
+ /** Archive every fully-finished root subtree. Returns the number of nodes archived. */
468
+ archiveFinished(): number;
469
+ /** Return an archived subtree to the live view. Returns the number of nodes restored. */
470
+ unarchive(id: string): number;
462
471
  }
463
472
 
464
- /** Narrow surface of ProcessRegistry this read-model depends on. */
465
- export type FleetRegistryLike = Pick<ProcessRegistry, 'query' | 'subscribe' | 'interrupt' | 'resume' | 'kill' | 'steer'>;
473
+ /**
474
+ * Narrow surface of ProcessRegistry this read-model depends on. The archive
475
+ * methods are optional: a runtime whose registry lacks the archive layer
476
+ * (or a bare test double) degrades to "no archive" instead of crashing.
477
+ */
478
+ export type FleetRegistryLike = Pick<ProcessRegistry, 'query' | 'subscribe' | 'interrupt' | 'resume' | 'kill' | 'steer'>
479
+ & Partial<Pick<ArchivableProcessRegistry, 'archive' | 'archiveFinished' | 'unarchive' | 'listArchived'>>;
466
480
 
467
481
  /**
468
482
  * Create a live FleetReadModel backed by the SDK's ProcessRegistry.
@@ -503,6 +517,21 @@ export function createFleetReadModel(
503
517
  }
504
518
  });
505
519
  },
520
+ getArchivedSnapshot(): FleetSnapshot {
521
+ if (!registry.listArchived) return buildFleetSnapshot([]);
522
+ const snapshot = registry.listArchived();
523
+ return buildFleetSnapshot(snapshot.nodes, snapshot.capturedAt);
524
+ },
525
+ archive(id: string) {
526
+ if (!registry.archive) return { archived: false, count: 0, reason: 'this runtime has no fleet archive' };
527
+ return registry.archive(id);
528
+ },
529
+ archiveFinished(): number {
530
+ return registry.archiveFinished?.() ?? 0;
531
+ },
532
+ unarchive(id: string): number {
533
+ return registry.unarchive?.(id) ?? 0;
534
+ },
506
535
  };
507
536
  }
508
537
 
@@ -516,5 +545,9 @@ export function createStaticFleetReadModel(snapshot: FleetSnapshot): FleetReadMo
516
545
  kill: () => [],
517
546
  steer: () => ({ queued: false, reason: 'no live registry' }),
518
547
  subscribeConsumed: () => () => {},
548
+ getArchivedSnapshot: () => buildFleetSnapshot([]),
549
+ archive: () => ({ archived: false, count: 0, reason: 'no live registry' }),
550
+ archiveFinished: () => 0,
551
+ unarchive: () => 0,
519
552
  };
520
553
  }
@@ -108,6 +108,7 @@ export function buildFleetTreeHints(
108
108
  selected: ProcessNode | undefined,
109
109
  follow: boolean,
110
110
  hasTabs: boolean,
111
+ viewMode: 'active' | 'archived' = 'active',
111
112
  ): FleetHint[] {
112
113
  const live = selected !== undefined && !isTerminalProcessState(selected.state);
113
114
  const isPaused = selected !== undefined && selected.state === 'paused';
@@ -115,11 +116,22 @@ export function buildFleetTreeHints(
115
116
  { keys: 'j/k', label: 'navigate' },
116
117
  { keys: 'Enter', label: 'attach' },
117
118
  ];
119
+ if (viewMode === 'archived') {
120
+ // Archive view: restore + return to the live fleet; nothing here is live,
121
+ // so the live-only control hints (s/i/K/p) never apply.
122
+ if (selected) hints.push({ keys: 'a', label: 'restore' });
123
+ hints.push({ keys: 'v', label: 'live view' });
124
+ if (hasTabs) hints.push({ keys: '[ ]', label: 'tabs' });
125
+ return hints;
126
+ }
118
127
  if (live && selected.capabilities.steerable) hints.push({ keys: 's', label: 'steer' }); // discoverable from the tree (attach-and-steer)
119
128
  if (live && selected.capabilities.interruptible) hints.push({ keys: 'i', label: 'interrupt' });
120
129
  if (live && selected.capabilities.killable) hints.push({ keys: 'K', label: 'kill' });
121
130
  if (live && !isPaused && selected.capabilities.pausable) hints.push({ keys: 'p', label: 'pause' });
122
131
  if (isPaused && selected.capabilities.resumable) hints.push({ keys: 'p', label: 'resume' });
132
+ if (selected && !live) hints.push({ keys: 'a', label: 'archive' });
133
+ hints.push({ keys: 'A', label: 'archive finished' });
134
+ hints.push({ keys: 'v', label: 'archived' });
123
135
  hints.push({ keys: 'f', label: follow ? 'follow:on' : 'follow' });
124
136
  if (hasTabs) hints.push({ keys: '[ ]', label: 'tabs' });
125
137
  return hints;
@@ -63,6 +63,13 @@ export interface ProcessLifecycleDeps {
63
63
  export interface ProcessLifecycleHandlers {
64
64
  readonly exitApp: () => Promise<void>;
65
65
  readonly restoreTerminal: () => void;
66
+ /**
67
+ * True once restoreTerminal has run. The compositor must never write another
68
+ * frame after this: the terminal is back on the user's primary screen, and a
69
+ * late cursor-positioned frame (async shutdown races, stray timers) would
70
+ * paint over shell content and strand the prompt mid-screen.
71
+ */
72
+ readonly isTerminalRestored: () => boolean;
66
73
  readonly resizeHandler: () => void;
67
74
  readonly sigintHandler: () => void;
68
75
  readonly unhandledRejectionHandler: (reason: unknown) => void;
@@ -129,8 +136,20 @@ export function installProcessLifecycle(deps: ProcessLifecycleDeps): ProcessLife
129
136
  const restoreTerminal = (): void => {
130
137
  if (terminalRestored) return;
131
138
  terminalRestored = true;
132
- const exitScreen = noAltScreen ? ansi.CLEAR_SCREEN : ansi.CLEAR_SCREEN + ansi.ALT_SCREEN_EXIT;
133
- allowTerminalWrite(() => stdout.write(ansi.PASTE_DISABLE + ansi.KEYBOARD_EXT_DISABLE + ansi.MOUSE_DISABLE + ansi.FOCUS_DISABLE + ansi.CURSOR_SHOW + exitScreen));
139
+ // Alt-screen path: just leave the alt screen 1049l restores the primary
140
+ // screen and cursor exactly as they were at launch. Clearing first is
141
+ // pointless (the alt screen is discarded) and actively harmful: the old
142
+ // CLEAR_SCREEN included ESC[3J, which wipes the PRIMARY scrollback on
143
+ // several emulators even when issued from the alt screen.
144
+ // No-alt path: the compositor painted over the primary screen, so clear
145
+ // the viewport and home the cursor — but WITHOUT 3J, the user's scrollback
146
+ // is theirs. CURSOR_SHOW goes AFTER the screen switch so visibility
147
+ // applies to the screen the shell prompt lands on.
148
+ const exitScreen = noAltScreen ? '\x1b[2J\x1b[H' : ansi.ALT_SCREEN_EXIT;
149
+ allowTerminalWrite(() => stdout.write(
150
+ ansi.PASTE_DISABLE + ansi.KEYBOARD_EXT_DISABLE + ansi.MOUSE_DISABLE + ansi.FOCUS_DISABLE
151
+ + exitScreen + ansi.CURSOR_SHOW,
152
+ ));
134
153
  getTerminalOutputGuard().dispose();
135
154
  try { stdin.setRawMode(false); } catch { /* stdin may not be a TTY */ }
136
155
  };
@@ -201,6 +220,7 @@ export function installProcessLifecycle(deps: ProcessLifecycleDeps): ProcessLife
201
220
  return {
202
221
  exitApp,
203
222
  restoreTerminal,
223
+ isTerminalRestored: () => terminalRestored,
204
224
  resizeHandler,
205
225
  sigintHandler,
206
226
  unhandledRejectionHandler,
@@ -52,6 +52,14 @@ export interface RenderScheduler {
52
52
  export function createRenderScheduler(
53
53
  renderNow: () => void,
54
54
  scheduleFlush: (flush: () => void) => void = queueMicrotask,
55
+ /**
56
+ * When provided and returning true, every composite path becomes a no-op.
57
+ * Wired to process-lifecycle's isTerminalRestored(): once the exit teardown
58
+ * has handed the terminal back to the shell, a late frame (async shutdown
59
+ * races, stray timers) would paint cursor-positioned content over the
60
+ * user's primary screen and strand the next prompt mid-screen.
61
+ */
62
+ isReleased: () => boolean = () => false,
55
63
  ): RenderScheduler {
56
64
  let scheduled = false;
57
65
 
@@ -61,6 +69,7 @@ export function createRenderScheduler(
61
69
  // tick never composites twice.
62
70
  if (!scheduled) return;
63
71
  scheduled = false;
72
+ if (isReleased()) return;
64
73
  renderNow();
65
74
  };
66
75
 
@@ -73,6 +82,7 @@ export function createRenderScheduler(
73
82
  const flushNow = (): void => {
74
83
  // Satisfy any pending coalesced flush, then composite synchronously.
75
84
  scheduled = false;
85
+ if (isReleased()) return;
76
86
  renderNow();
77
87
  };
78
88
 
@@ -56,7 +56,7 @@ import { isFeatureFlagEnabled } from './surface-feature-flags.ts';
56
56
  import type { FeatureFlagManager } from '@/runtime/index.ts';
57
57
  import { createFeatureFlagManager } from '@/runtime/index.ts';
58
58
  import { PolicyRuntimeState } from '@/runtime/index.ts';
59
- import { createProcessRegistry, type ProcessRegistry } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
59
+ import { createProcessRegistry, withFleetArchive, type ArchivableProcessRegistry } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
60
60
  import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
61
61
  import { createWorkstreamServices, type OrchestrationEngine, type WorkstreamCommandService } from './workstream-services.ts';
62
62
  import { codeIndexDbPath, createCodeIndexServices, isCodeInjectionSettingEnabled } from './code-index-services.ts';
@@ -231,8 +231,8 @@ export interface RuntimeServices {
231
231
  /** The repo source-tree code index — see runtime/code-index-services.ts. */
232
232
  readonly codeIndexStore: CodeIndexStore;
233
233
  readonly codeIndexReindexScheduler: CodeIndexReindexScheduler; // tool-site reindex
234
- /** W2.1/W2.2: unified live process registry (agents, WRFC chains, workflows, watchers, background processes) backing the Fleet panel. */
235
- readonly processRegistry: ProcessRegistry;
234
+ /** Unified live process registry (agents, WRFC chains, workflows, watchers, background processes) backing the Fleet panel; archive-aware — finished subtrees can be moved to the session archive view. */
235
+ readonly processRegistry: ArchivableProcessRegistry;
236
236
  readonly modeManager: ModeManager;
237
237
  readonly fileUndoManager: FileUndoManager;
238
238
  readonly workspaceCheckpointManager: WorkspaceCheckpointManager;
@@ -609,7 +609,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
609
609
  // the Fleet panel (panels/fleet-read-model.ts) is its first consumer.
610
610
  // Constructed once here (not per-consumer) so the coalesced tick and the
611
611
  // agent-activity side-table are shared, not duplicated.
612
- const processRegistry = createProcessRegistry({
612
+ const processRegistry = withFleetArchive(createProcessRegistry({
613
613
  agentManager,
614
614
  wrfcController,
615
615
  orchestrationEngine, // Folds workstream/phase/work-item nodes into the fleet
@@ -628,7 +628,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
628
628
  if (!isModelPriced(modelId)) return null;
629
629
  return calcSessionCost(usage.inputTokens, usage.outputTokens, usage.cacheReadTokens, usage.cacheWriteTokens, modelId);
630
630
  },
631
- });
631
+ }));
632
632
  const modeManager = new ModeManager();
633
633
  const fileUndoManager = new FileUndoManager();
634
634
  const workspaceCheckpointManager = new WorkspaceCheckpointManager({
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '1.11.1';
9
+ let _version = '1.13.0';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;