@pellux/goodvibes-tui 0.20.3 → 0.22.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 +50 -0
- package/README.md +23 -2
- package/docs/foundation-artifacts/operator-contract.json +78 -1
- package/package.json +4 -2
- package/src/audio/spoken-turn-controller.ts +31 -1
- package/src/audio/spoken-turn-wiring.ts +26 -4
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/completions/generate.ts +658 -0
- package/src/cli/config-overrides.ts +68 -0
- package/src/cli/entrypoint.ts +6 -0
- package/src/cli/help.ts +4 -2
- package/src/cli/management-commands.ts +1 -1
- package/src/cli/management.ts +1 -8
- package/src/cli/parser.ts +31 -18
- package/src/cli/service-command.ts +1 -1
- package/src/cli/surface-command.ts +1 -1
- package/src/cli/tui-startup.ts +72 -10
- package/src/cli/types.ts +14 -3
- package/src/cli-flags.ts +1 -0
- package/src/config/atomic-write.ts +70 -0
- package/src/config/goodvibes-home-audit.ts +2 -0
- package/src/config/read-versioned.ts +115 -0
- package/src/core/context-auto-compact.ts +77 -0
- package/src/core/conversation-rendering.ts +49 -15
- package/src/core/conversation.ts +101 -16
- package/src/core/format-user-error.ts +192 -0
- package/src/core/stream-event-wiring.ts +144 -0
- package/src/core/stream-stall-watchdog.ts +103 -0
- package/src/core/system-message-router.ts +5 -1
- package/src/core/turn-event-wiring.ts +124 -0
- package/src/daemon/cli.ts +5 -0
- package/src/export/cost-utils.ts +71 -0
- package/src/export/gist-uploader.ts +136 -0
- package/src/input/command-registry.ts +32 -1
- package/src/input/commands/control-room-runtime.ts +10 -10
- package/src/input/commands/experience-runtime.ts +5 -4
- package/src/input/commands/knowledge.ts +1 -1
- package/src/input/commands/local-auth-runtime.ts +27 -5
- package/src/input/commands/local-setup.ts +4 -6
- package/src/input/commands/memory-product-runtime.ts +8 -6
- package/src/input/commands/operator-panel-runtime.ts +1 -1
- package/src/input/commands/operator-runtime.ts +3 -10
- package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
- package/src/input/commands/provider.ts +57 -3
- package/src/input/commands/recall-review.ts +26 -2
- package/src/input/commands/services-runtime.ts +2 -2
- package/src/input/commands/session-workflow.ts +8 -16
- package/src/input/commands/session.ts +70 -20
- package/src/input/commands/share-runtime.ts +99 -12
- package/src/input/commands/tts-runtime.ts +30 -4
- package/src/input/commands.ts +2 -4
- package/src/input/delete-key-policy.ts +46 -0
- package/src/input/feed-context-factory.ts +2 -0
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-interactions.ts +2 -15
- package/src/input/handler-modal-routes.ts +128 -12
- package/src/input/handler-modal-token-routes.ts +22 -5
- package/src/input/handler-onboarding-cloudflare.ts +1 -1
- package/src/input/handler-onboarding.ts +73 -69
- package/src/input/handler-types.ts +163 -0
- package/src/input/handler.ts +6 -2
- package/src/input/input-history.ts +76 -6
- package/src/input/model-picker-filter.ts +265 -0
- package/src/input/model-picker-items.ts +208 -0
- package/src/input/model-picker.ts +92 -325
- package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
- package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +14 -4
- package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +16 -2
- package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
- package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
- package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
- package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
- package/src/input/onboarding/onboarding-wizard-steps.ts +24 -25
- package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
- package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
- package/src/input/onboarding/onboarding-wizard.ts +3 -3
- package/src/input/settings-modal-behavior.ts +5 -0
- package/src/input/settings-modal-data.ts +378 -0
- package/src/input/settings-modal-mutations.ts +157 -0
- package/src/input/settings-modal-reset.ts +154 -0
- package/src/input/settings-modal.ts +236 -232
- package/src/main.ts +93 -85
- package/src/panels/agent-inspector-panel.ts +120 -18
- package/src/panels/agent-inspector-shared.ts +29 -0
- package/src/panels/builtin/agent.ts +4 -1
- package/src/panels/builtin/development.ts +5 -1
- package/src/panels/builtin/knowledge.ts +14 -13
- package/src/panels/builtin/operations.ts +22 -1
- package/src/panels/builtin/shared.ts +7 -0
- package/src/panels/cockpit-panel.ts +123 -3
- package/src/panels/cockpit-read-model.ts +232 -0
- package/src/panels/confirm-state.ts +27 -12
- package/src/panels/cost-tracker-panel.ts +23 -67
- package/src/panels/eval-panel.ts +10 -9
- package/src/panels/index.ts +1 -1
- package/src/panels/knowledge-graph-panel.ts +84 -0
- package/src/panels/local-auth-panel.ts +124 -4
- package/src/panels/memory-panel.ts +370 -40
- package/src/panels/project-planning-panel.ts +42 -4
- package/src/panels/search-focus.ts +11 -5
- package/src/panels/session-maintenance.ts +66 -15
- package/src/panels/subscription-panel.ts +33 -25
- package/src/panels/types.ts +28 -1
- package/src/panels/wrfc-panel.ts +224 -41
- package/src/renderer/agent-detail-modal.ts +118 -13
- package/src/renderer/code-block.ts +10 -2
- package/src/renderer/compositor.ts +18 -4
- package/src/renderer/context-inspector.ts +1 -5
- package/src/renderer/context-status-hint.ts +54 -0
- package/src/renderer/diff.ts +94 -21
- package/src/renderer/markdown.ts +29 -13
- package/src/renderer/settings-modal-helpers.ts +1 -1
- package/src/renderer/settings-modal.ts +90 -10
- package/src/renderer/shell-surface.ts +10 -0
- package/src/renderer/syntax-highlighter.ts +10 -3
- package/src/renderer/term-caps.ts +318 -0
- package/src/renderer/theme.ts +158 -0
- package/src/renderer/tool-call.ts +12 -2
- package/src/renderer/ui-factory.ts +50 -6
- package/src/runtime/bootstrap-command-context.ts +1 -0
- package/src/runtime/bootstrap-command-parts.ts +18 -0
- package/src/runtime/bootstrap-core.ts +145 -13
- package/src/runtime/bootstrap-shell.ts +11 -0
- package/src/runtime/bootstrap.ts +9 -0
- package/src/runtime/onboarding/apply.ts +4 -6
- package/src/runtime/onboarding/index.ts +1 -0
- package/src/runtime/onboarding/markers.ts +42 -49
- package/src/runtime/onboarding/progress.ts +148 -0
- package/src/runtime/onboarding/state.ts +133 -55
- package/src/runtime/onboarding/types.ts +20 -0
- package/src/runtime/services.ts +27 -1
- package/src/runtime/wrfc-persistence.ts +237 -0
- package/src/shell/blocking-input.ts +20 -5
- package/src/tools/wrfc-agent-guard.ts +64 -3
- package/src/utils/format-elapsed.ts +30 -0
- package/src/utils/terminal-width.ts +45 -0
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +4 -6
- package/src/panels/knowledge-panel.ts +0 -345
- package/src/planning/project-planning-coordinator.ts +0 -543
|
@@ -29,7 +29,59 @@ import { registerBootstrapHookBridge } from '@/runtime/index.ts';
|
|
|
29
29
|
import { registerBootstrapRuntimeEvents } from '@/runtime/index.ts';
|
|
30
30
|
import { createRuntimeServices, type RuntimeServices } from './services.ts';
|
|
31
31
|
import { createUiRuntimeServices, type UiRuntimeServices } from './ui-services.ts';
|
|
32
|
+
import { join } from 'node:path';
|
|
32
33
|
import { installWrfcAgentToolGuard } from '../tools/wrfc-agent-guard.ts';
|
|
34
|
+
import { createWrfcPersistence, type WrfcPersistence } from './wrfc-persistence.ts';
|
|
35
|
+
import type { SystemMessagePriority } from '../panels/system-messages-panel.ts';
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Pre-router buffer
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
const PRE_ROUTER_BUFFER_MAX = 100;
|
|
42
|
+
|
|
43
|
+
type BufferedWrfcMessage = {
|
|
44
|
+
readonly message: string;
|
|
45
|
+
readonly priority: SystemMessagePriority;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Small bounded queue that accumulates WRFC system messages emitted before
|
|
50
|
+
* the SystemMessageRouter is attached. On attach the queue flushes in order.
|
|
51
|
+
* If the queue overflows (> PRE_ROUTER_BUFFER_MAX), the oldest entries are
|
|
52
|
+
* dropped and a summary message is prepended to the first flushed message.
|
|
53
|
+
*/
|
|
54
|
+
export class WrfcPreRouterBuffer {
|
|
55
|
+
private readonly queue: BufferedWrfcMessage[] = [];
|
|
56
|
+
private overflowCount = 0;
|
|
57
|
+
|
|
58
|
+
push(message: string, priority: SystemMessagePriority): void {
|
|
59
|
+
if (this.queue.length >= PRE_ROUTER_BUFFER_MAX) {
|
|
60
|
+
this.queue.shift();
|
|
61
|
+
this.overflowCount++;
|
|
62
|
+
}
|
|
63
|
+
this.queue.push({ message, priority });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
flush(router: import('../core/system-message-router.ts').SystemMessageRouter): void {
|
|
67
|
+
const dropped = this.overflowCount;
|
|
68
|
+
const pending = this.queue.splice(0);
|
|
69
|
+
this.overflowCount = 0;
|
|
70
|
+
if (dropped > 0) {
|
|
71
|
+
router.wrfc(
|
|
72
|
+
`[WRFC] Pre-router buffer overflowed: ${dropped} earliest message${dropped !== 1 ? 's' : ''} were dropped`,
|
|
73
|
+
'low',
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
for (const item of pending) {
|
|
77
|
+
router.wrfc(item.message, item.priority);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get size(): number {
|
|
82
|
+
return this.queue.length;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
33
85
|
|
|
34
86
|
export interface BootstrapCoreState {
|
|
35
87
|
readonly userSessionId: string;
|
|
@@ -62,6 +114,11 @@ export interface BootstrapCoreState {
|
|
|
62
114
|
readonly requestRender: () => void;
|
|
63
115
|
readonly setRenderRequest: (fn: () => void) => void;
|
|
64
116
|
readonly runtimeSessionIdRef: { value: string };
|
|
117
|
+
/**
|
|
118
|
+
* WRFC chain persistence — call `rehydrate()` once after the SystemMessageRouter
|
|
119
|
+
* is wired so interrupted chains from a previous process are surfaced to the operator.
|
|
120
|
+
*/
|
|
121
|
+
readonly wrfcPersistence: WrfcPersistence;
|
|
65
122
|
}
|
|
66
123
|
|
|
67
124
|
export type CompanionMessagePayload = Extract<SessionEvent, { type: 'COMPANION_MESSAGE_RECEIVED' }>;
|
|
@@ -222,9 +279,9 @@ export async function initializeBootstrapCore(
|
|
|
222
279
|
overflowHandler: services.overflowHandler,
|
|
223
280
|
changeTracker: services.sessionChangeTracker,
|
|
224
281
|
});
|
|
225
|
-
installWrfcAgentToolGuard
|
|
226
|
-
|
|
227
|
-
|
|
282
|
+
// Note: installWrfcAgentToolGuard is called after routeOrBuffer is defined
|
|
283
|
+
// (further below) so the onTrace callback can route guard decisions through
|
|
284
|
+
// the pre-router buffer.
|
|
228
285
|
services.agentOrchestrator.setDependencies({
|
|
229
286
|
surfaceRoot: 'tui',
|
|
230
287
|
fileCache,
|
|
@@ -305,7 +362,26 @@ export async function initializeBootstrapCore(
|
|
|
305
362
|
void approvalBroker.start();
|
|
306
363
|
void sharedSessionBroker.start();
|
|
307
364
|
const runtimeSessionIdRef = { value: userSessionId };
|
|
308
|
-
const
|
|
365
|
+
const wrfcBuffer = new WrfcPreRouterBuffer();
|
|
366
|
+
// Smart ref: setting .value auto-flushes the pre-router buffer so events
|
|
367
|
+
// buffered before the SystemMessageRouter attaches are not permanently lost.
|
|
368
|
+
const systemMessageRouterRef = ((): { value: SystemMessageRouter | null } => {
|
|
369
|
+
let _value: SystemMessageRouter | null = null;
|
|
370
|
+
const ref = {} as { value: SystemMessageRouter | null };
|
|
371
|
+
Object.defineProperty(ref, 'value', {
|
|
372
|
+
get(): SystemMessageRouter | null { return _value; },
|
|
373
|
+
set(router: SystemMessageRouter | null): void {
|
|
374
|
+
_value = router;
|
|
375
|
+
if (router && wrfcBuffer.size > 0) {
|
|
376
|
+
wrfcBuffer.flush(router);
|
|
377
|
+
requestRender();
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
enumerable: true,
|
|
381
|
+
configurable: true,
|
|
382
|
+
});
|
|
383
|
+
return ref;
|
|
384
|
+
})();
|
|
309
385
|
const conversationFollowUpRef: { value: ((item: ConversationFollowUpItem) => void) | null } = { value: null };
|
|
310
386
|
const { unsubs: runtimeUnsubs, agentStatusIntervalRef } = registerBootstrapRuntimeEvents({
|
|
311
387
|
runtimeBus,
|
|
@@ -318,21 +394,69 @@ export async function initializeBootstrapCore(
|
|
|
318
394
|
wrfcController: services.wrfcController,
|
|
319
395
|
});
|
|
320
396
|
|
|
397
|
+
// ── WRFC chain persistence ──────────────────────────────────────────────────────────
|
|
398
|
+
const wrfcPersistence = createWrfcPersistence({
|
|
399
|
+
snapshotPath: join(workingDir, '.goodvibes', 'tui', 'wrfc-chains.json'),
|
|
400
|
+
getSystemMessageRouter: () => systemMessageRouterRef.value,
|
|
401
|
+
controller: services.wrfcController,
|
|
402
|
+
});
|
|
403
|
+
runtimeUnsubs.push(...wrfcPersistence.attach(runtimeBus));
|
|
404
|
+
// Flush any debounced snapshot on clean shutdown so final chain state is
|
|
405
|
+
// never silently dropped during a SIGINT/teardown (250ms debounce window).
|
|
406
|
+
bootstrapUnsubs.push(() => wrfcPersistence.flush());
|
|
407
|
+
|
|
321
408
|
// ── TUI-specific WRFC constraint-propagation event subscriptions (SDK 0.23.0) ──
|
|
322
409
|
// These supplement the SDK's registerBootstrapRuntimeEvents which handles the
|
|
323
410
|
// core WORKFLOW_REVIEW_COMPLETED / WORKFLOW_CHAIN_CREATED messages.
|
|
324
411
|
// The SDK does not surface constraint-specific system messages; the TUI layer
|
|
325
412
|
// adds them here so operators can observe constraint enumeration and violations
|
|
326
413
|
// in the SystemMessagesPanel and main conversation.
|
|
414
|
+
//
|
|
415
|
+
// Pre-router buffering: events that arrive before the SystemMessageRouter is
|
|
416
|
+
// attached are held in wrfcBuffer (bounded, 100 entries). When the router is
|
|
417
|
+
// set on systemMessageRouterRef, the smart setter flushes the buffer in order.
|
|
418
|
+
// If the buffer overflows, the oldest entries are dropped and a summary message
|
|
419
|
+
// is prepended to the first flushed batch.
|
|
420
|
+
const routeOrBuffer = (message: string, priority: SystemMessagePriority): void => {
|
|
421
|
+
const router = systemMessageRouterRef.value;
|
|
422
|
+
if (router) {
|
|
423
|
+
router.wrfc(message, priority);
|
|
424
|
+
} else {
|
|
425
|
+
wrfcBuffer.push(message, priority);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// Startup TLS banner — emitted via wrfcBuffer.push() because the
|
|
430
|
+
// SystemMessageRouter is not attached yet at this point in bootstrap. The
|
|
431
|
+
// smart-ref setter on systemMessageRouterRef auto-flushes the buffer when
|
|
432
|
+
// the router attaches, so the message will appear in the WRFC panel on startup.
|
|
433
|
+
{
|
|
434
|
+
const cpEnabled = Boolean(configManager.get('controlPlane.enabled'));
|
|
435
|
+
const cpHostMode = String(configManager.get('controlPlane.hostMode') ?? 'local');
|
|
436
|
+
const cpTlsMode = String(configManager.get('controlPlane.tls.mode') ?? 'off');
|
|
437
|
+
const hlEnabled = Boolean(configManager.get('danger.httpListener'));
|
|
438
|
+
const hlHostMode = String(configManager.get('httpListener.hostMode') ?? 'local');
|
|
439
|
+
const hlTlsMode = String(configManager.get('httpListener.tls.mode') ?? 'off');
|
|
440
|
+
const cpNetworkPlaintext = cpEnabled && cpHostMode !== 'local' && cpTlsMode === 'off';
|
|
441
|
+
const hlNetworkPlaintext = hlEnabled && hlHostMode !== 'local' && hlTlsMode === 'off';
|
|
442
|
+
if (cpNetworkPlaintext || hlNetworkPlaintext) {
|
|
443
|
+
const affected: string[] = [];
|
|
444
|
+
if (cpNetworkPlaintext) affected.push('control plane');
|
|
445
|
+
if (hlNetworkPlaintext) affected.push('HTTP listener');
|
|
446
|
+
wrfcBuffer.push(
|
|
447
|
+
`[SECURITY] TLS is off for the ${affected.join(' and ')} but it is network-reachable. All traffic (credentials, tokens, conversation content) travels in plaintext. Enable TLS (controlPlane.tls.mode / httpListener.tls.mode) or restrict to loopback before exposing to untrusted networks.`,
|
|
448
|
+
'high',
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
327
453
|
runtimeUnsubs.push(
|
|
328
454
|
runtimeBus.on<Extract<import('@/runtime/index.ts').WorkflowEvent, { type: 'WORKFLOW_CONSTRAINTS_ENUMERATED' }>>(
|
|
329
455
|
'WORKFLOW_CONSTRAINTS_ENUMERATED',
|
|
330
456
|
({ payload }) => {
|
|
331
|
-
const router = systemMessageRouterRef.value;
|
|
332
|
-
if (!router) return;
|
|
333
457
|
const count = payload.constraints.length;
|
|
334
458
|
if (count > 0) {
|
|
335
|
-
|
|
459
|
+
routeOrBuffer(
|
|
336
460
|
`[WRFC] Engineer enumerated ${count} constraint${count !== 1 ? 's' : ''} for chain ${payload.chainId.slice(0, 12)}`,
|
|
337
461
|
'low',
|
|
338
462
|
);
|
|
@@ -345,11 +469,9 @@ export async function initializeBootstrapCore(
|
|
|
345
469
|
runtimeBus.on<Extract<import('@/runtime/index.ts').WorkflowEvent, { type: 'WORKFLOW_FIX_ATTEMPTED' }>>(
|
|
346
470
|
'WORKFLOW_FIX_ATTEMPTED',
|
|
347
471
|
({ payload }) => {
|
|
348
|
-
const router = systemMessageRouterRef.value;
|
|
349
|
-
if (!router) return;
|
|
350
472
|
const targetIds = payload.targetConstraintIds;
|
|
351
473
|
if (targetIds && targetIds.length > 0) {
|
|
352
|
-
|
|
474
|
+
routeOrBuffer(
|
|
353
475
|
`[WRFC] Fix #${payload.attempt} targeting ${targetIds.length} constraint${targetIds.length !== 1 ? 's' : ''} on chain ${payload.chainId.slice(0, 12)}`,
|
|
354
476
|
'low',
|
|
355
477
|
);
|
|
@@ -362,11 +484,9 @@ export async function initializeBootstrapCore(
|
|
|
362
484
|
runtimeBus.on<Extract<import('@/runtime/index.ts').WorkflowEvent, { type: 'WORKFLOW_REVIEW_COMPLETED' }>>(
|
|
363
485
|
'WORKFLOW_REVIEW_COMPLETED',
|
|
364
486
|
({ payload }) => {
|
|
365
|
-
const router = systemMessageRouterRef.value;
|
|
366
|
-
if (!router) return;
|
|
367
487
|
const unsatisfied = payload.unsatisfiedConstraintIds;
|
|
368
488
|
if (!payload.passed && unsatisfied && unsatisfied.length > 0) {
|
|
369
|
-
|
|
489
|
+
routeOrBuffer(
|
|
370
490
|
`[WRFC] ✗ Chain ${payload.chainId.slice(0, 12)}: ${unsatisfied.length} constraint violation${unsatisfied.length !== 1 ? 's' : ''} forced failure`,
|
|
371
491
|
'high',
|
|
372
492
|
);
|
|
@@ -376,6 +496,17 @@ export async function initializeBootstrapCore(
|
|
|
376
496
|
),
|
|
377
497
|
);
|
|
378
498
|
|
|
499
|
+
// Wire the WRFC agent-guard with the onTrace callback so routing decisions are
|
|
500
|
+
// observable via the same routeOrBuffer path as WORKFLOW_* events.
|
|
501
|
+
// Placed here (after routeOrBuffer is defined) so the closure is fully wired.
|
|
502
|
+
installWrfcAgentToolGuard(toolRegistry, {
|
|
503
|
+
getLastUserMessage: () => conversation.getLastUserMessage(),
|
|
504
|
+
onTrace: ({ kind, reason, task }) => {
|
|
505
|
+
const shortTask = task.length > 80 ? `${task.slice(0, 77)}...` : task;
|
|
506
|
+
routeOrBuffer(`[WRFC] Guard: ${reason} — task: "${shortTask}" (${kind})`, 'low');
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
|
|
379
510
|
// Subscribe to companion main-chat messages received from the daemon's HTTP layer.
|
|
380
511
|
// The daemon emits COMPANION_MESSAGE_RECEIVED on the runtime bus when a companion
|
|
381
512
|
// POST /api/sessions/:id/messages with kind='message' arrives.
|
|
@@ -535,5 +666,6 @@ export async function initializeBootstrapCore(
|
|
|
535
666
|
renderRequestRef.value = fn;
|
|
536
667
|
},
|
|
537
668
|
runtimeSessionIdRef,
|
|
669
|
+
wrfcPersistence,
|
|
538
670
|
};
|
|
539
671
|
}
|
|
@@ -40,6 +40,11 @@ export interface BootstrapShellState {
|
|
|
40
40
|
readonly lastGitInfoRef: { value: GitHeaderInfo | undefined };
|
|
41
41
|
readonly inputHistory: InputHistory;
|
|
42
42
|
readonly systemMessageRouter: SystemMessageRouter;
|
|
43
|
+
/**
|
|
44
|
+
* Wire the agent detail modal opener after InputHandler is constructed.
|
|
45
|
+
* Call with `(id) => input.agentDetailModal.open(id)` from main.ts.
|
|
46
|
+
*/
|
|
47
|
+
readonly setOpenAgentDetail: (fn: (agentId: string) => void) => void;
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
export interface BootstrapShellOptions {
|
|
@@ -103,6 +108,8 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
|
|
|
103
108
|
providerRegistry: services.providerRegistry,
|
|
104
109
|
});
|
|
105
110
|
|
|
111
|
+
const openAgentDetailRef: { fn: (agentId: string) => void } = { fn: (_agentId: string) => {} };
|
|
112
|
+
|
|
106
113
|
let commandContextRef: CommandContext | null = null;
|
|
107
114
|
registerBuiltinPanels(services.panelManager, {
|
|
108
115
|
configManager,
|
|
@@ -143,6 +150,7 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
|
|
|
143
150
|
hookActivityTracker: services.hookActivityTracker,
|
|
144
151
|
hookWorkbench: services.hookWorkbench,
|
|
145
152
|
mcpRegistry: services.mcpRegistry,
|
|
153
|
+
openAgentDetail: (agentId: string) => openAgentDetailRef.fn(agentId),
|
|
146
154
|
daemonHomeDir: join(services.homeDirectory, '.goodvibes', 'daemon'),
|
|
147
155
|
});
|
|
148
156
|
services.panelManager.prewarmRegistered();
|
|
@@ -278,5 +286,8 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
|
|
|
278
286
|
lastGitInfoRef,
|
|
279
287
|
inputHistory,
|
|
280
288
|
systemMessageRouter,
|
|
289
|
+
setOpenAgentDetail: (fn: (agentId: string) => void) => {
|
|
290
|
+
openAgentDetailRef.fn = fn;
|
|
291
|
+
},
|
|
281
292
|
};
|
|
282
293
|
}
|
package/src/runtime/bootstrap.ts
CHANGED
|
@@ -118,6 +118,11 @@ export type BootstrapContext = RuntimeContext & {
|
|
|
118
118
|
* stay out of the main conversation and go to the SystemMessagesPanel instead.
|
|
119
119
|
*/
|
|
120
120
|
systemMessageRouter: SystemMessageRouter;
|
|
121
|
+
/**
|
|
122
|
+
* Wire the agent detail modal opener after InputHandler is constructed in main.ts.
|
|
123
|
+
* Call with `(id) => input.agentDetailModal.open(id)` once the InputHandler is ready.
|
|
124
|
+
*/
|
|
125
|
+
setOpenAgentDetail: (fn: (agentId: string) => void) => void;
|
|
121
126
|
};
|
|
122
127
|
|
|
123
128
|
// ── Bootstrap function ────────────────────────────────────────────────────
|
|
@@ -180,6 +185,7 @@ export async function bootstrapRuntime(
|
|
|
180
185
|
requestRender,
|
|
181
186
|
setRenderRequest,
|
|
182
187
|
runtimeSessionIdRef,
|
|
188
|
+
wrfcPersistence,
|
|
183
189
|
} = await initializeBootstrapCore(stdout, options, (limit) => controlPlaneRecentEventsRef.value(limit));
|
|
184
190
|
const providerRegistry = services.providerRegistry;
|
|
185
191
|
const {
|
|
@@ -285,11 +291,13 @@ export async function bootstrapRuntime(
|
|
|
285
291
|
});
|
|
286
292
|
const systemMessageRouter = shell.systemMessageRouter;
|
|
287
293
|
systemMessageRouterRef.value = systemMessageRouter;
|
|
294
|
+
wrfcPersistence.rehydrate();
|
|
288
295
|
const commandRegistry = shell.commandRegistry;
|
|
289
296
|
const commandContext = shell.commandContext;
|
|
290
297
|
const gitStatusProvider = shell.gitStatusProvider;
|
|
291
298
|
const inputHistory = shell.inputHistory;
|
|
292
299
|
const lastGitInfoRef = shell.lastGitInfoRef;
|
|
300
|
+
const setOpenAgentDetail = shell.setOpenAgentDetail;
|
|
293
301
|
const pluginCommandRegistry = {
|
|
294
302
|
register(command: {
|
|
295
303
|
readonly name: string;
|
|
@@ -620,6 +628,7 @@ export async function bootstrapRuntime(
|
|
|
620
628
|
_getConfiguredProviderIds: () => services.providerRegistry.getConfiguredProviderIds(),
|
|
621
629
|
commandRegistry,
|
|
622
630
|
systemMessageRouter,
|
|
631
|
+
setOpenAgentDetail,
|
|
623
632
|
shutdown: async (sessionData) => {
|
|
624
633
|
// Clear bootstrap-owned subscriptions
|
|
625
634
|
bootstrapUnsubs.forEach(fn => fn());
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { atomicWriteFileSync } from '../../config/atomic-write.ts';
|
|
3
3
|
import { isSecretRefInput } from '@pellux/goodvibes-sdk/platform/config';
|
|
4
4
|
import { CONFIG_SCHEMA, DEFAULT_CONFIG } from '../../config/index.ts';
|
|
5
5
|
import type { FeatureFlagConfigKey } from '../surface-feature-flags.ts';
|
|
@@ -34,8 +34,7 @@ function readJsonObject(path: string): Record<string, unknown> {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
function writeJsonObject(path: string, payload: Record<string, unknown>): void {
|
|
37
|
-
|
|
38
|
-
writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
|
|
37
|
+
atomicWriteFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { mkdirp: true });
|
|
39
38
|
}
|
|
40
39
|
|
|
41
40
|
function setNestedValue(root: Record<string, unknown>, key: string, value: unknown): Record<string, unknown> {
|
|
@@ -76,8 +75,7 @@ function restoreFile(path: string, previous: string | null, reload?: () => void)
|
|
|
76
75
|
if (previous === null) {
|
|
77
76
|
if (existsSync(path)) unlinkSync(path);
|
|
78
77
|
} else {
|
|
79
|
-
|
|
80
|
-
writeFileSync(path, previous, 'utf-8');
|
|
78
|
+
atomicWriteFileSync(path, previous, { mkdirp: true });
|
|
81
79
|
}
|
|
82
80
|
reload?.();
|
|
83
81
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { existsSync
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { atomicWriteFileSync } from '@/config/atomic-write.ts';
|
|
3
|
+
import { readVersioned } from '@/config/read-versioned.ts';
|
|
4
|
+
|
|
3
5
|
import type { ShellPathService } from '@/runtime/index.ts';
|
|
4
6
|
import type {
|
|
5
7
|
OnboardingCheckMarkerPayload,
|
|
@@ -25,50 +27,34 @@ function resolveMarkerPath(
|
|
|
25
27
|
: shellPaths.resolveUserPath('tui', ONBOARDING_CHECK_MARKER_FILE);
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
function isObject(value: unknown): value is Record<string, unknown> {
|
|
29
|
-
return typeof value === 'object' && value !== null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function isOnboardingMode(value: unknown): value is OnboardingCheckMarkerPayload['mode'] {
|
|
33
|
-
return value === 'new' || value === 'edit' || value === 'reopen';
|
|
34
|
-
}
|
|
35
|
-
|
|
36
30
|
function isCheckMarkerPayload(value: unknown): value is OnboardingCheckMarkerPayload {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
&& typeof
|
|
42
|
-
&& Number.isFinite(
|
|
43
|
-
&& typeof
|
|
44
|
-
&& (
|
|
45
|
-
&&
|
|
31
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
32
|
+
const v = value as Record<string, unknown>;
|
|
33
|
+
return (
|
|
34
|
+
v['version'] === 1
|
|
35
|
+
&& typeof v['checkedAt'] === 'number'
|
|
36
|
+
&& Number.isFinite(v['checkedAt'] as number)
|
|
37
|
+
&& typeof v['updatedAt'] === 'number'
|
|
38
|
+
&& Number.isFinite(v['updatedAt'] as number)
|
|
39
|
+
&& typeof v['source'] === 'string'
|
|
40
|
+
&& (v['mode'] === undefined || v['mode'] === 'new' || v['mode'] === 'edit' || v['mode'] === 'reopen')
|
|
41
|
+
&& (v['workspaceRoot'] === undefined || typeof v['workspaceRoot'] === 'string')
|
|
42
|
+
);
|
|
46
43
|
}
|
|
47
44
|
|
|
48
45
|
function buildMissingMarkerState(
|
|
49
46
|
scope: OnboardingStateScope,
|
|
50
47
|
path: string,
|
|
51
48
|
): OnboardingCheckMarkerState {
|
|
52
|
-
return {
|
|
53
|
-
scope,
|
|
54
|
-
path,
|
|
55
|
-
exists: false,
|
|
56
|
-
payload: null,
|
|
57
|
-
};
|
|
49
|
+
return { scope, path, exists: false, payload: null };
|
|
58
50
|
}
|
|
59
51
|
|
|
60
|
-
function
|
|
52
|
+
function buildErrorMarkerState(
|
|
61
53
|
scope: OnboardingStateScope,
|
|
62
54
|
path: string,
|
|
63
55
|
parseError: string,
|
|
64
56
|
): OnboardingCheckMarkerState {
|
|
65
|
-
return {
|
|
66
|
-
scope,
|
|
67
|
-
path,
|
|
68
|
-
exists: true,
|
|
69
|
-
payload: null,
|
|
70
|
-
parseError,
|
|
71
|
-
};
|
|
57
|
+
return { scope, path, exists: true, payload: null, parseError };
|
|
72
58
|
}
|
|
73
59
|
|
|
74
60
|
function pickEffectiveMarker(
|
|
@@ -91,24 +77,32 @@ export function readOnboardingCheckMarker(
|
|
|
91
77
|
scope: OnboardingStateScope = 'user',
|
|
92
78
|
): OnboardingCheckMarkerState {
|
|
93
79
|
const path = resolveMarkerPath(shellPaths, scope);
|
|
94
|
-
if (!existsSync(path)) return buildMissingMarkerState(scope, path);
|
|
95
|
-
|
|
96
|
-
try {
|
|
97
|
-
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
|
|
98
|
-
if (!isCheckMarkerPayload(parsed)) {
|
|
99
|
-
return buildParseErrorState(scope, path, 'Invalid onboarding check marker payload.');
|
|
100
|
-
}
|
|
101
80
|
|
|
102
|
-
|
|
81
|
+
const parsed = readVersioned<OnboardingCheckMarkerPayload & { version: number }>(
|
|
82
|
+
path,
|
|
83
|
+
{ currentVersion: 1, onUnknown: 'quarantine' },
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
if (parsed === null) {
|
|
87
|
+
// readVersioned returns null for: missing, corrupt JSON, or unrecognised
|
|
88
|
+
// version (renamed to <path>.unrecognized).
|
|
89
|
+
const nowExists = existsSync(path);
|
|
90
|
+
const quarantined = existsSync(`${path}.unrecognized`);
|
|
91
|
+
if (!nowExists && !quarantined) return buildMissingMarkerState(scope, path);
|
|
92
|
+
return buildErrorMarkerState(
|
|
103
93
|
scope,
|
|
104
94
|
path,
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
95
|
+
quarantined
|
|
96
|
+
? 'Unrecognised or corrupt marker file; quarantined.'
|
|
97
|
+
: 'Invalid onboarding check marker payload.',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!isCheckMarkerPayload(parsed)) {
|
|
102
|
+
return buildErrorMarkerState(scope, path, 'Invalid onboarding check marker payload.');
|
|
111
103
|
}
|
|
104
|
+
|
|
105
|
+
return { scope, path, exists: true, payload: parsed };
|
|
112
106
|
}
|
|
113
107
|
|
|
114
108
|
export function readOnboardingCheckMarkers(
|
|
@@ -140,8 +134,7 @@ export function writeOnboardingCheckMarker(
|
|
|
140
134
|
...(options.workspaceRoot ? { workspaceRoot: options.workspaceRoot } : {}),
|
|
141
135
|
};
|
|
142
136
|
|
|
143
|
-
|
|
144
|
-
writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
|
|
137
|
+
atomicWriteFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { mkdirp: true });
|
|
145
138
|
|
|
146
139
|
return readOnboardingCheckMarker(shellPaths, scope);
|
|
147
140
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { existsSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { atomicWriteFileSync } from '@/config/atomic-write.ts';
|
|
3
|
+
import { readVersioned } from '@/config/read-versioned.ts';
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
OnboardingMode,
|
|
7
|
+
OnboardingShellPaths,
|
|
8
|
+
WizardProgressPayload,
|
|
9
|
+
WizardProgressState,
|
|
10
|
+
} from './types.ts';
|
|
11
|
+
|
|
12
|
+
const WIZARD_PROGRESS_FILE = 'onboarding-progress.json';
|
|
13
|
+
|
|
14
|
+
function resolveProgressPath(shellPaths: OnboardingShellPaths): string {
|
|
15
|
+
return shellPaths.resolveUserPath('tui', WIZARD_PROGRESS_FILE);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isWizardProgressPayload(value: unknown): value is WizardProgressPayload {
|
|
19
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
20
|
+
const v = value as Record<string, unknown>;
|
|
21
|
+
if (v['version'] !== 1) return false;
|
|
22
|
+
if (typeof v['savedAt'] !== 'number' || !Number.isFinite(v['savedAt'] as number)) return false;
|
|
23
|
+
const mode = v['mode'];
|
|
24
|
+
if (mode !== 'new' && mode !== 'edit' && mode !== 'reopen') return false;
|
|
25
|
+
if (typeof v['stepIndex'] !== 'number' || !Number.isFinite(v['stepIndex'] as number)) return false;
|
|
26
|
+
if (!Array.isArray(v['toggleState'])) return false;
|
|
27
|
+
if (!Array.isArray(v['radioState'])) return false;
|
|
28
|
+
if (!Array.isArray(v['textState'])) return false;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Path to the wizard progress file (user-scoped, in ~/.config/goodvibes/tui/).
|
|
34
|
+
*/
|
|
35
|
+
export function getWizardProgressPath(shellPaths: OnboardingShellPaths): string {
|
|
36
|
+
return resolveProgressPath(shellPaths);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read the persisted wizard progress, if any.
|
|
41
|
+
*
|
|
42
|
+
* Returns a WizardProgressState with exists=false when no progress file is
|
|
43
|
+
* present. Returns exists=true, payload=null with a parseError when the file
|
|
44
|
+
* is present but unreadable or schema-mismatched (the bad file is left in
|
|
45
|
+
* place so the caller can decide whether to delete it).
|
|
46
|
+
*/
|
|
47
|
+
export function readWizardProgress(shellPaths: OnboardingShellPaths): WizardProgressState {
|
|
48
|
+
const path = resolveProgressPath(shellPaths);
|
|
49
|
+
|
|
50
|
+
const parsed = readVersioned<WizardProgressPayload & { version: number }>(
|
|
51
|
+
path,
|
|
52
|
+
{ currentVersion: 1, onUnknown: 'quarantine' },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (parsed === null) {
|
|
56
|
+
const nowExists = existsSync(path);
|
|
57
|
+
const quarantined = existsSync(`${path}.unrecognized`);
|
|
58
|
+
if (!nowExists && !quarantined) return { path, exists: false, payload: null };
|
|
59
|
+
return {
|
|
60
|
+
path,
|
|
61
|
+
exists: true,
|
|
62
|
+
payload: null,
|
|
63
|
+
parseError: quarantined
|
|
64
|
+
? 'Unrecognised or corrupt wizard progress file; quarantined.'
|
|
65
|
+
: 'Invalid wizard progress payload.',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!isWizardProgressPayload(parsed)) {
|
|
70
|
+
return { path, exists: true, payload: null, parseError: 'Invalid wizard progress payload.' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { path, exists: true, payload: parsed };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface WriteWizardProgressOptions {
|
|
77
|
+
readonly mode: OnboardingMode;
|
|
78
|
+
readonly stepIndex: number;
|
|
79
|
+
readonly toggleState: ReadonlyArray<readonly [string, boolean]>;
|
|
80
|
+
readonly radioState: ReadonlyArray<readonly [string, string]>;
|
|
81
|
+
readonly textState: ReadonlyArray<readonly [string, string]>;
|
|
82
|
+
readonly clock?: () => number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Atomically persist wizard progress to disk.
|
|
87
|
+
*
|
|
88
|
+
* Uses atomicWriteFileSync (write-to-tmp + rename) so a crash mid-write
|
|
89
|
+
* never leaves a torn file. The file is user-scoped so it survives across
|
|
90
|
+
* project switches and is shared with the resume-prompt check at startup.
|
|
91
|
+
*
|
|
92
|
+
* Masked (password) fields are deliberately excluded from the serialised
|
|
93
|
+
* textState by the caller — this function accepts whatever is passed in and
|
|
94
|
+
* does NOT filter. Callers must strip sensitive fields before calling.
|
|
95
|
+
*/
|
|
96
|
+
export function writeWizardProgress(
|
|
97
|
+
shellPaths: OnboardingShellPaths,
|
|
98
|
+
options: WriteWizardProgressOptions,
|
|
99
|
+
): void {
|
|
100
|
+
const path = resolveProgressPath(shellPaths);
|
|
101
|
+
const payload: WizardProgressPayload = {
|
|
102
|
+
version: 1,
|
|
103
|
+
savedAt: (options.clock ?? Date.now)(),
|
|
104
|
+
mode: options.mode,
|
|
105
|
+
stepIndex: options.stepIndex,
|
|
106
|
+
toggleState: options.toggleState,
|
|
107
|
+
radioState: options.radioState,
|
|
108
|
+
textState: options.textState,
|
|
109
|
+
};
|
|
110
|
+
atomicWriteFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { mkdirp: true });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Delete the wizard progress file (best-effort; ignores ENOENT).
|
|
115
|
+
*
|
|
116
|
+
* Called after a successful apply so the resume prompt is not shown on
|
|
117
|
+
* next startup.
|
|
118
|
+
*/
|
|
119
|
+
export function deleteWizardProgress(shellPaths: OnboardingShellPaths): void {
|
|
120
|
+
const path = resolveProgressPath(shellPaths);
|
|
121
|
+
try { unlinkSync(path); } catch { /* best-effort: file may not exist */ }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Returns true when an in-progress wizard session was interrupted and is
|
|
126
|
+
* still recent enough to reopen on startup (the caller reopens the wizard
|
|
127
|
+
* at the saved step so the user can continue or dismiss it).
|
|
128
|
+
*
|
|
129
|
+
* A progress file is considered resumable when:
|
|
130
|
+
* - it exists and can be parsed (payload !== null)
|
|
131
|
+
* - it is less than PROGRESS_MAX_AGE_MS old (default: 7 days)
|
|
132
|
+
*
|
|
133
|
+
* Negative age (future-dated `savedAt`) is treated as non-resumable: it
|
|
134
|
+
* indicates clock skew or a tampered file and is safer to reject than to
|
|
135
|
+
* open a wizard with unknown-age state.
|
|
136
|
+
*/
|
|
137
|
+
const PROGRESS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
138
|
+
|
|
139
|
+
export function hasResumableWizardProgress(
|
|
140
|
+
shellPaths: OnboardingShellPaths,
|
|
141
|
+
options: { now?: number; state?: WizardProgressState } = {},
|
|
142
|
+
): boolean {
|
|
143
|
+
const state = options.state ?? readWizardProgress(shellPaths);
|
|
144
|
+
if (!state.payload) return false;
|
|
145
|
+
const age = (options.now ?? Date.now()) - state.payload.savedAt;
|
|
146
|
+
// age < 0 means savedAt is in the future (clock skew / tampered file) — treat as non-resumable.
|
|
147
|
+
return age >= 0 && age < PROGRESS_MAX_AGE_MS;
|
|
148
|
+
}
|