@pellux/goodvibes-tui 1.7.0 → 1.9.1
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 +11 -0
- package/README.md +10 -9
- package/docs/foundation-artifacts/operator-contract.json +4045 -1763
- package/package.json +2 -2
- package/src/audio/spoken-turn-controller.ts +12 -2
- package/src/audio/spoken-turn-wiring.ts +2 -1
- package/src/cli/help.ts +8 -1
- package/src/cli/service-posture.ts +2 -1
- package/src/cli/status.ts +2 -1
- package/src/cli/surface-command.ts +2 -2
- package/src/config/credential-availability.ts +122 -0
- package/src/config/index.ts +15 -0
- package/src/core/composer-state.ts +11 -3
- package/src/core/conversation-line-cache.ts +27 -3
- package/src/core/conversation-rendering.ts +71 -14
- package/src/core/stream-event-wiring.ts +20 -1
- package/src/core/system-message-noise.ts +87 -0
- package/src/core/system-message-router.ts +68 -1
- package/src/core/turn-cancellation.ts +7 -2
- package/src/core/turn-event-wiring.ts +10 -2
- package/src/daemon/cli.ts +29 -2
- package/src/daemon/handlers/register.ts +8 -1
- package/src/daemon/service-commands.ts +329 -0
- package/src/input/autocomplete.ts +27 -1
- package/src/input/command-registry.ts +46 -4
- package/src/input/commands/codebase-runtime.ts +46 -6
- package/src/input/commands/config.ts +43 -3
- package/src/input/commands/health-runtime.ts +9 -1
- package/src/input/commands/memory.ts +68 -35
- package/src/input/commands/operator-panel-runtime.ts +31 -7
- package/src/input/commands/planning-runtime.ts +95 -6
- package/src/input/commands/qrcode-runtime.ts +25 -5
- package/src/input/commands/remote-runtime-setup.ts +5 -3
- package/src/input/commands/session-content.ts +20 -9
- package/src/input/commands/settings-sync-runtime.ts +15 -3
- package/src/input/commands/shell-core.ts +10 -1
- package/src/input/commands/workstream-runtime.ts +168 -18
- package/src/input/config-modal-types.ts +15 -1
- package/src/input/config-modal.ts +227 -12
- package/src/input/feed-context-factory.ts +3 -0
- package/src/input/handler-command-route.ts +10 -3
- package/src/input/handler-content-actions.ts +17 -2
- package/src/input/handler-feed-routes.ts +43 -121
- package/src/input/handler-feed.ts +32 -4
- package/src/input/handler-modal-routes.ts +78 -13
- package/src/input/handler-modal-stack.ts +11 -2
- package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
- package/src/input/handler-onboarding.ts +71 -59
- package/src/input/handler-picker-routes.ts +15 -8
- package/src/input/handler-shortcuts.ts +59 -9
- package/src/input/handler.ts +6 -1
- package/src/input/keybindings.ts +6 -5
- package/src/input/model-picker.ts +19 -2
- package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
- package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
- package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
- package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
- package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
- package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
- package/src/input/panel-mouse-geometry.ts +97 -0
- package/src/input/panel-paste-flood-guard.ts +86 -0
- package/src/input/selection-modal.ts +11 -0
- package/src/input/session-picker-modal.ts +44 -4
- package/src/input/settings-modal-data.ts +51 -2
- package/src/input/settings-modal-types.ts +4 -2
- package/src/main.ts +28 -27
- package/src/panels/builtin/shared.ts +9 -3
- package/src/panels/fleet-deep-link.ts +31 -0
- package/src/panels/fleet-panel-format.ts +62 -0
- package/src/panels/fleet-panel-worktree-detail.ts +48 -0
- package/src/panels/fleet-panel.ts +89 -131
- package/src/panels/fleet-read-model.ts +43 -0
- package/src/panels/fleet-steer.ts +35 -2
- package/src/panels/fleet-stop.ts +29 -1
- package/src/panels/modals/keybindings-modal.ts +16 -1
- package/src/panels/modals/modal-theme.ts +35 -29
- package/src/panels/modals/pairing-modal.ts +25 -6
- package/src/panels/modals/planning-modal.ts +43 -21
- package/src/panels/modals/work-plan-modal.ts +28 -0
- package/src/panels/panel-manager.ts +15 -4
- package/src/panels/polish-core.ts +38 -25
- package/src/panels/project-planning-answer-actions.ts +8 -13
- package/src/panels/types.ts +24 -0
- package/src/permissions/prompt.ts +160 -13
- package/src/renderer/autocomplete-overlay.ts +28 -2
- package/src/renderer/compositor.ts +2 -3
- package/src/renderer/config-modal.ts +19 -5
- package/src/renderer/footer-tips.ts +5 -1
- package/src/renderer/fullscreen-primitives.ts +32 -22
- package/src/renderer/git-status.ts +3 -1
- package/src/renderer/layout.ts +0 -4
- package/src/renderer/markdown.ts +7 -3
- package/src/renderer/modal-factory.ts +25 -20
- package/src/renderer/model-workspace.ts +18 -3
- package/src/renderer/overlay-box.ts +21 -17
- package/src/renderer/process-indicator.ts +14 -3
- package/src/renderer/selection-modal-overlay.ts +6 -1
- package/src/renderer/session-picker-modal.ts +196 -3
- package/src/renderer/settings-modal-helpers.ts +2 -0
- package/src/renderer/settings-modal.ts +7 -0
- package/src/renderer/shell-surface.ts +8 -1
- package/src/renderer/status-glyphs.ts +14 -15
- package/src/renderer/system-message.ts +15 -3
- package/src/renderer/terminal-bg-probe.ts +339 -0
- package/src/renderer/terminal-escapes.ts +20 -0
- package/src/renderer/theme-mode-config.ts +67 -0
- package/src/renderer/theme.ts +91 -1
- package/src/renderer/thinking.ts +11 -3
- package/src/renderer/tool-call.ts +15 -9
- package/src/renderer/tool-result-summary.ts +148 -0
- package/src/renderer/turn-injection.ts +22 -3
- package/src/renderer/ui-factory.ts +154 -85
- package/src/renderer/ui-primitives.ts +30 -129
- package/src/runtime/bootstrap-command-context.ts +6 -0
- package/src/runtime/bootstrap-command-parts.ts +8 -4
- package/src/runtime/bootstrap-core.ts +46 -24
- package/src/runtime/bootstrap-hook-bridge.ts +7 -0
- package/src/runtime/bootstrap-shell.ts +19 -1
- package/src/runtime/bootstrap.ts +118 -5
- package/src/runtime/code-index-services.ts +25 -2
- package/src/runtime/legacy-daemon-migration.ts +516 -0
- package/src/runtime/memory-fold.ts +55 -0
- package/src/runtime/onboarding/derivation.ts +7 -2
- package/src/runtime/onboarding/snapshot.ts +27 -1
- package/src/runtime/onboarding/types.ts +31 -1
- package/src/runtime/operator-token-cleanup.ts +82 -1
- package/src/runtime/orchestrator-core-services.ts +10 -0
- package/src/runtime/resume-notice.ts +209 -0
- package/src/runtime/services.ts +12 -8
- package/src/runtime/session-inbound-inputs.ts +252 -0
- package/src/runtime/session-spine-transport.ts +64 -0
- package/src/runtime/terminal-output-guard.ts +15 -8
- package/src/runtime/ui-services.ts +19 -3
- package/src/runtime/workstream-services.ts +160 -28
- package/src/runtime/wrfc-persistence.ts +124 -17
- package/src/shell/blocking-input.ts +46 -3
- package/src/shell/recovery-input-helpers.ts +170 -1
- package/src/shell/ui-openers.ts +42 -9
- package/src/utils/terminal-width.ts +52 -0
- package/src/version.ts +1 -1
|
@@ -31,11 +31,22 @@
|
|
|
31
31
|
import {
|
|
32
32
|
createOrchestrationEngine,
|
|
33
33
|
fromChainSpec,
|
|
34
|
+
fromPlanProposal,
|
|
34
35
|
type CreateWorkstreamInput,
|
|
35
36
|
type OrchestrationEngine,
|
|
37
|
+
type WorkstreamIsolation,
|
|
36
38
|
} from '@pellux/goodvibes-sdk/platform/orchestration';
|
|
37
39
|
export type { OrchestrationEngine } from '@pellux/goodvibes-sdk/platform/orchestration';
|
|
38
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
AdaptivePlanner,
|
|
42
|
+
decomposeGoal,
|
|
43
|
+
type DecompositionGate,
|
|
44
|
+
type DecompositionServiceConfig,
|
|
45
|
+
type DecomposeGoalResult,
|
|
46
|
+
type PlannerInputs,
|
|
47
|
+
type PlanProposal,
|
|
48
|
+
} from '@pellux/goodvibes-sdk/platform/core';
|
|
49
|
+
import { createAgentManagerDecompositionRunner } from '@pellux/goodvibes-sdk/platform/agents';
|
|
39
50
|
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
40
51
|
import type { AgentManager } from '@pellux/goodvibes-sdk/platform/tools';
|
|
41
52
|
import type { RuntimeEventBus } from '@/runtime/index.ts';
|
|
@@ -49,12 +60,31 @@ export interface WorkstreamServicesDeps {
|
|
|
49
60
|
readonly projectRoot: string;
|
|
50
61
|
}
|
|
51
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Honest provenance for how a draft's decomposition was produced. Derived from
|
|
65
|
+
* the SDK decomposition service's outcome so the draft render can state plainly
|
|
66
|
+
* whether a planning agent decomposed the goal, or the heuristic path did (and
|
|
67
|
+
* if so, why).
|
|
68
|
+
*/
|
|
69
|
+
export interface WorkstreamDraftProvenance {
|
|
70
|
+
readonly kind: 'agent' | 'heuristic-configured' | 'gate-declined' | 'fallback';
|
|
71
|
+
readonly itemCount: number;
|
|
72
|
+
readonly agentCostUsd?: number | undefined;
|
|
73
|
+
readonly agentTokens?: number | undefined;
|
|
74
|
+
readonly elapsedMs?: number | undefined;
|
|
75
|
+
readonly fallbackReason?: string | undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
52
78
|
/** A not-yet-launched /workstream proposal. See this file's header doc for why it lives here rather than on the engine. */
|
|
53
79
|
export interface WorkstreamDraft {
|
|
54
80
|
readonly id: string;
|
|
55
81
|
task: string;
|
|
56
82
|
spec: CreateWorkstreamInput;
|
|
57
83
|
readonly gate: DecompositionGate;
|
|
84
|
+
/** The engine-agnostic decomposition proposal (model- or heuristic-produced). */
|
|
85
|
+
proposal: PlanProposal;
|
|
86
|
+
/** How that proposal came to be, for honest rendering. */
|
|
87
|
+
provenance: WorkstreamDraftProvenance;
|
|
58
88
|
approved: boolean;
|
|
59
89
|
readonly createdAt: number;
|
|
60
90
|
}
|
|
@@ -62,11 +92,12 @@ export interface WorkstreamDraft {
|
|
|
62
92
|
/** `ctx.session.workstreamEngine`'s real shape: the live engine plus the draft-proposal bookkeeping the engine itself has no concept of. */
|
|
63
93
|
export interface WorkstreamCommandService {
|
|
64
94
|
readonly engine: OrchestrationEngine;
|
|
65
|
-
|
|
95
|
+
/** Spawn a bounded read-only planning agent to decompose the goal (with automatic heuristic fallback), then hold the draft. Async because the planning agent is real. `isolation` omitted ⇒ the engine's own default ('shared'); see CreateWorkstreamInput.isolation (SDK). */
|
|
96
|
+
proposeDraft(task: string, isolation?: WorkstreamIsolation): Promise<WorkstreamDraft>;
|
|
66
97
|
getDraft(id: string): WorkstreamDraft | undefined;
|
|
67
98
|
listDrafts(): WorkstreamDraft[];
|
|
68
|
-
/** Re-derive a held draft's spec from a new task string. Clears any prior approval — an edit must be re-approved. */
|
|
69
|
-
editDraft(id: string, task: string): WorkstreamDraft | undefined
|
|
99
|
+
/** Re-derive a held draft's spec + decomposition from a new task string. Clears any prior approval — an edit must be re-approved. `isolation` omitted ⇒ keeps the draft's current choice (an edit that only changes the task text must not silently reset isolation back to shared). */
|
|
100
|
+
editDraft(id: string, task: string, isolation?: WorkstreamIsolation): Promise<WorkstreamDraft | undefined>;
|
|
70
101
|
approveDraft(id: string): WorkstreamDraft | undefined;
|
|
71
102
|
removeDraft(id: string): boolean;
|
|
72
103
|
/** Materialize an approved draft into a real, running Workstream (engine.createWorkstream + start), then drop the draft. Null when the draft is missing or not approved. */
|
|
@@ -95,41 +126,130 @@ function buildPlannerInputs(task: string): PlannerInputs {
|
|
|
95
126
|
};
|
|
96
127
|
}
|
|
97
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Read the planner decomposition config (mode + bounds) from the config
|
|
131
|
+
* manager, defensively defaulting anything missing or invalid. Real config
|
|
132
|
+
* always supplies the DEFAULT_CONFIG values; these fallbacks matter only for a
|
|
133
|
+
* partially-stubbed config manager, and guarantee finite positive bounds so a
|
|
134
|
+
* planning-agent poll can never loop forever on a NaN deadline.
|
|
135
|
+
*/
|
|
136
|
+
function readDecompositionConfig(configManager: Pick<ConfigManager, 'get'>): DecompositionServiceConfig {
|
|
137
|
+
const num = (value: unknown, fallback: number): number =>
|
|
138
|
+
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
139
|
+
return {
|
|
140
|
+
mode: configManager.get('planner.decomposition') === 'heuristic' ? 'heuristic' : 'agent',
|
|
141
|
+
bounds: {
|
|
142
|
+
maxTurns: num(configManager.get('planner.maxTurns'), 6),
|
|
143
|
+
tokenCeiling: num(configManager.get('planner.tokenCeiling'), 120_000),
|
|
144
|
+
wallTimeoutMs: num(configManager.get('planner.wallTimeoutMs'), 60_000),
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Honest cost estimator: prices the planning agent's tokens only when the
|
|
150
|
+
* session's default model is one we actually have pricing for; otherwise the
|
|
151
|
+
* render falls back to a raw token count. Never throws. */
|
|
152
|
+
function makeCostEstimator(configManager: Pick<ConfigManager, 'get'>): (usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number | undefined; cacheWriteTokens?: number | undefined }) => number | undefined {
|
|
153
|
+
return (usage) => {
|
|
154
|
+
try {
|
|
155
|
+
const model = configManager.get('provider.model') as unknown as string | undefined;
|
|
156
|
+
if (!model || !isModelPriced(model)) return undefined;
|
|
157
|
+
return calcSessionCost(usage.inputTokens, usage.outputTokens, usage.cacheReadTokens ?? 0, usage.cacheWriteTokens ?? 0, model);
|
|
158
|
+
} catch {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function toProvenance(result: DecomposeGoalResult): WorkstreamDraftProvenance {
|
|
165
|
+
const p = result.proposal;
|
|
166
|
+
const kind: WorkstreamDraftProvenance['kind'] =
|
|
167
|
+
result.outcome.kind === 'agent' ? 'agent'
|
|
168
|
+
: result.outcome.kind === 'heuristic-configured' ? 'heuristic-configured'
|
|
169
|
+
: result.outcome.kind === 'gate-declined' ? 'gate-declined'
|
|
170
|
+
: 'fallback';
|
|
171
|
+
return {
|
|
172
|
+
kind,
|
|
173
|
+
itemCount: p.workItems.length,
|
|
174
|
+
...(p.agentCostUsd !== undefined ? { agentCostUsd: p.agentCostUsd } : {}),
|
|
175
|
+
...(p.agentUsage ? { agentTokens: p.agentUsage.totalTokens } : {}),
|
|
176
|
+
...(p.elapsedMs !== undefined ? { elapsedMs: p.elapsedMs } : {}),
|
|
177
|
+
...(p.fallbackReason ? { fallbackReason: p.fallbackReason } : {}),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
98
181
|
function createWorkstreamCommandService(
|
|
99
182
|
engine: OrchestrationEngine,
|
|
100
183
|
adaptivePlanner: AdaptivePlanner,
|
|
101
184
|
configManager: Pick<ConfigManager, 'get' | 'getCategory'>,
|
|
185
|
+
agentManager: Pick<AgentManager, 'spawn' | 'getStatus' | 'cancel'>,
|
|
186
|
+
projectRoot: string,
|
|
102
187
|
): WorkstreamCommandService {
|
|
103
188
|
const drafts = new Map<string, WorkstreamDraft>();
|
|
104
189
|
|
|
105
190
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
191
|
+
* Run the SDK decomposition service: it spawns a bounded, read-only planning
|
|
192
|
+
* agent (which surfaces in the fleet like any agent — kill/steer reach it,
|
|
193
|
+
* and a kill lands as a 'cancelled' fallback) and validates its structured
|
|
194
|
+
* output, or falls back to the heuristic single-item path on any failure.
|
|
195
|
+
* The returned proposal is engine-agnostic; the launchable `spec` is still
|
|
196
|
+
* derived by `buildSpec` (see below).
|
|
197
|
+
*/
|
|
198
|
+
async function decompose(task: string): Promise<DecomposeGoalResult> {
|
|
199
|
+
const runner = createAgentManagerDecompositionRunner({ agentManager });
|
|
200
|
+
return decomposeGoal(
|
|
201
|
+
{ goal: task, workingDir: projectRoot, constraints: {} },
|
|
202
|
+
adaptivePlanner,
|
|
203
|
+
buildPlannerInputs(task),
|
|
204
|
+
readDecompositionConfig(configManager),
|
|
205
|
+
runner,
|
|
206
|
+
{ estimateCostUsd: makeCostEstimator(configManager) },
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Derive the launchable CreateWorkstreamInput from the decomposition
|
|
212
|
+
* proposal (BIG-3 — the final stage of the WRFC→orchestration migration).
|
|
213
|
+
*
|
|
214
|
+
* THE BOUNDARY, stated honestly (and in the /workstream help + draft render):
|
|
215
|
+
* - A genuinely MULTI-ITEM proposal (the planning agent decomposed the goal
|
|
216
|
+
* into >1 work item) is assembled by the SDK's fromPlanProposal into the
|
|
217
|
+
* REAL multi-item workstream: one engineer→review-phased item per proposal
|
|
218
|
+
* item, inter-item dependencies preserved as scheduling constraints, and
|
|
219
|
+
* workstream-level provenance carried. This is the plan the engine runs —
|
|
220
|
+
* no flattening.
|
|
221
|
+
* - A SINGLE-ITEM proposal (the heuristic single-item path, a gate-decline,
|
|
222
|
+
* or an agent that honestly returned one item) keeps the fromChainSpec
|
|
223
|
+
* COMPAT path: byte-for-byte the same engineer→review chain
|
|
224
|
+
* WrfcController.createChain would start. A single item carries no
|
|
225
|
+
* dependencies and no multi-item structure, so the proposal mapping would
|
|
226
|
+
* add nothing — the compat path is the honest, unchanged choice.
|
|
227
|
+
*
|
|
228
|
+
* Earlier waves always took the compat path because no planning-agent-spawn
|
|
229
|
+
* pipeline existed to produce a real multi-item decomposition; BIG-2 landed
|
|
230
|
+
* that pipeline, so `result.proposal` can now be a real multi-item plan and
|
|
231
|
+
* this maps it faithfully. The rendered draft shows THIS spec, so the
|
|
232
|
+
* proposal preview and the launch are always the same plan.
|
|
119
233
|
*/
|
|
120
|
-
function buildSpec(task: string): CreateWorkstreamInput {
|
|
234
|
+
function buildSpec(task: string, proposal: PlanProposal): CreateWorkstreamInput {
|
|
235
|
+
if (proposal.workItems.length > 1) {
|
|
236
|
+
return fromPlanProposal(proposal, configManager);
|
|
237
|
+
}
|
|
121
238
|
return fromChainSpec({ id: `item-${crypto.randomUUID().slice(0, 8)}`, task }, configManager);
|
|
122
239
|
}
|
|
123
240
|
|
|
124
241
|
return {
|
|
125
242
|
engine,
|
|
126
|
-
proposeDraft(task: string): WorkstreamDraft {
|
|
127
|
-
const
|
|
243
|
+
async proposeDraft(task: string, isolation?: WorkstreamIsolation): Promise<WorkstreamDraft> {
|
|
244
|
+
const result = await decompose(task);
|
|
245
|
+
const spec = buildSpec(task, result.proposal);
|
|
128
246
|
const draft: WorkstreamDraft = {
|
|
129
247
|
id: `wsd_${crypto.randomUUID().slice(0, 8)}`,
|
|
130
248
|
task,
|
|
131
|
-
spec:
|
|
132
|
-
gate,
|
|
249
|
+
spec: isolation ? { ...spec, isolation } : spec,
|
|
250
|
+
gate: result.gate,
|
|
251
|
+
proposal: result.proposal,
|
|
252
|
+
provenance: toProvenance(result),
|
|
133
253
|
approved: false,
|
|
134
254
|
createdAt: Date.now(),
|
|
135
255
|
};
|
|
@@ -138,11 +258,15 @@ function createWorkstreamCommandService(
|
|
|
138
258
|
},
|
|
139
259
|
getDraft: (id) => drafts.get(id),
|
|
140
260
|
listDrafts: () => Array.from(drafts.values()).sort((a, b) => a.createdAt - b.createdAt),
|
|
141
|
-
editDraft(id, task) {
|
|
261
|
+
async editDraft(id, task, isolation) {
|
|
142
262
|
const draft = drafts.get(id);
|
|
143
263
|
if (!draft) return undefined;
|
|
264
|
+
const result = await decompose(task);
|
|
265
|
+
const nextIsolation = isolation ?? draft.spec.isolation;
|
|
144
266
|
draft.task = task;
|
|
145
|
-
draft.spec = buildSpec(task);
|
|
267
|
+
draft.spec = { ...buildSpec(task, result.proposal), isolation: nextIsolation };
|
|
268
|
+
draft.proposal = result.proposal;
|
|
269
|
+
draft.provenance = toProvenance(result);
|
|
146
270
|
draft.approved = false;
|
|
147
271
|
return draft;
|
|
148
272
|
},
|
|
@@ -169,9 +293,11 @@ function createWorkstreamCommandService(
|
|
|
169
293
|
* facade. `persist` and `createWorktree` are left at the engine's own
|
|
170
294
|
* defaults: journal-backed snapshots under .goodvibes/orchestration/ (so
|
|
171
295
|
* resumeAllFromDisk below has something to resume) and a plain
|
|
172
|
-
* AgentWorktree(projectRoot)
|
|
173
|
-
*
|
|
174
|
-
*
|
|
296
|
+
* AgentWorktree(projectRoot) for the (default) `shared`-isolation path.
|
|
297
|
+
* A draft's `isolation: 'worktree'` (see /workstream create --isolation
|
|
298
|
+
* worktree, input/commands/workstream-runtime.ts) opts a single workstream
|
|
299
|
+
* into the SDK's per-item git-worktree isolation (WorktreeIsolationManager,
|
|
300
|
+
* engine-side) instead — the engine, not this facade, owns that lifecycle.
|
|
175
301
|
*/
|
|
176
302
|
export function createWorkstreamServices(deps: WorkstreamServicesDeps): WorkstreamServices {
|
|
177
303
|
const orchestrationEngine = createOrchestrationEngine({
|
|
@@ -190,6 +316,12 @@ export function createWorkstreamServices(deps: WorkstreamServicesDeps): Workstre
|
|
|
190
316
|
// restart. Never throws — persistence.ts guards every read/parse and
|
|
191
317
|
// quarantines an unrecognized snapshot rather than propagating.
|
|
192
318
|
orchestrationEngine.resumeAllFromDisk();
|
|
193
|
-
const workstreamCommands = createWorkstreamCommandService(
|
|
319
|
+
const workstreamCommands = createWorkstreamCommandService(
|
|
320
|
+
orchestrationEngine,
|
|
321
|
+
deps.adaptivePlanner,
|
|
322
|
+
deps.configManager,
|
|
323
|
+
deps.agentManager,
|
|
324
|
+
deps.projectRoot,
|
|
325
|
+
);
|
|
194
326
|
return { orchestrationEngine, workstreamCommands };
|
|
195
327
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* WRFC chain persistence — snapshot active chains to disk on every lifecycle
|
|
3
|
-
* event so that a crash/restart can surface interrupted chains to the operator
|
|
3
|
+
* event so that a crash/restart can surface interrupted chains to the operator,
|
|
4
|
+
* and so that terminal chains (passed/failed/cancelled) remain visible as
|
|
5
|
+
* bounded history rather than vanishing the moment they finish.
|
|
4
6
|
*
|
|
5
7
|
* Architecture
|
|
6
8
|
* ─────────────
|
|
@@ -16,11 +18,27 @@
|
|
|
16
18
|
* row and can be resumed by the operator. The `interruptedChains` accessor
|
|
17
19
|
* additionally exposes the recovered set for inspection.
|
|
18
20
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* A chain that looked interrupted (non-terminal) in the old snapshot can be
|
|
22
|
+
* reaped to terminal IN PLACE by `WrfcController.importChain` (Wave 6, wo-F:
|
|
23
|
+
* no member agent survived the restart — see wrfc-controller.ts). This
|
|
24
|
+
* module always re-checks `chain.state` AFTER the import call, never the
|
|
25
|
+
* pre-import classification, so a reaped chain is treated as history, not
|
|
26
|
+
* re-surfaced as interrupted and never re-imported again on a later restart.
|
|
27
|
+
*
|
|
28
|
+
* 3. Terminal-history retention (bounded, most-recently-completed first):
|
|
29
|
+
* Previously, every terminal chain was pruned from the snapshot the moment
|
|
30
|
+
* it was written or rehydrated — a killed/finished chain vanished from
|
|
31
|
+
* wrfc-chains.json entirely, including across a restart, so nothing could
|
|
32
|
+
* ever honestly report "last chain: <state>". Terminal chains are now
|
|
33
|
+
* RETAINED, capped at MAX_TERMINAL_HISTORY (20) most-recently-completed,
|
|
34
|
+
* both:
|
|
35
|
+
* - across a live session, via an internal history cache that survives
|
|
36
|
+
* even after WrfcController's own in-memory cleanup (60s after a
|
|
37
|
+
* chain terminates) drops the chain from listChains(); and
|
|
38
|
+
* - across a restart, via rehydrate() seeding that cache from whatever
|
|
39
|
+
* was already terminal in the prior snapshot instead of discarding it.
|
|
40
|
+
* A corrupt or version-mismatched snapshot is quarantined by renaming it
|
|
41
|
+
* to `<path>.unrecognized` — never a hard crash.
|
|
24
42
|
*
|
|
25
43
|
* Snapshot path: `.goodvibes/tui/wrfc-chains.json`
|
|
26
44
|
* Snapshot schema: `{ version: 1, writtenAt: number, chains: WrfcChain[] }`
|
|
@@ -37,6 +55,9 @@ import type { SystemMessageRouter } from '../core/system-message-router.ts';
|
|
|
37
55
|
const SNAPSHOT_VERSION = 1;
|
|
38
56
|
const DEBOUNCE_MS = 250;
|
|
39
57
|
|
|
58
|
+
/** Bounded terminal-history retention: keep the most recent K chains, prune beyond. */
|
|
59
|
+
const MAX_TERMINAL_HISTORY = 20;
|
|
60
|
+
|
|
40
61
|
/** Terminal states — chains in these states will not be surfaced as interrupted. */
|
|
41
62
|
const TERMINAL_STATES = new Set<WrfcState>(['passed', 'failed']);
|
|
42
63
|
|
|
@@ -45,6 +66,13 @@ function isNonTerminal(state: WrfcState): boolean {
|
|
|
45
66
|
return !TERMINAL_STATES.has(state);
|
|
46
67
|
}
|
|
47
68
|
|
|
69
|
+
/** Sort by most-recently-completed first (falls back to createdAt for chains missing completedAt) and keep only the first `limit`. */
|
|
70
|
+
function takeMostRecentTerminal(chains: readonly WrfcChain[], limit: number): WrfcChain[] {
|
|
71
|
+
return [...chains]
|
|
72
|
+
.sort((a, b) => (b.completedAt ?? b.createdAt ?? 0) - (a.completedAt ?? a.createdAt ?? 0))
|
|
73
|
+
.slice(0, limit);
|
|
74
|
+
}
|
|
75
|
+
|
|
48
76
|
// ─── Snapshot schema ─────────────────────────────────────────────────────────
|
|
49
77
|
|
|
50
78
|
interface WrfcSnapshot {
|
|
@@ -90,9 +118,19 @@ export interface WrfcPersistence {
|
|
|
90
118
|
*/
|
|
91
119
|
attach(runtimeBus: RuntimeEventBus): Array<() => void>;
|
|
92
120
|
|
|
121
|
+
/**
|
|
122
|
+
* All chains known after rehydrate(): the (re-checked, post-reap) interrupted
|
|
123
|
+
* set plus the bounded terminal-history cache seeded from the prior snapshot.
|
|
124
|
+
* Empty until rehydrate() is called. Consumers building a post-restart
|
|
125
|
+
* summary (e.g. the boot resume notice) read this instead of re-parsing the
|
|
126
|
+
* snapshot file themselves.
|
|
127
|
+
*/
|
|
128
|
+
readonly knownChains: readonly WrfcChain[];
|
|
129
|
+
|
|
93
130
|
/**
|
|
94
131
|
* Read the snapshot from a previous process, surface any interrupted chains
|
|
95
|
-
* as system messages, and
|
|
132
|
+
* as system messages, and retain terminal chains (bounded, most-recently-
|
|
133
|
+
* completed first) as on-disk history instead of pruning them.
|
|
96
134
|
*
|
|
97
135
|
* Must be called after the SystemMessageRouter is available.
|
|
98
136
|
*/
|
|
@@ -111,6 +149,14 @@ class WrfcPersistenceImpl implements WrfcPersistence {
|
|
|
111
149
|
|
|
112
150
|
private _interruptedChains: WrfcChain[] = [];
|
|
113
151
|
private _debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
152
|
+
/**
|
|
153
|
+
* Bounded terminal-history cache (most-recently-completed first once
|
|
154
|
+
* trimmed). Survives past WrfcController's own in-memory cleanup of
|
|
155
|
+
* terminated chains (60s after termination — see wrfc-controller.ts
|
|
156
|
+
* scheduleChainCleanup) so a chain that ages out of listChains() is not
|
|
157
|
+
* silently dropped from the persisted snapshot.
|
|
158
|
+
*/
|
|
159
|
+
private _terminalHistory: WrfcChain[] = [];
|
|
114
160
|
|
|
115
161
|
constructor(options: WrfcPersistenceOptions) {
|
|
116
162
|
this.snapshotPath = options.snapshotPath;
|
|
@@ -122,6 +168,10 @@ class WrfcPersistenceImpl implements WrfcPersistence {
|
|
|
122
168
|
return this._interruptedChains;
|
|
123
169
|
}
|
|
124
170
|
|
|
171
|
+
get knownChains(): readonly WrfcChain[] {
|
|
172
|
+
return [...this._interruptedChains, ...this._terminalHistory];
|
|
173
|
+
}
|
|
174
|
+
|
|
125
175
|
attach(runtimeBus: RuntimeEventBus): Array<() => void> {
|
|
126
176
|
const schedule = (): void => this._scheduleSnapshot();
|
|
127
177
|
|
|
@@ -144,17 +194,40 @@ class WrfcPersistenceImpl implements WrfcPersistence {
|
|
|
144
194
|
const snapshot = this._readSnapshot();
|
|
145
195
|
if (!snapshot) return;
|
|
146
196
|
|
|
147
|
-
|
|
148
|
-
this
|
|
197
|
+
// Seed the bounded terminal-history cache from whatever was already
|
|
198
|
+
// terminal in the prior process's snapshot, so it survives this restart
|
|
199
|
+
// instead of being wiped (the old behaviour pruned every terminal chain
|
|
200
|
+
// unconditionally on every rehydrate).
|
|
201
|
+
const alreadyTerminal = snapshot.chains.filter((c) => !isNonTerminal(c.state));
|
|
202
|
+
this._terminalHistory = takeMostRecentTerminal(alreadyTerminal, MAX_TERMINAL_HISTORY);
|
|
149
203
|
|
|
150
|
-
const
|
|
151
|
-
for (const chain of
|
|
204
|
+
const candidateInterrupted = snapshot.chains.filter((c) => isNonTerminal(c.state));
|
|
205
|
+
for (const chain of candidateInterrupted) {
|
|
152
206
|
// Re-import so the chain reappears in the controller's in-memory map and
|
|
153
207
|
// becomes selectable/resumable from the panel. On a fresh process start
|
|
154
208
|
// the map is empty, so importChain (force=false) never clobbers a live
|
|
155
209
|
// chain. The accessor is optional for read-only test doubles.
|
|
210
|
+
//
|
|
211
|
+
// NOTE: importChain may reap this chain to a terminal state IN PLACE
|
|
212
|
+
// (Wave 6, wo-F zombie-reap: no member agent survived the restart) —
|
|
213
|
+
// always read chain.state AFTER this call below, never the pre-import
|
|
214
|
+
// classification captured by candidateInterrupted.
|
|
156
215
|
this.controller.importChain?.(chain);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Re-partition after the reap check: a chain that looked interrupted
|
|
219
|
+
// before import may now be terminal. Only genuinely still-live chains are
|
|
220
|
+
// surfaced to the operator as interrupted; anything reaped just now joins
|
|
221
|
+
// history instead — it is done, not a resurrection candidate, and must
|
|
222
|
+
// never be handed to importChain again on a future restart (a chain only
|
|
223
|
+
// ever lands in candidateInterrupted while its persisted state is
|
|
224
|
+
// non-terminal; once it's history its state is terminal for good).
|
|
225
|
+
this._interruptedChains = candidateInterrupted.filter((c) => isNonTerminal(c.state));
|
|
226
|
+
const reapedJustNow = candidateInterrupted.filter((c) => !isNonTerminal(c.state));
|
|
227
|
+
for (const chain of reapedJustNow) this._upsertHistoryChain(chain);
|
|
157
228
|
|
|
229
|
+
const router = this.getSystemMessageRouter();
|
|
230
|
+
for (const chain of this._interruptedChains) {
|
|
158
231
|
const msg =
|
|
159
232
|
`[WRFC] Chain ${chain.id.slice(0, 12)} (${chain.task.slice(0, 60).trim()}) ` +
|
|
160
233
|
`was interrupted by a restart — state was '${chain.state}' ` +
|
|
@@ -162,14 +235,19 @@ class WrfcPersistenceImpl implements WrfcPersistence {
|
|
|
162
235
|
router?.wrfc(msg, 'high');
|
|
163
236
|
}
|
|
164
237
|
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
238
|
+
// Rewrite the snapshot only when something actually changed: a chain was
|
|
239
|
+
// reaped just now, or the history cap pruned an entry that was on disk.
|
|
240
|
+
// Terminal chains are RETAINED (bounded to MAX_TERMINAL_HISTORY, most
|
|
241
|
+
// recently completed first) instead of erased — post-restart surfaces
|
|
242
|
+
// (fleet, the resume notice, /wrfc history) need this to honestly report
|
|
243
|
+
// e.g. "last chain: cancelled".
|
|
244
|
+
if (reapedJustNow.length > 0 || alreadyTerminal.length !== this._terminalHistory.length) {
|
|
245
|
+
const rebuilt: WrfcSnapshot = {
|
|
168
246
|
version: SNAPSHOT_VERSION,
|
|
169
247
|
writtenAt: Date.now(),
|
|
170
|
-
chains:
|
|
248
|
+
chains: [...this._interruptedChains, ...this._terminalHistory],
|
|
171
249
|
};
|
|
172
|
-
this._writeSnapshot(
|
|
250
|
+
this._writeSnapshot(rebuilt);
|
|
173
251
|
}
|
|
174
252
|
}
|
|
175
253
|
|
|
@@ -194,14 +272,43 @@ class WrfcPersistenceImpl implements WrfcPersistence {
|
|
|
194
272
|
}
|
|
195
273
|
|
|
196
274
|
private _writeCurrentSnapshot(): void {
|
|
275
|
+
const live = this.controller.listChains();
|
|
276
|
+
this._recordTerminalHistory(live);
|
|
197
277
|
const snapshot: WrfcSnapshot = {
|
|
198
278
|
version: SNAPSHOT_VERSION,
|
|
199
279
|
writtenAt: Date.now(),
|
|
200
|
-
chains: this.
|
|
280
|
+
chains: this._mergeWithHistory(live),
|
|
201
281
|
};
|
|
202
282
|
this._writeSnapshot(snapshot);
|
|
203
283
|
}
|
|
204
284
|
|
|
285
|
+
/** Fold any terminal chains in a live listing into the bounded history cache, so they are not lost once WrfcController's own in-memory cleanup drops them from listChains(). */
|
|
286
|
+
private _recordTerminalHistory(chains: readonly WrfcChain[]): void {
|
|
287
|
+
for (const chain of chains) {
|
|
288
|
+
if (!isNonTerminal(chain.state)) this._upsertHistoryChain(chain);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Insert/replace `chain` in the terminal-history cache (by id), then trim to MAX_TERMINAL_HISTORY, most-recently-completed first. */
|
|
293
|
+
private _upsertHistoryChain(chain: WrfcChain): void {
|
|
294
|
+
const next = this._terminalHistory.filter((c) => c.id !== chain.id);
|
|
295
|
+
next.push(chain);
|
|
296
|
+
this._terminalHistory = takeMostRecentTerminal(next, MAX_TERMINAL_HISTORY);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Combine the live chains the controller is currently tracking with any
|
|
301
|
+
* historical terminal chains the live map has already forgotten (e.g. after
|
|
302
|
+
* WrfcController's own 60s in-memory cleanup) — so the persisted file never
|
|
303
|
+
* loses a terminal chain just because the live map stopped carrying it.
|
|
304
|
+
* Live entries win on id collision since they are always the freshest copy.
|
|
305
|
+
*/
|
|
306
|
+
private _mergeWithHistory(live: readonly WrfcChain[]): WrfcChain[] {
|
|
307
|
+
const liveIds = new Set(live.map((c) => c.id));
|
|
308
|
+
const historyOnly = this._terminalHistory.filter((c) => !liveIds.has(c.id));
|
|
309
|
+
return [...live, ...historyOnly];
|
|
310
|
+
}
|
|
311
|
+
|
|
205
312
|
private _writeSnapshot(snapshot: WrfcSnapshot): void {
|
|
206
313
|
try {
|
|
207
314
|
atomicWriteFileSync(this.snapshotPath, JSON.stringify(snapshot), { mkdirp: true });
|
|
@@ -10,6 +10,8 @@ export type PendingPermissionState = PermissionRequest & {
|
|
|
10
10
|
resolve: (approved: boolean, remember?: boolean, modifiedArgs?: Record<string, unknown>) => void;
|
|
11
11
|
/** Present only when isHunkSelectable(request) was true when the prompt was opened. */
|
|
12
12
|
hunkState?: HunkSelectionState;
|
|
13
|
+
/** True once the user pressed `d` to expand a condensed low-risk card. (UX-B 2b.) */
|
|
14
|
+
detailsExpanded?: boolean;
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
export type BlockingInputHandlerOptions = {
|
|
@@ -22,6 +24,15 @@ export type BlockingInputHandlerOptions = {
|
|
|
22
24
|
render: () => void;
|
|
23
25
|
loadRecoveryConversation: () => SessionSnapshot | null;
|
|
24
26
|
deleteRecoveryFile: () => void;
|
|
27
|
+
/**
|
|
28
|
+
* Copy the live recovery.jsonl aside to a preserved sibling so the dismiss
|
|
29
|
+
* message's promise ("still on disk") survives this session's later 60s
|
|
30
|
+
* autosaves, which would otherwise overwrite the shared recovery file with
|
|
31
|
+
* the CURRENT (post-dismiss) session's state (W3 Finding 3). Optional so
|
|
32
|
+
* every pre-existing test/caller that doesn't care about preservation is
|
|
33
|
+
* unaffected; when omitted, dismiss behaves exactly as before.
|
|
34
|
+
*/
|
|
35
|
+
preserveRecoveryFile?: () => { readonly preserved: boolean; readonly replacedPrevious: boolean };
|
|
25
36
|
/**
|
|
26
37
|
* Absolute home directory used to locate the transcript journal for this
|
|
27
38
|
* recovery session. Required for journal replay on Ctrl+R restore.
|
|
@@ -68,6 +79,7 @@ export function handleBlockingShellInput(
|
|
|
68
79
|
render,
|
|
69
80
|
loadRecoveryConversation,
|
|
70
81
|
deleteRecoveryFile,
|
|
82
|
+
preserveRecoveryFile,
|
|
71
83
|
homeDirectory,
|
|
72
84
|
sessionId,
|
|
73
85
|
persistSnapshot,
|
|
@@ -115,6 +127,16 @@ export function handleBlockingShellInput(
|
|
|
115
127
|
return { handled: true, pendingPermission: null, recoveryPending };
|
|
116
128
|
}
|
|
117
129
|
|
|
130
|
+
if (key === 'd') {
|
|
131
|
+
// Toggle the condensed↔full detail view without resolving the request.
|
|
132
|
+
render();
|
|
133
|
+
return {
|
|
134
|
+
handled: true,
|
|
135
|
+
pendingPermission: { ...req, detailsExpanded: !req.detailsExpanded },
|
|
136
|
+
recoveryPending,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
118
140
|
render();
|
|
119
141
|
return { handled: true, pendingPermission, recoveryPending };
|
|
120
142
|
}
|
|
@@ -156,10 +178,31 @@ export function handleBlockingShellInput(
|
|
|
156
178
|
return { handled: true, pendingPermission: null, recoveryPending: false };
|
|
157
179
|
}
|
|
158
180
|
|
|
159
|
-
//
|
|
160
|
-
|
|
181
|
+
// Any other key demonstrates the user's intent to ignore the banner and
|
|
182
|
+
// keep working — the prompt's own text invites exactly this ("start
|
|
183
|
+
// typing to ignore it"). Previously this branch re-posted the same
|
|
184
|
+
// '[Recovery] Ctrl+R to restore...' line on EVERY such key and never
|
|
185
|
+
// cleared recoveryPending, so a user who took that invitation got a
|
|
186
|
+
// fresh [Recovery] line injected into the transcript around every
|
|
187
|
+
// character they typed, forever. Dismiss ONCE instead: clear
|
|
188
|
+
// recoveryPending so it stops re-asserting, but do NOT delete the
|
|
189
|
+
// recovery file — dismiss is not discard, and the file remains
|
|
190
|
+
// restorable by the automatic recovery check on the next launch.
|
|
191
|
+
//
|
|
192
|
+
// W3 Finding 3: the promise below ("still on disk; you will be asked
|
|
193
|
+
// again") used to go false silently — main.ts's 60s autosave overwrites
|
|
194
|
+
// the single shared recovery.jsonl with the CURRENT session's state
|
|
195
|
+
// within a minute. preserveRecoveryFile (when wired) copies it aside
|
|
196
|
+
// NOW, while it still holds the dismissed session's data, so the
|
|
197
|
+
// promise stays true. If an earlier dismiss's preserved snapshot gets
|
|
198
|
+
// replaced by this one, say so honestly instead of silently discarding it.
|
|
199
|
+
const preserveResult = preserveRecoveryFile?.();
|
|
200
|
+
if (preserveResult?.replacedPrevious) {
|
|
201
|
+
systemMessageRouter.low('[Recovery] Replacing the previously preserved (unrestored) snapshot with this one.');
|
|
202
|
+
}
|
|
203
|
+
systemMessageRouter.high('[Recovery] Dismissed — the unsaved session is still on disk; you will be asked again next time GoodVibes starts here.');
|
|
161
204
|
render();
|
|
162
|
-
return { handled: false, pendingPermission, recoveryPending:
|
|
205
|
+
return { handled: false, pendingPermission, recoveryPending: false };
|
|
163
206
|
}
|
|
164
207
|
|
|
165
208
|
return { handled: false, pendingPermission, recoveryPending };
|