@pellux/goodvibes-tui 1.13.0 → 1.14.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 +101 -0
- package/README.md +6 -6
- package/bin/goodvibes +6 -0
- package/bin/goodvibes-daemon +6 -0
- package/bin/launcher-support.js +34 -1
- package/docs/foundation-artifacts/operator-contract.json +8076 -2047
- package/package.json +15 -11
- package/scripts/postinstall.js +73 -48
- package/src/cli/bundle-command.ts +5 -5
- package/src/cli/completion.ts +1 -1
- package/src/cli/completions/generate.ts +15 -3
- package/src/cli/doctor.ts +385 -0
- package/src/cli/entrypoint.ts +72 -1
- package/src/cli/help.ts +29 -9
- package/src/cli/hooks-command.ts +74 -0
- package/src/cli/hooks-report.ts +167 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/management.ts +13 -1
- package/src/cli/parser.ts +10 -2
- package/src/cli/plugin-bundles-command.ts +228 -0
- package/src/cli/plugin-command.ts +179 -0
- package/src/cli/service-posture.ts +10 -0
- package/src/cli/status.ts +275 -2
- package/src/cli/tui-startup.ts +119 -1
- package/src/cli/types.ts +3 -1
- package/src/config/index.ts +16 -0
- package/src/config/tui-extension-settings.ts +169 -0
- package/src/core/approval-alert.ts +15 -2
- package/src/core/compaction-receipt.ts +127 -0
- package/src/core/model-routing-chip.ts +82 -0
- package/src/core/permission-mode.ts +89 -0
- package/src/core/rewind-receipt.ts +83 -0
- package/src/core/rewind-turn-anchors.ts +160 -0
- package/src/core/scriptable-statusline.ts +141 -0
- package/src/core/session-auto-titler.ts +110 -0
- package/src/core/stream-event-wiring.ts +20 -0
- package/src/core/system-message-router.ts +1 -1
- package/src/core/terminal-notifier.ts +102 -0
- package/src/core/turn-event-wiring.ts +80 -1
- package/src/input/command-reference.ts +96 -0
- package/src/input/command-registry.ts +25 -0
- package/src/input/commands/channel-pairing.ts +245 -0
- package/src/input/commands/channel-runtime.ts +152 -6
- package/src/input/commands/checkin-runtime.ts +89 -0
- package/src/input/commands/checkpoint-runtime.ts +7 -179
- package/src/input/commands/ci-runtime.ts +207 -0
- package/src/input/commands/cost-attribution-format.ts +52 -0
- package/src/input/commands/cost-runtime.ts +44 -4
- package/src/input/commands/editor-runtime.ts +22 -0
- package/src/input/commands/flags-runtime.ts +328 -0
- package/src/input/commands/health-metrics-format.ts +67 -0
- package/src/input/commands/health-runtime.ts +55 -1
- package/src/input/commands/memory.ts +23 -0
- package/src/input/commands/operator-rpc.ts +95 -0
- package/src/input/commands/palette-runtime.ts +22 -0
- package/src/input/commands/permissions-provenance.ts +264 -0
- package/src/input/commands/permissions-runtime.ts +21 -0
- package/src/input/commands/planning-runtime.ts +28 -1
- package/src/input/commands/platform-access-runtime.ts +2 -97
- package/src/input/commands/principals-runtime.ts +212 -0
- package/src/input/commands/product-runtime.ts +43 -3
- package/src/input/commands/recall-files-config.ts +54 -0
- package/src/input/commands/recall-files-git.ts +57 -0
- package/src/input/commands/recall-files.ts +185 -0
- package/src/input/commands/recall-query.ts +8 -3
- package/src/input/commands/recall-review.ts +2 -2
- package/src/input/commands/recall-shared.ts +13 -0
- package/src/input/commands/relay-runtime.ts +86 -0
- package/src/input/commands/review-runtime.ts +160 -0
- package/src/input/commands/rewind-runtime.ts +362 -0
- package/src/input/commands/schedule-nl.ts +147 -0
- package/src/input/commands/schedule-runtime.ts +29 -10
- package/src/input/commands/secret-runtime.ts +55 -0
- package/src/input/commands/session-content.ts +19 -8
- package/src/input/commands/session-workflow.ts +10 -0
- package/src/input/commands/update-runtime.ts +363 -0
- package/src/input/commands/workstream-attempt-dependents.ts +103 -0
- package/src/input/commands/workstream-attempts.ts +244 -0
- package/src/input/commands/workstream-runtime.ts +164 -26
- package/src/input/commands/worktree-runtime.ts +56 -2
- package/src/input/commands.ts +135 -80
- package/src/input/composer-capture.ts +67 -0
- package/src/input/composer-editor.ts +134 -0
- package/src/input/concealed-input.ts +94 -0
- package/src/input/exec-env-scrub-config.ts +84 -0
- package/src/input/feature-unit-layout.ts +142 -0
- package/src/input/feed-context-factory.ts +2 -0
- package/src/input/handler-feed-routes.ts +17 -0
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-modal-routes.ts +5 -2
- package/src/input/handler-shortcuts.ts +37 -0
- package/src/input/handler.ts +24 -2
- package/src/input/keybindings.ts +11 -2
- package/src/input/onboarding/onboarding-feature-units.ts +536 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +40 -6
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +324 -629
- package/src/input/onboarding/onboarding-wizard-steps.ts +5 -5
- package/src/input/onboarding/onboarding-wizard-types.ts +6 -0
- package/src/input/relay-settings-descriptions.ts +35 -0
- package/src/input/sandbox-exec-config.ts +107 -0
- package/src/input/settings-modal-adjustment.ts +4 -7
- package/src/input/settings-modal-data.ts +178 -8
- package/src/input/settings-modal-mutations.ts +41 -13
- package/src/input/settings-modal-types.ts +42 -5
- package/src/input/settings-modal.ts +67 -15
- package/src/input/submission-router.ts +4 -1
- package/src/input/worktree-setup-config.ts +93 -0
- package/src/main.ts +43 -43
- package/src/panels/builtin/development.ts +14 -0
- package/src/panels/diff-review-model.ts +189 -0
- package/src/panels/diff-review-panel.ts +395 -0
- package/src/panels/fleet-panel-format.ts +114 -2
- package/src/panels/fleet-panel.ts +65 -78
- package/src/panels/fleet-read-model.ts +91 -11
- package/src/panels/fleet-stop.ts +19 -4
- package/src/panels/modals/memory-modal.ts +11 -2
- package/src/permissions/hunk-selection.ts +86 -5
- package/src/permissions/prompt.ts +108 -7
- package/src/permissions/sandbox-exec-gate.ts +162 -0
- package/src/renderer/settings-modal-helpers.ts +21 -1
- package/src/renderer/settings-modal.ts +70 -44
- package/src/renderer/shell-surface.ts +23 -1
- package/src/renderer/system-message.ts +4 -0
- package/src/renderer/tool-result-summary.ts +95 -5
- package/src/renderer/ui-factory.ts +22 -0
- package/src/runtime/bootstrap-command-context.ts +15 -1
- package/src/runtime/bootstrap-command-parts.ts +14 -2
- package/src/runtime/bootstrap-core.ts +38 -38
- package/src/runtime/bootstrap-notifier-sync.ts +41 -0
- package/src/runtime/bootstrap-shell.ts +8 -0
- package/src/runtime/bootstrap.ts +21 -23
- package/src/runtime/context-accounting-source.ts +103 -0
- package/src/runtime/conversation-rewind-port.ts +130 -0
- package/src/runtime/fleet-needs-input-push.ts +70 -0
- package/src/runtime/install-self-check-startup.ts +36 -0
- package/src/runtime/install-self-check.ts +149 -0
- package/src/runtime/onboarding/fast-path.ts +93 -0
- package/src/runtime/process-lifecycle.ts +32 -24
- package/src/runtime/relay-reachability-bridge.ts +53 -0
- package/src/runtime/release-artifacts.ts +79 -0
- package/src/runtime/services.ts +38 -37
- package/src/runtime/trust/workspace-registration.ts +184 -0
- package/src/runtime/trust/workspace-trust.ts +202 -0
- package/src/runtime/ui-services.ts +2 -1
- package/src/runtime/update-check.ts +113 -0
- package/src/runtime/workspace-checkpointing.ts +32 -0
- package/src/runtime/workstream-attempts-validation.ts +77 -0
- package/src/runtime/workstream-draft-edits.ts +95 -0
- package/src/runtime/workstream-draft-store.ts +101 -0
- package/src/runtime/workstream-draft-types.ts +42 -0
- package/src/runtime/workstream-services.ts +70 -39
- package/src/shell/blocking-input.ts +29 -0
- package/src/shell/ui-openers.ts +65 -0
- package/src/verification/verification-ledger.ts +56 -2
- package/src/version.ts +1 -1
- package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +0 -130
- package/src/runtime/render-scheduler.ts +0 -90
|
@@ -13,25 +13,18 @@
|
|
|
13
13
|
|
|
14
14
|
import { readFile } from 'node:fs/promises';
|
|
15
15
|
import type { Line } from '../types/grid.ts';
|
|
16
|
-
import type {
|
|
17
|
-
import { formatWorkItemIsolationDetailFromRaw } from './fleet-panel-worktree-detail.ts';
|
|
16
|
+
import type { SteerResult } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
|
|
18
17
|
import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
|
|
19
18
|
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
20
19
|
import { ScrollableListPanel } from './scrollable-list-panel.ts';
|
|
21
20
|
import { PanelConfirmOverlay } from './panel-confirm-overlay.ts';
|
|
22
|
-
import { formatAgentCost } from './agent-inspector-shared.ts';
|
|
23
|
-
import { formatElapsed } from '../utils/format-elapsed.ts';
|
|
24
|
-
import { truncateDisplay } from '../utils/terminal-width.ts';
|
|
25
21
|
import { isPanelSearchBackspace, isPanelSearchCancel, isPanelSearchCommit, isPanelSearchPrintable } from './search-focus.ts';
|
|
26
|
-
import {
|
|
22
|
+
import { buildKeyboardHints, buildPanelWorkspace, buildSearchInputLine, DEFAULT_PANEL_PALETTE, getPanelWorkspaceContentBudget, type PanelPalette } from './polish.ts';
|
|
27
23
|
import {
|
|
28
|
-
|
|
29
|
-
fleetUsageTokens,
|
|
30
|
-
hasFleetCost,
|
|
24
|
+
isBlockedOnUserState,
|
|
31
25
|
isRunningProcessState,
|
|
32
26
|
isTerminalProcessState,
|
|
33
27
|
type FleetReadModel,
|
|
34
|
-
type FleetStateTone,
|
|
35
28
|
type FleetTreeRow,
|
|
36
29
|
} from './fleet-read-model.ts';
|
|
37
30
|
import {
|
|
@@ -46,12 +39,12 @@ import {
|
|
|
46
39
|
type FleetTabsState,
|
|
47
40
|
type SteerBadge,
|
|
48
41
|
} from './fleet-tabs.ts';
|
|
49
|
-
import { liveSteerableLabels, reconcileSteerBadges as reconcileSteerBadgesPure, renderSteerBadgeLine,
|
|
50
|
-
import { FleetStopTracker,
|
|
42
|
+
import { liveSteerableLabels, reconcileSteerBadges as reconcileSteerBadgesPure, renderSteerBadgeLine, steerRefusalMessage } from './fleet-steer.ts';
|
|
43
|
+
import { FleetStopTracker, toggleFleetPause, buildFleetTreeHints, countDescendantStats } from './fleet-stop.ts';
|
|
51
44
|
import { resolveFleetDeepLinkIndex } from './fleet-deep-link.ts';
|
|
52
45
|
import { parseAgentLedger, renderFleetAgentTranscript, renderFleetChainSummary, renderFleetLedgerFallback, renderFleetTranscriptLoading } from './fleet-transcript.ts';
|
|
53
46
|
import { renderFleetTabStrip } from '../renderer/fleet-tab-strip.ts';
|
|
54
|
-
import {
|
|
47
|
+
import { renderFleetDetailLines, renderFleetRowLine } from './fleet-panel-format.ts';
|
|
55
48
|
|
|
56
49
|
const C = DEFAULT_PANEL_PALETTE;
|
|
57
50
|
|
|
@@ -322,6 +315,44 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
|
|
|
322
315
|
this.clearError(); this.selectedIndex = idx; this.selectedNodeId = target.id; this.markDirty();
|
|
323
316
|
}
|
|
324
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Focus the next node blocked on the operator (awaiting approval/input),
|
|
320
|
+
* cycling top-to-bottom in display order and wrapping. Always resolves
|
|
321
|
+
* against the LIVE fleet (blocked nodes never live in the archive), and
|
|
322
|
+
* returns to the tree view from any attached tab so the reveal is visible —
|
|
323
|
+
* mirrors receiveDeepLink's "make it visible" contract. Honest no-op with a
|
|
324
|
+
* message when nothing is currently waiting on the operator. Returns true
|
|
325
|
+
* (the 'b' key is always consumed by the panel).
|
|
326
|
+
*/
|
|
327
|
+
private jumpToNextBlocked(): boolean {
|
|
328
|
+
const snapshot = this.readModel.getSnapshot();
|
|
329
|
+
const blockedIds = snapshot.blockedNodeIds;
|
|
330
|
+
if (blockedIds.length === 0) {
|
|
331
|
+
this.setError('Nothing is blocked on you right now.');
|
|
332
|
+
this.markDirty();
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
// Pick the blocked id strictly after the current anchor (wrapping), so
|
|
336
|
+
// repeated presses cycle through every waiting node. Anchor on whatever row
|
|
337
|
+
// is actually selected right now (the base class defaults the cursor to
|
|
338
|
+
// row 0 before any explicit navigation sets selectedNodeId), so the first
|
|
339
|
+
// press already advances past a blocked node the cursor happens to sit on.
|
|
340
|
+
const anchorId = this.getSelectedItem()?.node.id ?? this.selectedNodeId;
|
|
341
|
+
const currentPos = anchorId === null || anchorId === undefined ? -1 : blockedIds.indexOf(anchorId);
|
|
342
|
+
const nextId = blockedIds[(currentPos + 1) % blockedIds.length]!;
|
|
343
|
+
// Reveal in the live tree: leave the archive view and any active session tab.
|
|
344
|
+
this.viewMode = 'active';
|
|
345
|
+
this.tabsState = { tabs: this.tabsState.tabs, activeTabIndex: 0 };
|
|
346
|
+
const idx = snapshot.rows.findIndex((row) => row.node.id === nextId);
|
|
347
|
+
if (idx >= 0) {
|
|
348
|
+
this.selectedIndex = idx;
|
|
349
|
+
this.selectedNodeId = nextId;
|
|
350
|
+
}
|
|
351
|
+
this.clearError();
|
|
352
|
+
this.markDirty();
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
|
|
325
356
|
/**
|
|
326
357
|
* The "dropped inference" reconciliation: called on every read-model
|
|
327
358
|
* snapshot update and on the panel's 1s tick. See fleet-steer.ts's
|
|
@@ -376,6 +407,13 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
|
|
|
376
407
|
return true;
|
|
377
408
|
}
|
|
378
409
|
|
|
410
|
+
// Jump-to-blocked works from ANYWHERE in the panel (root tree OR an attached
|
|
411
|
+
// session tab): it steps to the next node waiting on the operator and
|
|
412
|
+
// reveals it in the live tree so the selection is actually visible.
|
|
413
|
+
if (key === 'b') {
|
|
414
|
+
return this.jumpToNextBlocked();
|
|
415
|
+
}
|
|
416
|
+
|
|
379
417
|
if (this.tabsState.activeTabIndex > 0) {
|
|
380
418
|
if (key === 's') {
|
|
381
419
|
return this.tryOpenSteerDraft(activeFleetTab(this.tabsState)!);
|
|
@@ -603,35 +641,11 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
|
|
|
603
641
|
}
|
|
604
642
|
|
|
605
643
|
protected renderItem(row: FleetTreeRow, _index: number, _selected: boolean, width: number): Line {
|
|
606
|
-
|
|
607
|
-
//
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
const disp = fleetStateDisplay(node.state, stopping);
|
|
612
|
-
const color = toneColor(disp.tone, C);
|
|
613
|
-
const label = `${row.treePrefix}${node.label}`;
|
|
614
|
-
const badge = this.steerBadgeForNode(node.id);
|
|
615
|
-
const activity = stopping
|
|
616
|
-
? disp.label
|
|
617
|
-
: badge
|
|
618
|
-
? `${steerBadgeGlyph(badge.status)} ${node.currentActivity?.text ?? ''}`.trimEnd()
|
|
619
|
-
: (node.currentActivity?.text ?? '');
|
|
620
|
-
const activityColor = stopping ? color : badge ? steerBadgeTone(badge.status, C) : C.dim;
|
|
621
|
-
|
|
622
|
-
return buildAlignedRow(
|
|
623
|
-
width,
|
|
624
|
-
[
|
|
625
|
-
{ text: disp.glyph, fg: color },
|
|
626
|
-
{ text: fleetKindTag(node.kind), fg: C.dim },
|
|
627
|
-
{ text: label, fg: C.value },
|
|
628
|
-
{ text: formatElapsed(node.elapsedMs), fg: C.dim },
|
|
629
|
-
{ text: formatFleetTokens(node.usage), fg: C.dim },
|
|
630
|
-
{ text: formatFleetCost(node.costUsd, node.costState), fg: C.value },
|
|
631
|
-
{ text: activity, fg: activityColor },
|
|
632
|
-
],
|
|
633
|
-
planColumns(width),
|
|
634
|
-
);
|
|
644
|
+
// Display-only overrides (stopping wins, then blocked-on-user) are derived
|
|
645
|
+
// here from panel state and passed into the pure row renderer.
|
|
646
|
+
const stopping = this.stopTracker.isStopping(row.node.id);
|
|
647
|
+
const blocked = !stopping && isBlockedOnUserState(row.node.state);
|
|
648
|
+
return renderFleetRowLine(row, width, stopping, blocked, this.steerBadgeForNode(row.node.id), C);
|
|
635
649
|
}
|
|
636
650
|
|
|
637
651
|
/** A node's steer badge, looked up via its attached tab (if any) — null when the node has no tab or no active/recent steer. */
|
|
@@ -641,41 +655,11 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
|
|
|
641
655
|
|
|
642
656
|
private renderDetail(row: FleetTreeRow, width: number): Line[] {
|
|
643
657
|
const node = row.node;
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
const
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
const line1 = buildPanelLine(width, [
|
|
650
|
-
[' ', C.dim],
|
|
651
|
-
[disp.glyph, color],
|
|
652
|
-
[` ${node.kind}`, C.dim],
|
|
653
|
-
[' id ', C.label],
|
|
654
|
-
[node.id, C.value],
|
|
655
|
-
[' state ', C.label],
|
|
656
|
-
[disp.label, color],
|
|
657
|
-
[' elapsed ', C.label],
|
|
658
|
-
[formatElapsed(node.elapsedMs), C.value],
|
|
659
|
-
]);
|
|
660
|
-
const line2 = buildPanelLine(width, [
|
|
661
|
-
[' model ', C.label],
|
|
662
|
-
[node.model ?? 'unknown', C.info],
|
|
663
|
-
[' tokens ', C.label],
|
|
664
|
-
[formatFleetTokens(node.usage), C.value],
|
|
665
|
-
[' cost ', C.label],
|
|
666
|
-
[formatFleetCost(node.costUsd, node.costState), C.value],
|
|
667
|
-
]);
|
|
668
|
-
const activityText = node.currentActivity
|
|
669
|
-
? `${node.currentActivity.kind}: ${node.currentActivity.text}`
|
|
670
|
-
: '(no recent activity)';
|
|
671
|
-
const line3 = buildPanelLine(width, [
|
|
672
|
-
[' activity ', C.label],
|
|
673
|
-
[truncateDisplay(activityText, Math.max(0, width - 11)), C.dim],
|
|
674
|
-
]);
|
|
675
|
-
// Approval history attaches here once session tabs land.
|
|
676
|
-
const line4 = buildPanelLine(width, [[' approvals ', C.label], ['—', C.dim]]);
|
|
677
|
-
const isolationDetail = node.kind === 'work-item' ? formatWorkItemIsolationDetailFromRaw(node.raw) : null;
|
|
678
|
-
return [line1, line2, line3, line4, ...(isolationDetail ? [buildPanelLine(width, [[' isolation ', C.label], [isolationDetail, C.dim]])] : [])];
|
|
658
|
+
// Mirror the tree row's display-only overrides (stopping wins, then blocked)
|
|
659
|
+
// so the detail's literal state text never contradicts the row glyph.
|
|
660
|
+
const stopping = this.stopTracker.isStopping(node.id);
|
|
661
|
+
const blocked = !stopping && isBlockedOnUserState(node.state);
|
|
662
|
+
return renderFleetDetailLines(node, width, stopping, blocked, C);
|
|
679
663
|
}
|
|
680
664
|
|
|
681
665
|
/** Renders the active session tab's content (transcript / chain summary / ledger fallback) in place of the tree. */
|
|
@@ -774,7 +758,10 @@ export class FleetPanel extends ScrollableListPanel<FleetTreeRow> {
|
|
|
774
758
|
// can actually accept them (most kinds are never interruptible; nothing is
|
|
775
759
|
// killable once terminal), incl. the d2 state-dependent p pause/resume chip.
|
|
776
760
|
// Mirrors the handleInput guards above. See buildFleetTreeHints (fleet-stop.ts).
|
|
777
|
-
|
|
761
|
+
// Blocked count is always taken from the LIVE fleet (blocked nodes never
|
|
762
|
+
// live in the archive), so the 'b' jump hint shows even from the archive view.
|
|
763
|
+
const blockedCount = this.readModel.getSnapshot().blockedNodeIds.length;
|
|
764
|
+
const hints = buildFleetTreeHints(selected?.node, this.follow, this.tabsState.tabs.length > 0, this.viewMode, blockedCount);
|
|
778
765
|
|
|
779
766
|
// Tab strip renders only when tabs exist — omitting it entirely with no
|
|
780
767
|
// tabs attached keeps the pre-session-tab root-tree rendering byte-identical.
|
|
@@ -63,6 +63,14 @@ export interface FleetSnapshot {
|
|
|
63
63
|
readonly totalTokens: number | null;
|
|
64
64
|
/** Count of nodes in an actively-working state (see isRunningProcessState). */
|
|
65
65
|
readonly runningCount: number;
|
|
66
|
+
/**
|
|
67
|
+
* Ids of the nodes that are "blocked on me" — waiting on a user approval or
|
|
68
|
+
* user input right now (see isBlockedOnUserState) — in display (row) order.
|
|
69
|
+
* This is a DERIVED view over the same node states the rows already carry,
|
|
70
|
+
* never a second source of truth: the panel reads it to badge/jump, it is
|
|
71
|
+
* not a store. Empty when nothing is waiting on the operator.
|
|
72
|
+
*/
|
|
73
|
+
readonly blockedNodeIds: readonly string[];
|
|
66
74
|
readonly capturedAt: number;
|
|
67
75
|
}
|
|
68
76
|
|
|
@@ -127,6 +135,17 @@ const STATE_TONES: Record<ProcessState, FleetStateTone> = {
|
|
|
127
135
|
/** Terminal states — interrupt/kill are not offered; not counted as running. */
|
|
128
136
|
const TERMINAL_STATES = new Set<ProcessState>(['done', 'failed', 'killed', 'interrupted']);
|
|
129
137
|
|
|
138
|
+
/**
|
|
139
|
+
* States that mean "this node is waiting on ME right now" — a user approval or
|
|
140
|
+
* user input. Today the registry derives exactly one such state,
|
|
141
|
+
* `awaiting-approval` (an agent parked at a permission prompt). This is the
|
|
142
|
+
* single place that mapping lives, so if the SDK adds a distinct
|
|
143
|
+
* awaiting-user-input state later it joins here and every "blocked on me"
|
|
144
|
+
* surface (badge, sort, jump) picks it up for free. Kept as a set for that
|
|
145
|
+
* forward-compatibility, not because more than one state qualifies today.
|
|
146
|
+
*/
|
|
147
|
+
const BLOCKED_ON_USER_STATES = new Set<ProcessState>(['awaiting-approval']);
|
|
148
|
+
|
|
130
149
|
/** States representing actively-working nodes (drives runningCount + follow target). */
|
|
131
150
|
const RUNNING_STATES = new Set<ProcessState>([
|
|
132
151
|
'thinking', 'executing-tool', 'awaiting-approval', 'streaming', 'stalled', 'retrying',
|
|
@@ -171,6 +190,11 @@ export function isRunningProcessState(state: ProcessState): boolean {
|
|
|
171
190
|
return RUNNING_STATES.has(state);
|
|
172
191
|
}
|
|
173
192
|
|
|
193
|
+
/** True when a node is waiting on a user approval or user input right now — "blocked on me". */
|
|
194
|
+
export function isBlockedOnUserState(state: ProcessState): boolean {
|
|
195
|
+
return BLOCKED_ON_USER_STATES.has(state);
|
|
196
|
+
}
|
|
197
|
+
|
|
174
198
|
// ---------------------------------------------------------------------------
|
|
175
199
|
// Honest usage/cost helpers (W0.9 convention, ported from agent-inspector-shared.ts)
|
|
176
200
|
// ---------------------------------------------------------------------------
|
|
@@ -201,10 +225,53 @@ export function hasFleetCost(costUsd: number | null | undefined, costState: Proc
|
|
|
201
225
|
// Tree builder — pure, testable (ported tree-walk shape from process-modal.ts)
|
|
202
226
|
// ---------------------------------------------------------------------------
|
|
203
227
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
228
|
+
/**
|
|
229
|
+
* Sibling comparator. `blockedSubtree` is the set of node ids whose own subtree
|
|
230
|
+
* contains a node blocked on the user (see collectBlockedSubtrees) — those
|
|
231
|
+
* siblings sort to the TOP of their level so a blocked node (and the family
|
|
232
|
+
* that leads to it) floats up the tree, at every depth, without breaking the
|
|
233
|
+
* parent/child structure. Ties fall back to startedAt then id, unchanged.
|
|
234
|
+
*/
|
|
235
|
+
function makeCompareNodes(blockedSubtree: ReadonlySet<string>): (a: ProcessNode, b: ProcessNode) => number {
|
|
236
|
+
return (a, b) => {
|
|
237
|
+
const aBlocked = blockedSubtree.has(a.id) ? 0 : 1;
|
|
238
|
+
const bBlocked = blockedSubtree.has(b.id) ? 0 : 1;
|
|
239
|
+
if (aBlocked !== bBlocked) return aBlocked - bBlocked;
|
|
240
|
+
const delta = (a.startedAt ?? Infinity) - (b.startedAt ?? Infinity);
|
|
241
|
+
if (delta !== 0) return delta;
|
|
242
|
+
return a.id.localeCompare(b.id);
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The set of node ids whose subtree (the node itself or any descendant) holds a
|
|
248
|
+
* node currently blocked on the user. Post-order DFS with a memo + visiting
|
|
249
|
+
* guard so a defensive parentId cycle terminates. Drives the blocked-first
|
|
250
|
+
* sibling ordering; purely derived from the live node states.
|
|
251
|
+
*/
|
|
252
|
+
function collectBlockedSubtrees(
|
|
253
|
+
nodes: readonly ProcessNode[],
|
|
254
|
+
childrenByParent: Map<string, ProcessNode[]>,
|
|
255
|
+
): Set<string> {
|
|
256
|
+
const blocked = new Set<string>();
|
|
257
|
+
const memo = new Map<string, boolean>();
|
|
258
|
+
const visiting = new Set<string>();
|
|
259
|
+
const dfs = (node: ProcessNode): boolean => {
|
|
260
|
+
const cached = memo.get(node.id);
|
|
261
|
+
if (cached !== undefined) return cached;
|
|
262
|
+
if (visiting.has(node.id)) return false; // cycle guard
|
|
263
|
+
visiting.add(node.id);
|
|
264
|
+
let has = isBlockedOnUserState(node.state);
|
|
265
|
+
for (const child of childrenByParent.get(node.id) ?? []) {
|
|
266
|
+
if (dfs(child)) has = true;
|
|
267
|
+
}
|
|
268
|
+
visiting.delete(node.id);
|
|
269
|
+
memo.set(node.id, has);
|
|
270
|
+
if (has) blocked.add(node.id);
|
|
271
|
+
return has;
|
|
272
|
+
};
|
|
273
|
+
for (const node of nodes) dfs(node);
|
|
274
|
+
return blocked;
|
|
208
275
|
}
|
|
209
276
|
|
|
210
277
|
function appendSubtree(
|
|
@@ -215,17 +282,18 @@ function appendSubtree(
|
|
|
215
282
|
ancestorPrefix: string,
|
|
216
283
|
isLast: boolean,
|
|
217
284
|
visited: Set<string>,
|
|
285
|
+
compare: (a: ProcessNode, b: ProcessNode) => number,
|
|
218
286
|
): void {
|
|
219
287
|
if (visited.has(node.id)) return; // cycle guard
|
|
220
288
|
visited.add(node.id);
|
|
221
289
|
|
|
222
|
-
const children = (childrenByParent.get(node.id) ?? []).slice().sort(
|
|
290
|
+
const children = (childrenByParent.get(node.id) ?? []).slice().sort(compare);
|
|
223
291
|
const treePrefix = depth === 0 ? '' : `${ancestorPrefix}${isLast ? '└─ ' : '├─ '}`;
|
|
224
292
|
rows.push({ node, depth, treePrefix, isLastChild: isLast, hasChildren: children.length > 0 });
|
|
225
293
|
|
|
226
294
|
const descendantPrefix = depth === 0 ? '' : `${ancestorPrefix}${isLast ? ' ' : '│ '}`;
|
|
227
295
|
children.forEach((child, index) => {
|
|
228
|
-
appendSubtree(rows, child, childrenByParent, depth + 1, descendantPrefix, index === children.length - 1, visited);
|
|
296
|
+
appendSubtree(rows, child, childrenByParent, depth + 1, descendantPrefix, index === children.length - 1, visited, compare);
|
|
229
297
|
});
|
|
230
298
|
}
|
|
231
299
|
|
|
@@ -254,18 +322,24 @@ export function buildFleetRows(nodes: readonly ProcessNode[]): FleetTreeRow[] {
|
|
|
254
322
|
}
|
|
255
323
|
}
|
|
256
324
|
|
|
325
|
+
// Blocked-on-user nodes (and the families that lead to them) float to the top
|
|
326
|
+
// at every level — see makeCompareNodes / collectBlockedSubtrees. Derived from
|
|
327
|
+
// the live node states on this same pass; never a stored flag.
|
|
328
|
+
const blockedSubtree = collectBlockedSubtrees(nodes, childrenByParent);
|
|
329
|
+
const compare = makeCompareNodes(blockedSubtree);
|
|
330
|
+
|
|
257
331
|
const rows: FleetTreeRow[] = [];
|
|
258
332
|
const visited = new Set<string>();
|
|
259
333
|
|
|
260
|
-
for (const root of roots.slice().sort(
|
|
261
|
-
appendSubtree(rows, root, childrenByParent, 0, '', true, visited);
|
|
334
|
+
for (const root of roots.slice().sort(compare)) {
|
|
335
|
+
appendSubtree(rows, root, childrenByParent, 0, '', true, visited, compare);
|
|
262
336
|
}
|
|
263
337
|
|
|
264
338
|
// Defensive: nodes never reached because their entire ancestor chain forms
|
|
265
339
|
// a cycle with no true root. Walked as pseudo-roots so they still render.
|
|
266
|
-
const leftovers = nodes.filter((n) => !visited.has(n.id)).sort(
|
|
340
|
+
const leftovers = nodes.filter((n) => !visited.has(n.id)).sort(compare);
|
|
267
341
|
for (const node of leftovers) {
|
|
268
|
-
appendSubtree(rows, node, childrenByParent, 0, '', true, visited);
|
|
342
|
+
appendSubtree(rows, node, childrenByParent, 0, '', true, visited, compare);
|
|
269
343
|
}
|
|
270
344
|
|
|
271
345
|
return rows;
|
|
@@ -381,7 +455,13 @@ export function buildFleetSnapshot(nodes: readonly ProcessNode[], capturedAt: nu
|
|
|
381
455
|
if (isFleetRunningLeaf(node)) runningCount++;
|
|
382
456
|
}
|
|
383
457
|
|
|
384
|
-
|
|
458
|
+
// Blocked ids in display (row) order so the panel's jump key steps through
|
|
459
|
+
// them top-to-bottom exactly as they appear on screen.
|
|
460
|
+
const blockedNodeIds = rows
|
|
461
|
+
.filter((row) => isBlockedOnUserState(row.node.state))
|
|
462
|
+
.map((row) => row.node.id);
|
|
463
|
+
|
|
464
|
+
return { rows, totalCost, totalTokens, runningCount, blockedNodeIds, capturedAt };
|
|
385
465
|
}
|
|
386
466
|
|
|
387
467
|
/**
|
package/src/panels/fleet-stop.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { fleetStateGlyph, fleetStateTone, isTerminalProcessState, type FleetStat
|
|
|
14
14
|
export const STOP_SETTLE_MS = 1500;
|
|
15
15
|
/** Display-only glyph for a node with an in-flight stop request. Verified free against STATE_GLYPHS (fleet-read-model.ts) and width-1 like its ⊘/⊟ siblings. */
|
|
16
16
|
export const STOPPING_GLYPH = '⊗';
|
|
17
|
+
/** Display-only glyph for a node blocked on the user (awaiting your approval/input). Verified free against STATE_GLYPHS + STOPPING_GLYPH and width-1. */
|
|
18
|
+
export const BLOCKED_GLYPH = '⚑';
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* Tracks node ids the operator just asked to stop (kill/interrupt/pause) mapped
|
|
@@ -54,10 +56,18 @@ export interface FleetStateDisplay {
|
|
|
54
56
|
readonly tone: FleetStateTone;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Glyph/label/tone for a row, applying two display-only overrides in priority
|
|
61
|
+
* order: an in-flight stop ('stopping…') wins first (a node the operator just
|
|
62
|
+
* asked to stop is no longer meaningfully "blocked on you"), then a
|
|
63
|
+
* blocked-on-user node gets the distinct ⚑ badge + 'blocked on you' label so it
|
|
64
|
+
* reads as needing the operator, not as an anonymous 'awaiting-approval'. Both
|
|
65
|
+
* are display-only; neither is a ProcessState.
|
|
66
|
+
*/
|
|
67
|
+
export function fleetStateDisplay(state: ProcessState, stopping: boolean, blocked = false): FleetStateDisplay {
|
|
68
|
+
if (stopping) return { glyph: STOPPING_GLYPH, label: 'stopping…', tone: 'warn' };
|
|
69
|
+
if (blocked) return { glyph: BLOCKED_GLYPH, label: 'blocked on you', tone: 'warn' };
|
|
70
|
+
return { glyph: fleetStateGlyph(state), label: state, tone: fleetStateTone(state) };
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
/** The narrow action + side-effect surface `toggleFleetPause` needs from FleetPanel. */
|
|
@@ -109,6 +119,7 @@ export function buildFleetTreeHints(
|
|
|
109
119
|
follow: boolean,
|
|
110
120
|
hasTabs: boolean,
|
|
111
121
|
viewMode: 'active' | 'archived' = 'active',
|
|
122
|
+
blockedCount = 0,
|
|
112
123
|
): FleetHint[] {
|
|
113
124
|
const live = selected !== undefined && !isTerminalProcessState(selected.state);
|
|
114
125
|
const isPaused = selected !== undefined && selected.state === 'paused';
|
|
@@ -116,6 +127,10 @@ export function buildFleetTreeHints(
|
|
|
116
127
|
{ keys: 'j/k', label: 'navigate' },
|
|
117
128
|
{ keys: 'Enter', label: 'attach' },
|
|
118
129
|
];
|
|
130
|
+
// Jump-to-blocked is offered whenever something is waiting on the operator,
|
|
131
|
+
// in either view (the blocked nodes live in the active fleet, and the jump
|
|
132
|
+
// switches back to it) — so it is placed before the view-specific branch.
|
|
133
|
+
if (blockedCount > 0) hints.push({ keys: 'b', label: `blocked (${blockedCount})` });
|
|
119
134
|
if (viewMode === 'archived') {
|
|
120
135
|
// Archive view: restore + return to the live fleet; nothing here is live,
|
|
121
136
|
// so the live-only control hints (s/i/K/p) never apply.
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ConfigModalTab,
|
|
7
7
|
ConfigModalView,
|
|
8
8
|
} from '../../input/config-modal-types.ts';
|
|
9
|
+
import { memoryRecordTemporalStatus } from '@pellux/goodvibes-sdk/platform/state';
|
|
9
10
|
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
11
12
|
// Memory → config-modal surface (W6.1 group-B port). Two tabs: 'All Records'
|
|
@@ -44,9 +45,17 @@ interface MemoryRecordLike {
|
|
|
44
45
|
readonly reviewedBy?: string | undefined;
|
|
45
46
|
readonly createdAt: number;
|
|
46
47
|
readonly updatedAt?: number | undefined;
|
|
48
|
+
readonly validFrom?: number | undefined;
|
|
49
|
+
readonly validUntil?: number | undefined;
|
|
47
50
|
readonly provenance: readonly { readonly kind: string; readonly ref: string }[];
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
/** Compact `[pending]`/`[expired]` suffix for a record row; empty for 'active' (no window, or currently inside it). */
|
|
54
|
+
function temporalSuffix(record: Pick<MemoryRecordLike, 'validFrom' | 'validUntil'>): string {
|
|
55
|
+
const status = memoryRecordTemporalStatus(record);
|
|
56
|
+
return status === 'active' ? '' : ` [${status}]`;
|
|
57
|
+
}
|
|
58
|
+
|
|
50
59
|
export interface MemoryModalDeps {
|
|
51
60
|
readonly memoryRegistry?: {
|
|
52
61
|
honestSearch(filter?: { limit?: number }): Promise<{
|
|
@@ -140,7 +149,7 @@ class MemoryModalSurface implements ConfigModalSurface {
|
|
|
140
149
|
private allTab(): ConfigModalTab {
|
|
141
150
|
const rows: ConfigModalRow[] = this.allRecords.map((record) => ({
|
|
142
151
|
id: record.id,
|
|
143
|
-
label: `[${record.scope.slice(0, 1).toUpperCase()}/${record.cls.slice(0, 3).toUpperCase()}] ${record.id.slice(-8)} ${fmtTime(record.createdAt)} ${record.summary}${record.tags.length > 0 ? ` #${record.tags.join(' #')}` : ''}`,
|
|
152
|
+
label: `[${record.scope.slice(0, 1).toUpperCase()}/${record.cls.slice(0, 3).toUpperCase()}] ${record.id.slice(-8)} ${fmtTime(record.createdAt)} ${record.summary}${record.tags.length > 0 ? ` #${record.tags.join(' #')}` : ''}${temporalSuffix(record)}`,
|
|
144
153
|
}));
|
|
145
154
|
return {
|
|
146
155
|
id: 'all',
|
|
@@ -155,7 +164,7 @@ class MemoryModalSurface implements ConfigModalSurface {
|
|
|
155
164
|
private reviewTab(): ConfigModalTab {
|
|
156
165
|
const rows: ConfigModalRow[] = this.reviewRecords.map((record) => ({
|
|
157
166
|
id: record.id,
|
|
158
|
-
label: `${record.reviewState.padEnd(13)} ${String(record.confidence).padStart(3)}% ${record.summary}${record.staleReason ? ` (stale: ${record.staleReason})` : ''}`,
|
|
167
|
+
label: `${record.reviewState.padEnd(13)} ${String(record.confidence).padStart(3)}% ${record.summary}${record.staleReason ? ` (stale: ${record.staleReason})` : ''}${temporalSuffix(record)}`,
|
|
159
168
|
}));
|
|
160
169
|
return {
|
|
161
170
|
id: 'review',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PermissionPromptRequest } from '@pellux/goodvibes-sdk/platform/permissions';
|
|
1
|
+
import type { PermissionAttribution, PermissionPromptRequest } from '@pellux/goodvibes-sdk/platform/permissions';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Per-hunk accept/reject at the approval gate (W1.3 v1).
|
|
@@ -158,22 +158,103 @@ export function buildModifiedEditArgs(
|
|
|
158
158
|
return { ...request.args, edits: filtered };
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/** Narrow approval-broker surface needed to attribute a prompt to its requester. */
|
|
162
|
+
export interface ApprovalRequesterLookup {
|
|
163
|
+
listApprovals(limit?: number): ReadonlyArray<{
|
|
164
|
+
readonly callId: string;
|
|
165
|
+
readonly sessionId?: string | undefined;
|
|
166
|
+
readonly metadata: Record<string, unknown>;
|
|
167
|
+
}>;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Human label for a `mcp-server` or `sandbox-escalation` attribution — the two
|
|
172
|
+
* non-agent origins the approval broker can name (see PermissionAttribution in
|
|
173
|
+
* the SDK). `background-agent` deliberately falls through to the
|
|
174
|
+
* broker-metadata lookup below instead, which resolves a richer label
|
|
175
|
+
* (agentId/session) than the attribution alone carries.
|
|
176
|
+
*/
|
|
177
|
+
function describeNonAgentAttribution(attribution: PermissionAttribution): string | null {
|
|
178
|
+
switch (attribution.kind) {
|
|
179
|
+
case 'mcp-server':
|
|
180
|
+
return `MCP server: ${attribution.serverName}`;
|
|
181
|
+
case 'sandbox-escalation':
|
|
182
|
+
return `${attribution.sandbox} wants: ${attribution.escalations.join(', ')}`;
|
|
183
|
+
default:
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Human label for WHICH agent/process/server/sandbox raised this approval.
|
|
190
|
+
*
|
|
191
|
+
* Prefers the request's own typed `attribution` (see PermissionAttribution)
|
|
192
|
+
* for the `mcp-server` and `sandbox-escalation` origins, since those carry
|
|
193
|
+
* structured data (which server, which escalations) no broker-metadata scan
|
|
194
|
+
* can reconstruct. For `background-agent` (and when no attribution is
|
|
195
|
+
* present), falls back to the approval broker by callId (the broker record
|
|
196
|
+
* for a request exists by the time its local prompt is shown), preferring an
|
|
197
|
+
* explicit requester recorded in the broker record's metadata (agentId /
|
|
198
|
+
* agent / requestedBy / actor / actorSurface), then the owning session id.
|
|
199
|
+
* Returns null when nothing identifying is available — the prompt then omits
|
|
200
|
+
* the attribution line rather than guessing a name.
|
|
201
|
+
*/
|
|
202
|
+
export function resolveApprovalRequester(
|
|
203
|
+
broker: ApprovalRequesterLookup | null | undefined,
|
|
204
|
+
callId: string,
|
|
205
|
+
attribution?: PermissionAttribution | undefined,
|
|
206
|
+
): string | null {
|
|
207
|
+
if (attribution && attribution.kind !== 'background-agent') {
|
|
208
|
+
const described = describeNonAgentAttribution(attribution);
|
|
209
|
+
if (described) return described;
|
|
210
|
+
}
|
|
211
|
+
if (!broker) return null;
|
|
212
|
+
let record: { readonly sessionId?: string | undefined; readonly metadata: Record<string, unknown> } | undefined;
|
|
213
|
+
try {
|
|
214
|
+
record = broker.listApprovals().find((r) => r.callId === callId);
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
if (!record) return null;
|
|
219
|
+
const meta = record.metadata ?? {};
|
|
220
|
+
for (const key of ['agentId', 'agent', 'requestedBy', 'actor', 'actorSurface'] as const) {
|
|
221
|
+
const value = meta[key];
|
|
222
|
+
if (typeof value === 'string' && value.trim().length > 0) return value.trim();
|
|
223
|
+
}
|
|
224
|
+
if (typeof record.sessionId === 'string' && record.sessionId.length > 0) {
|
|
225
|
+
return `session ${record.sessionId.slice(0, 8)}`;
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
161
230
|
/**
|
|
162
231
|
* buildPendingPermissionExtras — the `hunkState` + wrapped `resolve` fields
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* architecture cap and must
|
|
232
|
+
* (plus the input-debounce timestamp and requester attribution) main.ts spreads
|
|
233
|
+
* onto its PendingPermissionState alongside `...request`. Bundled into one
|
|
234
|
+
* helper (rather than inlined in main.ts) to keep main.ts's net line count at
|
|
235
|
+
* zero for this change — main.ts sits at the 800-line architecture cap and must
|
|
236
|
+
* never grow it.
|
|
237
|
+
*
|
|
238
|
+
* `openedAt` is the moment the prompt appeared; the blocking-input handler uses
|
|
239
|
+
* it to swallow typed-ahead keystrokes for a short settle window so buffered
|
|
240
|
+
* text cannot answer a prompt the user has not seen yet. `requestedBy` is the
|
|
241
|
+
* requester attribution resolved from the broker (see resolveApprovalRequester).
|
|
167
242
|
*/
|
|
168
243
|
export function buildPendingPermissionExtras(
|
|
169
244
|
request: PermissionPromptRequest,
|
|
170
245
|
resolvePromise: (decision: PermissionPromptDecisionWithModifiedArgs) => void,
|
|
246
|
+
broker?: ApprovalRequesterLookup | null,
|
|
247
|
+
now: number = Date.now(),
|
|
171
248
|
): {
|
|
172
249
|
hunkState: HunkSelectionState | undefined;
|
|
173
250
|
resolve: (approved: boolean, remember?: boolean, modifiedArgs?: Record<string, unknown>) => void;
|
|
251
|
+
openedAt: number;
|
|
252
|
+
requestedBy: string | undefined;
|
|
174
253
|
} {
|
|
175
254
|
return {
|
|
176
255
|
hunkState: isHunkSelectable(request) ? buildHunkSelectionState(request) : undefined,
|
|
177
256
|
resolve: (approved, remember = false, modifiedArgs) => resolvePromise({ approved, remember, modifiedArgs }),
|
|
257
|
+
openedAt: now,
|
|
258
|
+
requestedBy: resolveApprovalRequester(broker, request.callId, request.attribution) ?? undefined,
|
|
178
259
|
};
|
|
179
260
|
}
|