@pellux/goodvibes-tui 1.12.0 → 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/CHANGELOG.md +7 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +1225 -870
- package/package.json +2 -2
- package/src/input/commands/context-window.ts +12 -4
- package/src/input/commands/session.ts +46 -1
- package/src/input/commands/shell-core.ts +1 -0
- package/src/input/commands.ts +4 -1
- package/src/main.ts +2 -2
- package/src/panels/fleet-panel.ts +42 -4
- package/src/panels/fleet-read-model.ts +35 -2
- package/src/panels/fleet-stop.ts +12 -0
- package/src/runtime/process-lifecycle.ts +22 -2
- package/src/runtime/render-scheduler.ts +10 -0
- package/src/runtime/services.ts +5 -5
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-tui",
|
|
3
|
-
"version": "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.
|
|
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",
|
|
@@ -31,6 +31,7 @@ export function parseContextWindowSize(raw: string): number | null {
|
|
|
31
31
|
function describeProvenance(model: ModelDefinition): string {
|
|
32
32
|
switch (model.contextWindowProvenance) {
|
|
33
33
|
case 'configured_cap': return 'custom override';
|
|
34
|
+
case 'observed_limit': return 'learned from a provider rejection';
|
|
34
35
|
case 'provider_api': return 'reported by the provider';
|
|
35
36
|
case 'fallback': return 'family default (no catalog entry)';
|
|
36
37
|
default: return 'model catalog';
|
|
@@ -42,15 +43,21 @@ export function buildContextWindowStatusText(
|
|
|
42
43
|
model: ModelDefinition,
|
|
43
44
|
resolvedWindow: number,
|
|
44
45
|
override: number | null,
|
|
46
|
+
observed: number | null = null,
|
|
45
47
|
): string {
|
|
46
48
|
const lines = [
|
|
47
49
|
`Context window for ${model.displayName} (${model.registryKey}):`,
|
|
48
50
|
` resolved: ${resolvedWindow.toLocaleString()} tokens (${describeProvenance(model)})`,
|
|
49
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(
|
|
50
57
|
'',
|
|
51
58
|
'Set: /context window <size> (e.g. 120000, 200k, 1m)',
|
|
52
|
-
'Clear: /context window clear',
|
|
53
|
-
|
|
59
|
+
'Clear: /context window clear (also forgets the learned limit)',
|
|
60
|
+
);
|
|
54
61
|
return lines.join('\n');
|
|
55
62
|
}
|
|
56
63
|
|
|
@@ -66,13 +73,14 @@ export function handleContextWindowSubcommand(args: readonly string[], ctx: Comm
|
|
|
66
73
|
model,
|
|
67
74
|
registry.getContextWindowForModel(model),
|
|
68
75
|
registry.getModelContextCap(model.registryKey),
|
|
76
|
+
registry.getObservedContextWindow(model.registryKey),
|
|
69
77
|
);
|
|
70
78
|
} else if (arg === 'clear' || arg === 'auto' || arg === 'reset') {
|
|
71
79
|
const existed = registry.clearModelContextCap(model.registryKey);
|
|
72
80
|
const resolved = registry.getContextWindowForModel(registry.getCurrentModel());
|
|
73
81
|
output = existed
|
|
74
|
-
? `
|
|
75
|
-
: `${model.displayName} has no custom context window set (automatic: ${resolved.toLocaleString()} tokens).`;
|
|
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).`;
|
|
76
84
|
} else {
|
|
77
85
|
const size = parseContextWindowSize(arg);
|
|
78
86
|
if (size === null) {
|
|
@@ -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
|
+
};
|
|
@@ -137,6 +137,7 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
|
|
|
137
137
|
{ id: '/session', label: '/session', detail: 'Current session info', category: 'Conversation' },
|
|
138
138
|
{ id: '/session list', label: '/session list', detail: 'List all sessions', category: 'Conversation' },
|
|
139
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' },
|
|
140
141
|
{ id: '/session resume', label: '/session resume <id>', detail: 'Load and resume a session', category: 'Conversation' },
|
|
141
142
|
{ id: '/session fork', label: '/session fork [name]', detail: 'Fork current session to new ID', category: 'Conversation' },
|
|
142
143
|
{ id: '/session save', label: '/session save [name]', detail: 'Force-save current session', category: 'Conversation' },
|
package/src/input/commands.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
465
|
-
|
|
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
|
}
|
package/src/panels/fleet-stop.ts
CHANGED
|
@@ -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
|
-
|
|
133
|
-
|
|
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
|
|
package/src/runtime/services.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
235
|
-
readonly 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.
|
|
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;
|