@pellux/goodvibes-tui 1.13.1 → 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.
Files changed (158) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +6 -6
  3. package/bin/goodvibes +6 -0
  4. package/bin/goodvibes-daemon +6 -0
  5. package/bin/launcher-support.js +34 -1
  6. package/docs/foundation-artifacts/operator-contract.json +8076 -2047
  7. package/package.json +15 -11
  8. package/scripts/postinstall.js +73 -48
  9. package/src/cli/bundle-command.ts +5 -5
  10. package/src/cli/completion.ts +1 -1
  11. package/src/cli/completions/generate.ts +15 -3
  12. package/src/cli/doctor.ts +385 -0
  13. package/src/cli/entrypoint.ts +72 -1
  14. package/src/cli/help.ts +29 -9
  15. package/src/cli/hooks-command.ts +74 -0
  16. package/src/cli/hooks-report.ts +167 -0
  17. package/src/cli/index.ts +3 -0
  18. package/src/cli/management.ts +13 -1
  19. package/src/cli/parser.ts +10 -2
  20. package/src/cli/plugin-bundles-command.ts +228 -0
  21. package/src/cli/plugin-command.ts +179 -0
  22. package/src/cli/service-posture.ts +10 -0
  23. package/src/cli/status.ts +275 -2
  24. package/src/cli/tui-startup.ts +119 -1
  25. package/src/cli/types.ts +3 -1
  26. package/src/config/index.ts +16 -0
  27. package/src/config/tui-extension-settings.ts +169 -0
  28. package/src/core/approval-alert.ts +15 -2
  29. package/src/core/compaction-receipt.ts +127 -0
  30. package/src/core/model-routing-chip.ts +82 -0
  31. package/src/core/permission-mode.ts +89 -0
  32. package/src/core/rewind-receipt.ts +83 -0
  33. package/src/core/rewind-turn-anchors.ts +160 -0
  34. package/src/core/scriptable-statusline.ts +141 -0
  35. package/src/core/session-auto-titler.ts +110 -0
  36. package/src/core/stream-event-wiring.ts +20 -0
  37. package/src/core/system-message-router.ts +1 -1
  38. package/src/core/terminal-notifier.ts +102 -0
  39. package/src/core/turn-event-wiring.ts +80 -1
  40. package/src/input/command-reference.ts +96 -0
  41. package/src/input/command-registry.ts +25 -0
  42. package/src/input/commands/channel-pairing.ts +245 -0
  43. package/src/input/commands/channel-runtime.ts +152 -6
  44. package/src/input/commands/checkin-runtime.ts +89 -0
  45. package/src/input/commands/checkpoint-runtime.ts +7 -179
  46. package/src/input/commands/ci-runtime.ts +207 -0
  47. package/src/input/commands/cost-attribution-format.ts +52 -0
  48. package/src/input/commands/cost-runtime.ts +44 -4
  49. package/src/input/commands/editor-runtime.ts +22 -0
  50. package/src/input/commands/flags-runtime.ts +328 -0
  51. package/src/input/commands/health-metrics-format.ts +67 -0
  52. package/src/input/commands/health-runtime.ts +55 -1
  53. package/src/input/commands/memory.ts +23 -0
  54. package/src/input/commands/operator-rpc.ts +95 -0
  55. package/src/input/commands/palette-runtime.ts +22 -0
  56. package/src/input/commands/permissions-provenance.ts +264 -0
  57. package/src/input/commands/permissions-runtime.ts +21 -0
  58. package/src/input/commands/planning-runtime.ts +28 -1
  59. package/src/input/commands/platform-access-runtime.ts +2 -97
  60. package/src/input/commands/principals-runtime.ts +212 -0
  61. package/src/input/commands/product-runtime.ts +43 -3
  62. package/src/input/commands/recall-files-config.ts +54 -0
  63. package/src/input/commands/recall-files-git.ts +57 -0
  64. package/src/input/commands/recall-files.ts +185 -0
  65. package/src/input/commands/recall-query.ts +8 -3
  66. package/src/input/commands/recall-review.ts +2 -2
  67. package/src/input/commands/recall-shared.ts +13 -0
  68. package/src/input/commands/relay-runtime.ts +86 -0
  69. package/src/input/commands/review-runtime.ts +160 -0
  70. package/src/input/commands/rewind-runtime.ts +362 -0
  71. package/src/input/commands/schedule-nl.ts +147 -0
  72. package/src/input/commands/schedule-runtime.ts +29 -10
  73. package/src/input/commands/secret-runtime.ts +55 -0
  74. package/src/input/commands/session-content.ts +19 -8
  75. package/src/input/commands/session-workflow.ts +10 -0
  76. package/src/input/commands/update-runtime.ts +363 -0
  77. package/src/input/commands/workstream-attempt-dependents.ts +103 -0
  78. package/src/input/commands/workstream-attempts.ts +244 -0
  79. package/src/input/commands/workstream-runtime.ts +164 -26
  80. package/src/input/commands/worktree-runtime.ts +56 -2
  81. package/src/input/commands.ts +135 -80
  82. package/src/input/composer-capture.ts +67 -0
  83. package/src/input/composer-editor.ts +134 -0
  84. package/src/input/concealed-input.ts +94 -0
  85. package/src/input/exec-env-scrub-config.ts +84 -0
  86. package/src/input/feature-unit-layout.ts +142 -0
  87. package/src/input/feed-context-factory.ts +2 -0
  88. package/src/input/handler-feed-routes.ts +17 -0
  89. package/src/input/handler-feed.ts +3 -0
  90. package/src/input/handler-modal-routes.ts +5 -2
  91. package/src/input/handler-shortcuts.ts +37 -0
  92. package/src/input/handler.ts +24 -2
  93. package/src/input/keybindings.ts +11 -2
  94. package/src/input/onboarding/onboarding-feature-units.ts +536 -0
  95. package/src/input/onboarding/onboarding-wizard-apply.ts +40 -6
  96. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +324 -629
  97. package/src/input/onboarding/onboarding-wizard-steps.ts +5 -5
  98. package/src/input/onboarding/onboarding-wizard-types.ts +6 -0
  99. package/src/input/relay-settings-descriptions.ts +35 -0
  100. package/src/input/sandbox-exec-config.ts +107 -0
  101. package/src/input/settings-modal-adjustment.ts +4 -7
  102. package/src/input/settings-modal-data.ts +178 -8
  103. package/src/input/settings-modal-mutations.ts +41 -13
  104. package/src/input/settings-modal-types.ts +42 -5
  105. package/src/input/settings-modal.ts +67 -15
  106. package/src/input/submission-router.ts +4 -1
  107. package/src/input/worktree-setup-config.ts +93 -0
  108. package/src/main.ts +43 -43
  109. package/src/panels/builtin/development.ts +14 -0
  110. package/src/panels/diff-review-model.ts +189 -0
  111. package/src/panels/diff-review-panel.ts +395 -0
  112. package/src/panels/fleet-panel-format.ts +114 -2
  113. package/src/panels/fleet-panel.ts +65 -78
  114. package/src/panels/fleet-read-model.ts +91 -11
  115. package/src/panels/fleet-stop.ts +19 -4
  116. package/src/panels/modals/memory-modal.ts +11 -2
  117. package/src/permissions/hunk-selection.ts +86 -5
  118. package/src/permissions/prompt.ts +108 -7
  119. package/src/permissions/sandbox-exec-gate.ts +162 -0
  120. package/src/renderer/settings-modal-helpers.ts +21 -1
  121. package/src/renderer/settings-modal.ts +70 -44
  122. package/src/renderer/shell-surface.ts +23 -1
  123. package/src/renderer/system-message.ts +4 -0
  124. package/src/renderer/tool-result-summary.ts +95 -5
  125. package/src/renderer/ui-factory.ts +22 -0
  126. package/src/runtime/bootstrap-command-context.ts +15 -1
  127. package/src/runtime/bootstrap-command-parts.ts +14 -2
  128. package/src/runtime/bootstrap-core.ts +38 -38
  129. package/src/runtime/bootstrap-notifier-sync.ts +41 -0
  130. package/src/runtime/bootstrap-shell.ts +8 -0
  131. package/src/runtime/bootstrap.ts +21 -23
  132. package/src/runtime/context-accounting-source.ts +103 -0
  133. package/src/runtime/conversation-rewind-port.ts +130 -0
  134. package/src/runtime/fleet-needs-input-push.ts +70 -0
  135. package/src/runtime/install-self-check-startup.ts +36 -0
  136. package/src/runtime/install-self-check.ts +149 -0
  137. package/src/runtime/onboarding/fast-path.ts +93 -0
  138. package/src/runtime/process-lifecycle.ts +32 -24
  139. package/src/runtime/relay-reachability-bridge.ts +53 -0
  140. package/src/runtime/release-artifacts.ts +79 -0
  141. package/src/runtime/services.ts +33 -33
  142. package/src/runtime/trust/workspace-registration.ts +184 -0
  143. package/src/runtime/trust/workspace-trust.ts +202 -0
  144. package/src/runtime/ui-services.ts +2 -1
  145. package/src/runtime/update-check.ts +113 -0
  146. package/src/runtime/workspace-checkpointing.ts +32 -0
  147. package/src/runtime/workstream-attempts-validation.ts +77 -0
  148. package/src/runtime/workstream-draft-edits.ts +95 -0
  149. package/src/runtime/workstream-draft-store.ts +101 -0
  150. package/src/runtime/workstream-draft-types.ts +42 -0
  151. package/src/runtime/workstream-services.ts +70 -39
  152. package/src/shell/blocking-input.ts +29 -0
  153. package/src/shell/ui-openers.ts +65 -0
  154. package/src/verification/verification-ledger.ts +56 -2
  155. package/src/version.ts +1 -1
  156. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +0 -130
  157. package/src/runtime/gateway-verbs.ts +0 -47
  158. 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 { ProcessCostState, ProcessUsage, SteerResult } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
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 { buildAlignedRow, buildKeyboardHints, buildPanelLine, buildPanelWorkspace, buildSearchInputLine, DEFAULT_PANEL_PALETTE, getPanelWorkspaceContentBudget, type ColumnSpec, type PanelPalette } from './polish.ts';
22
+ import { buildKeyboardHints, buildPanelWorkspace, buildSearchInputLine, DEFAULT_PANEL_PALETTE, getPanelWorkspaceContentBudget, type PanelPalette } from './polish.ts';
27
23
  import {
28
- fleetKindTag,
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, steerBadgeGlyph, steerBadgeTone, steerRefusalMessage } from './fleet-steer.ts';
50
- import { FleetStopTracker, fleetStateDisplay, toggleFleetPause, buildFleetTreeHints, countDescendantStats } from './fleet-stop.ts';
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 { planColumns, toneColor, formatFleetTokens, formatFleetCost } from './fleet-panel-format.ts';
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
- const node = row.node;
607
- // d1: while a stop is in flight, fleetStateDisplay overrides the state
608
- // glyph/label/tone with a display-only 'stopping…' so the row never claims
609
- // the past-tense outcome before the state has actually flipped.
610
- const stopping = this.stopTracker.isStopping(node.id);
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
- // d1: mirror the tree row's 'stopping…' override so the literal state text
645
- // never claims 'killed' mid-write either.
646
- const disp = fleetStateDisplay(node.state, this.stopTracker.isStopping(node.id));
647
- const color = toneColor(disp.tone, C);
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
- const hints = buildFleetTreeHints(selected?.node, this.follow, this.tabsState.tabs.length > 0, this.viewMode);
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
- function compareNodes(a: ProcessNode, b: ProcessNode): number {
205
- const delta = (a.startedAt ?? Infinity) - (b.startedAt ?? Infinity);
206
- if (delta !== 0) return delta;
207
- return a.id.localeCompare(b.id);
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(compareNodes);
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(compareNodes)) {
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(compareNodes);
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
- return { rows, totalCost, totalTokens, runningCount, capturedAt };
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
  /**
@@ -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
- export function fleetStateDisplay(state: ProcessState, stopping: boolean): FleetStateDisplay {
58
- return stopping
59
- ? { glyph: STOPPING_GLYPH, label: 'stopping…', tone: 'warn' }
60
- : { glyph: fleetStateGlyph(state), label: state, tone: fleetStateTone(state) };
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
- * main.ts spreads onto its PendingPermissionState alongside `...request`.
164
- * Bundled into one helper (rather than inlined in main.ts) to keep main.ts's
165
- * net line count at zero for this change — main.ts sits at the 800-line
166
- * architecture cap and must never grow it.
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
  }