@yaag/extension 0.1.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/README.md +218 -0
- package/package.json +40 -0
- package/src/background-widget.ts +107 -0
- package/src/cli-child.ts +97 -0
- package/src/describe-tool.ts +69 -0
- package/src/event-reader.ts +47 -0
- package/src/fake-extension-ui.ts +183 -0
- package/src/fake-theme.ts +72 -0
- package/src/fake-tui.ts +165 -0
- package/src/index.ts +73 -0
- package/src/pi-keybindings.ts +45 -0
- package/src/program-directories.ts +29 -0
- package/src/resolve-bun.ts +35 -0
- package/src/resolve-cli.ts +9 -0
- package/src/run-complete-renderer.ts +20 -0
- package/src/run-details.ts +294 -0
- package/src/run-foreground-view.ts +95 -0
- package/src/run-foreground.ts +154 -0
- package/src/run-picker.ts +40 -0
- package/src/run-registry.ts +107 -0
- package/src/run-settlement.ts +64 -0
- package/src/run-tool-test-support.ts +126 -0
- package/src/run-tool.ts +276 -0
- package/src/run-tree-component.ts +50 -0
- package/src/run-tree-host.ts +56 -0
- package/src/run-trees.ts +91 -0
- package/src/setup-workspace-command.ts +45 -0
- package/src/setup-workspace-tool.ts +45 -0
- package/src/setup-workspace.ts +69 -0
- package/src/spawn-run.ts +84 -0
- package/src/status-tool.ts +118 -0
- package/src/status.ts +13 -0
- package/src/stderr-buffer.ts +34 -0
- package/src/stop-tool.ts +115 -0
- package/src/system-prompt-append.ts +53 -0
- package/src/test-extension-context.ts +59 -0
- package/src/test-tui-context.ts +107 -0
- package/src/usage.ts +26 -0
- package/src/yaag-command.ts +163 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentActivity,
|
|
3
|
+
AgentInfo,
|
|
4
|
+
LifecycleEvent,
|
|
5
|
+
NodeInfo,
|
|
6
|
+
RunSummary,
|
|
7
|
+
TokenBreakdown,
|
|
8
|
+
} from "@yaag/runtime";
|
|
9
|
+
|
|
10
|
+
/** Renderer-only facts persisted with a Run tool result or completion message. */
|
|
11
|
+
export interface RunDetails {
|
|
12
|
+
readonly summary: RunSummary;
|
|
13
|
+
/** Identifies an fd 3 stream occurrence, never an event value or timestamp. */
|
|
14
|
+
readonly sequence?: number;
|
|
15
|
+
readonly event?: LifecycleEvent;
|
|
16
|
+
readonly id?: string;
|
|
17
|
+
readonly result?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Decodes persisted Run details without throwing.
|
|
22
|
+
*
|
|
23
|
+
* Returns undefined when historical or malformed message data cannot safely
|
|
24
|
+
* reach the tree renderer.
|
|
25
|
+
*/
|
|
26
|
+
export function parseRunDetails(value: unknown): RunDetails | undefined {
|
|
27
|
+
if (!isRecord(value) || !isRunSummary(value.summary)) return undefined;
|
|
28
|
+
if (!optionalString(value.id) || !optionalString(value.result)) return undefined;
|
|
29
|
+
if (!optionalSequence(value.sequence) || !optionalEvent(value.event)) return undefined;
|
|
30
|
+
return {
|
|
31
|
+
summary: normalizeAgents(value.summary),
|
|
32
|
+
...(value.sequence === undefined ? {} : { sequence: value.sequence }),
|
|
33
|
+
...(value.event === undefined ? {} : { event: value.event }),
|
|
34
|
+
...(value.id === undefined ? {} : { id: value.id }),
|
|
35
|
+
...(value.result === undefined ? {} : { result: value.result }),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isRunSummary(value: unknown): value is RunSummary {
|
|
40
|
+
if (!isRecord(value) || !isSummaryBase(value) || !isAgentMap(value.agents)) return false;
|
|
41
|
+
if (value.runState === "running") return value.outcome === null && value.ok === null;
|
|
42
|
+
return (
|
|
43
|
+
value.runState === "ended" &&
|
|
44
|
+
isOutcome(value.outcome) &&
|
|
45
|
+
typeof value.ok === "boolean" &&
|
|
46
|
+
nullableNumber(value.endedAt)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isSummaryBase(value: Record<string, unknown>): boolean {
|
|
51
|
+
return (
|
|
52
|
+
typeof value.program === "string" &&
|
|
53
|
+
nullableNumber(value.startedAt) &&
|
|
54
|
+
natural(value.asksStarted) &&
|
|
55
|
+
natural(value.asksSettled) &&
|
|
56
|
+
number(value.cost) &&
|
|
57
|
+
nullableTokens(value.tokens) &&
|
|
58
|
+
typeof value.incomplete === "boolean" &&
|
|
59
|
+
natural(value.durationMs) &&
|
|
60
|
+
natural(value.worstFrameGapMs)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isAgentMap(value: unknown): boolean {
|
|
65
|
+
return isRecord(value) && Object.values(value).every(isAgent);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Normalizes each Agent's Nested Node table. A message persisted before the
|
|
70
|
+
* node table existed carries neither field, and must keep rendering, so the
|
|
71
|
+
* absent values become an empty table rather than a rejection.
|
|
72
|
+
*/
|
|
73
|
+
function normalizeAgents(value: RunSummary): RunSummary {
|
|
74
|
+
const agents: Record<string, AgentInfo> = {};
|
|
75
|
+
for (const [name, agent] of Object.entries(value.agents)) {
|
|
76
|
+
agents[name] = {
|
|
77
|
+
...agent,
|
|
78
|
+
nodes: agent.nodes ?? [],
|
|
79
|
+
finishedNodesPruned: agent.finishedNodesPruned ?? 0,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return { ...value, agents };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function optionalNodes(value: unknown): boolean {
|
|
86
|
+
return value === undefined || (Array.isArray(value) && value.every(isNode));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isNode(value: unknown): value is NodeInfo {
|
|
90
|
+
return (
|
|
91
|
+
isRecord(value) &&
|
|
92
|
+
typeof value.path === "string" &&
|
|
93
|
+
isNodeState(value.state) &&
|
|
94
|
+
nullableString(value.activityGist) &&
|
|
95
|
+
nullableTokens(value.tokens) &&
|
|
96
|
+
nullableNumber(value.cost) &&
|
|
97
|
+
nullableNumber(value.updatedAt)
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isNodeState(value: unknown): boolean {
|
|
102
|
+
return value === "running" || value === "exited" || value === "failed";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function optionalNodeUsage(value: unknown): boolean {
|
|
106
|
+
if (value === undefined) return true;
|
|
107
|
+
if (!isRecord(value)) return false;
|
|
108
|
+
return (
|
|
109
|
+
(value.tokens === undefined || isTokens(value.tokens)) &&
|
|
110
|
+
(value.cost === undefined || number(value.cost))
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isAgent(value: unknown): value is AgentInfo {
|
|
115
|
+
if (!isRecord(value) || !isAgentBase(value)) return false;
|
|
116
|
+
if (value.state === "asking") {
|
|
117
|
+
return (
|
|
118
|
+
natural(value.askIndex) &&
|
|
119
|
+
typeof value.promptGist === "string" &&
|
|
120
|
+
typeof value.replayed === "boolean"
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return (
|
|
124
|
+
(value.state === "idle" || value.state === "exited") &&
|
|
125
|
+
nullableNatural(value.askIndex) &&
|
|
126
|
+
nullableString(value.promptGist)
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isAgentBase(value: Record<string, unknown>): boolean {
|
|
131
|
+
return (
|
|
132
|
+
nullableString(value.model) &&
|
|
133
|
+
nullableString(value.cwd) &&
|
|
134
|
+
nullableString(value.branch) &&
|
|
135
|
+
nullableString(value.sessionFile) &&
|
|
136
|
+
nullableActivity(value.activity) &&
|
|
137
|
+
nullableTokens(value.tokens) &&
|
|
138
|
+
nullableNumber(value.cost) &&
|
|
139
|
+
typeof value.incomplete === "boolean" &&
|
|
140
|
+
nullableNumber(value.stateChangedAt) &&
|
|
141
|
+
nullableNumber(value.usageUpdatedAt) &&
|
|
142
|
+
nullableNumber(value.askStartedAt) &&
|
|
143
|
+
optionalNodes(value.nodes) &&
|
|
144
|
+
(value.finishedNodesPruned === undefined || natural(value.finishedNodesPruned))
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function optionalEvent(value: unknown): value is LifecycleEvent | undefined {
|
|
149
|
+
return value === undefined || isEvent(value);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isEvent(value: unknown): value is LifecycleEvent {
|
|
153
|
+
if (!isRecord(value) || !number(value.at) || typeof value.type !== "string") return false;
|
|
154
|
+
switch (value.type) {
|
|
155
|
+
case "run_start":
|
|
156
|
+
return typeof value.program === "string";
|
|
157
|
+
case "agent_spawn":
|
|
158
|
+
return (
|
|
159
|
+
strings(value.agent, value.model, value.cwd) &&
|
|
160
|
+
optionalString(value.branch) &&
|
|
161
|
+
optionalString(value.sessionFile)
|
|
162
|
+
);
|
|
163
|
+
case "ask_start":
|
|
164
|
+
return (
|
|
165
|
+
strings(value.agent, value.promptGist) &&
|
|
166
|
+
natural(value.index) &&
|
|
167
|
+
natural(value.promptChars) &&
|
|
168
|
+
(value.replayed === undefined || value.replayed === true)
|
|
169
|
+
);
|
|
170
|
+
case "ask_activity":
|
|
171
|
+
return strings(value.agent) && natural(value.index) && isActivity(value.activity);
|
|
172
|
+
case "node_update":
|
|
173
|
+
return (
|
|
174
|
+
strings(value.agent, value.path) &&
|
|
175
|
+
isNodeState(value.state) &&
|
|
176
|
+
optionalString(value.activityGist) &&
|
|
177
|
+
optionalNodeUsage(value.usage)
|
|
178
|
+
);
|
|
179
|
+
case "ask_output":
|
|
180
|
+
return (
|
|
181
|
+
strings(value.agent, value.text) &&
|
|
182
|
+
natural(value.index) &&
|
|
183
|
+
(value.channel === "text" || value.channel === "thinking")
|
|
184
|
+
);
|
|
185
|
+
case "ask_end":
|
|
186
|
+
return (
|
|
187
|
+
strings(value.agent) &&
|
|
188
|
+
natural(value.index) &&
|
|
189
|
+
natural(value.durationMs) &&
|
|
190
|
+
typeof value.ok === "boolean" &&
|
|
191
|
+
(value.maxFrameGapMs === undefined || natural(value.maxFrameGapMs))
|
|
192
|
+
);
|
|
193
|
+
case "agent_usage":
|
|
194
|
+
return strings(value.agent) && isTokens(value.tokens) && number(value.cost);
|
|
195
|
+
case "agent_exit":
|
|
196
|
+
return (
|
|
197
|
+
strings(value.agent) &&
|
|
198
|
+
nullableTokens(value.tokens) &&
|
|
199
|
+
nullableNumber(value.cost) &&
|
|
200
|
+
typeof value.incomplete === "boolean" &&
|
|
201
|
+
optionalWorktree(value.worktree)
|
|
202
|
+
);
|
|
203
|
+
case "run_end":
|
|
204
|
+
return (
|
|
205
|
+
typeof value.ok === "boolean" &&
|
|
206
|
+
natural(value.durationMs) &&
|
|
207
|
+
number(value.cost) &&
|
|
208
|
+
nullableTokens(value.tokens) &&
|
|
209
|
+
typeof value.incomplete === "boolean" &&
|
|
210
|
+
natural(value.worstFrameGapMs)
|
|
211
|
+
);
|
|
212
|
+
default:
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function nullableActivity(value: unknown): value is AgentActivity | null {
|
|
218
|
+
return value === null || isActivity(value);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isActivity(value: unknown): value is AgentActivity {
|
|
222
|
+
if (!isRecord(value) || typeof value.type !== "string") return false;
|
|
223
|
+
switch (value.type) {
|
|
224
|
+
case "thinking":
|
|
225
|
+
case "writing":
|
|
226
|
+
case "compacting":
|
|
227
|
+
return true;
|
|
228
|
+
case "tool":
|
|
229
|
+
return strings(value.name, value.argsGist);
|
|
230
|
+
case "retrying":
|
|
231
|
+
return natural(value.attempt) && natural(value.max);
|
|
232
|
+
default:
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function optionalWorktree(value: unknown): boolean {
|
|
238
|
+
return value === undefined || (isRecord(value) && strings(value.cwd, value.branch));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function nullableTokens(value: unknown): value is TokenBreakdown | null {
|
|
242
|
+
return value === null || isTokens(value);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isTokens(value: unknown): value is TokenBreakdown {
|
|
246
|
+
return (
|
|
247
|
+
isRecord(value) &&
|
|
248
|
+
natural(value.input) &&
|
|
249
|
+
natural(value.output) &&
|
|
250
|
+
natural(value.cacheRead) &&
|
|
251
|
+
natural(value.cacheWrite) &&
|
|
252
|
+
natural(value.total)
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isOutcome(value: unknown): boolean {
|
|
257
|
+
return value === "completed" || value === "failed" || value === "stopped" || value === "paused";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function optionalString(value: unknown): value is string | undefined {
|
|
261
|
+
return value === undefined || typeof value === "string";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function nullableString(value: unknown): boolean {
|
|
265
|
+
return value === null || typeof value === "string";
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function optionalSequence(value: unknown): value is number | undefined {
|
|
269
|
+
return value === undefined || natural(value);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function nullableNumber(value: unknown): boolean {
|
|
273
|
+
return value === null || number(value);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function nullableNatural(value: unknown): boolean {
|
|
277
|
+
return value === null || natural(value);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function number(value: unknown): value is number {
|
|
281
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function natural(value: unknown): value is number {
|
|
285
|
+
return number(value) && value >= 0 && Number.isInteger(value);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function strings(...values: readonly unknown[]): boolean {
|
|
289
|
+
return values.every((value) => typeof value === "string");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
293
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
294
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opens the interactive Run tree for a blocking `yaag_run` in tui mode
|
|
3
|
+
* (spec §4), and adapts the Host Session's capabilities to
|
|
4
|
+
* `RunTreeViewHost`.
|
|
5
|
+
*
|
|
6
|
+
* The host surface is read-only except for the Run's own reap-ladder stop and
|
|
7
|
+
* the detach request: nothing here can prompt an Agent (ADR — a Peek observes,
|
|
8
|
+
* it never sends).
|
|
9
|
+
*/
|
|
10
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import {
|
|
12
|
+
createRunTreeView,
|
|
13
|
+
type RunTreeView,
|
|
14
|
+
type RunTreeViewHost,
|
|
15
|
+
type RunViewExit,
|
|
16
|
+
type TreeState,
|
|
17
|
+
} from "@yaag/tui";
|
|
18
|
+
import { createRunTreeHost } from "./run-tree-host.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What `openRunTreeView` needs to adapt one Run to the interactive view.
|
|
22
|
+
*
|
|
23
|
+
* The caller must supply a tui-mode `ctx` with UI; `openRunTreeView` throws
|
|
24
|
+
* when `ctx.ui` is absent, because there is no surface to open.
|
|
25
|
+
*/
|
|
26
|
+
export interface OpenRunTreeViewOptions {
|
|
27
|
+
readonly ctx: ExtensionContext;
|
|
28
|
+
/** Created and ingested from Run start, so no early Ask is missed. */
|
|
29
|
+
readonly state: TreeState;
|
|
30
|
+
readonly id: string;
|
|
31
|
+
/** Runs the reap ladder for this Run (ADR-0008). */
|
|
32
|
+
stop(): void;
|
|
33
|
+
/** Converts the Run to a background Run. */
|
|
34
|
+
onDetach(): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The opened view: its exit promise, plus the handles the tool drives. */
|
|
38
|
+
export interface OpenedRunTreeView {
|
|
39
|
+
/** Resolves when the reader dismisses or detaches the view. */
|
|
40
|
+
readonly exit: Promise<RunViewExit>;
|
|
41
|
+
/** The Run settled; freeze the tree and show the Result region. */
|
|
42
|
+
settle(result: Parameters<RunTreeView["settle"]>[0]): void;
|
|
43
|
+
/** A pushed fd 3 update landed in the TreeState; redraw. */
|
|
44
|
+
touch(): void;
|
|
45
|
+
/** Closes the view from the host's side, e.g. on an abort. */
|
|
46
|
+
close(exit: RunViewExit): void;
|
|
47
|
+
dispose(): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Opens the view through `ctx.ui.custom()`.
|
|
52
|
+
*
|
|
53
|
+
* The returned `exit` promise always settles: the reader's `q`/detach resolves
|
|
54
|
+
* it, and `close` resolves it for a host-side abort, so a tool call can never
|
|
55
|
+
* hang on the view.
|
|
56
|
+
*/
|
|
57
|
+
export function openRunTreeView(options: OpenRunTreeViewOptions): OpenedRunTreeView {
|
|
58
|
+
const state = options.state;
|
|
59
|
+
let view: RunTreeView | undefined;
|
|
60
|
+
let finish: ((exit: RunViewExit) => void) | undefined;
|
|
61
|
+
let pendingExit: RunViewExit | undefined;
|
|
62
|
+
let pendingSettle: Parameters<RunTreeView["settle"]>[0] | undefined;
|
|
63
|
+
|
|
64
|
+
const exit = options.ctx.ui.custom<RunViewExit>((tui, _theme, keybindings, done) => {
|
|
65
|
+
finish = done;
|
|
66
|
+
const host: RunTreeViewHost = {
|
|
67
|
+
...createRunTreeHost({ ui: options.ctx.ui, tui, keybindings, state }),
|
|
68
|
+
stop: options.stop,
|
|
69
|
+
detach: options.onDetach,
|
|
70
|
+
done,
|
|
71
|
+
};
|
|
72
|
+
view = createRunTreeView({ state, host, label: options.id, surface: "foreground" });
|
|
73
|
+
if (pendingSettle !== undefined) view.settle(pendingSettle);
|
|
74
|
+
if (pendingExit !== undefined) done(pendingExit);
|
|
75
|
+
return view;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
exit,
|
|
80
|
+
settle(result): void {
|
|
81
|
+
if (view === undefined) pendingSettle = result;
|
|
82
|
+
else view.settle(result);
|
|
83
|
+
},
|
|
84
|
+
touch(): void {
|
|
85
|
+
view?.touch();
|
|
86
|
+
},
|
|
87
|
+
close(kind): void {
|
|
88
|
+
if (finish === undefined) pendingExit = kind;
|
|
89
|
+
else finish(kind);
|
|
90
|
+
},
|
|
91
|
+
dispose(): void {
|
|
92
|
+
view?.dispose();
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two blocking `yaag_run` paths (spec §4): the details-only stream every
|
|
3
|
+
* client gets, and the interactive tree a tui-mode Host Session gets.
|
|
4
|
+
*
|
|
5
|
+
* Both return the same model-visible contract, so opening the interactive view
|
|
6
|
+
* changes nothing the model can see.
|
|
7
|
+
*/
|
|
8
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import type { TreeState } from "@yaag/tui";
|
|
10
|
+
import type { RunDetails } from "./run-details.ts";
|
|
11
|
+
import { openRunTreeView } from "./run-foreground-view.ts";
|
|
12
|
+
import type { LiveRun, RunRegistry, RunSettlement } from "./run-registry.ts";
|
|
13
|
+
import { failure, observedSettlement, toError, viewResult } from "./run-settlement.ts";
|
|
14
|
+
import type { RunTreeStore } from "./run-trees.ts";
|
|
15
|
+
import { toUsage } from "./usage.ts";
|
|
16
|
+
|
|
17
|
+
/** What `execute` returns for a blocking Run. */
|
|
18
|
+
export interface ForegroundResult {
|
|
19
|
+
content: { type: "text"; text: string }[];
|
|
20
|
+
details: RunDetails;
|
|
21
|
+
usage?: ReturnType<typeof toUsage>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What a blocking Run needs to be awaited.
|
|
26
|
+
*
|
|
27
|
+
* `signal` is the tool call's own abort signal; when it fires, the Run is
|
|
28
|
+
* stopped through the reap ladder and the call rejects as cancelled.
|
|
29
|
+
*/
|
|
30
|
+
export interface ForegroundOptions {
|
|
31
|
+
readonly run: LiveRun;
|
|
32
|
+
readonly registry: RunRegistry;
|
|
33
|
+
readonly signal: AbortSignal | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `ForegroundOptions` plus what opening the interactive tree needs.
|
|
38
|
+
*
|
|
39
|
+
* Only valid when `interactiveAvailable()` is true; `ctx.ui` is touched on no
|
|
40
|
+
* other path.
|
|
41
|
+
*/
|
|
42
|
+
export interface InteractiveOptions extends ForegroundOptions {
|
|
43
|
+
readonly ctx: ExtensionContext;
|
|
44
|
+
/** Ingested from Run start, so the tree misses no early Ask. */
|
|
45
|
+
readonly state: TreeState;
|
|
46
|
+
/** Detaching hands the Run to the combined inline widget. */
|
|
47
|
+
readonly store: RunTreeStore;
|
|
48
|
+
/** Registers the `yaag-run-complete` follow-up; called only on detach. */
|
|
49
|
+
announce(): void;
|
|
50
|
+
/** Receives the redraw handle, so a pushed fd 3 update can reach the view. */
|
|
51
|
+
onOpen?(view: { touch(): void }): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whether this call may open the interactive tree (spec §4: tui mode only). */
|
|
55
|
+
export function interactiveAvailable(ctx: ExtensionContext, background: boolean): boolean {
|
|
56
|
+
return !background && ctx.mode === "tui" && ctx.hasUI;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The details-only blocking path, unchanged for rpc and json clients.
|
|
61
|
+
*
|
|
62
|
+
* Throws on a failed, killed, or cancelled Run; the message carries the bounded
|
|
63
|
+
* tail of the Run's own stderr.
|
|
64
|
+
*/
|
|
65
|
+
export async function foregroundResult(options: ForegroundOptions): Promise<ForegroundResult> {
|
|
66
|
+
const { run, registry, signal } = options;
|
|
67
|
+
signal?.addEventListener("abort", run.stop, { once: true });
|
|
68
|
+
if (signal?.aborted === true) run.stop();
|
|
69
|
+
let settlement: RunSettlement;
|
|
70
|
+
try {
|
|
71
|
+
settlement = await observedSettlement(run.id, run.outcome, registry);
|
|
72
|
+
} finally {
|
|
73
|
+
signal?.removeEventListener("abort", run.stop);
|
|
74
|
+
}
|
|
75
|
+
if (signal?.aborted === true) throw new Error("yaag_run: cancelled");
|
|
76
|
+
return finalResult(run.id, settlement);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The interactive path: the reader watches the tree while the Run executes, then
|
|
81
|
+
* dismisses it or detaches the Run.
|
|
82
|
+
*
|
|
83
|
+
* Dismissal returns exactly what `foregroundResult` returns, throw included.
|
|
84
|
+
* Detach returns an acknowledgement and leaves the Run executing as `rN`, with
|
|
85
|
+
* its completion delivered by the `yaag-run-complete` follow-up. Every path
|
|
86
|
+
* closes the view, so `ctx.ui.custom()` cannot outlive the tool call.
|
|
87
|
+
*/
|
|
88
|
+
export async function foregroundInteractive(
|
|
89
|
+
options: InteractiveOptions,
|
|
90
|
+
): Promise<ForegroundResult> {
|
|
91
|
+
const { run, registry, signal, ctx } = options;
|
|
92
|
+
const view = openRunTreeView({
|
|
93
|
+
ctx,
|
|
94
|
+
state: options.state,
|
|
95
|
+
id: run.id,
|
|
96
|
+
stop: run.stop,
|
|
97
|
+
onDetach: () => options.store.adopt(run.id),
|
|
98
|
+
});
|
|
99
|
+
options.onOpen?.(view);
|
|
100
|
+
const abort = (): void => {
|
|
101
|
+
run.stop();
|
|
102
|
+
view.close("dismissed");
|
|
103
|
+
};
|
|
104
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
105
|
+
if (signal?.aborted === true) abort();
|
|
106
|
+
try {
|
|
107
|
+
// Kept as a settled-shaped promise so a detach — which leaves this promise
|
|
108
|
+
// pending — cannot surface as an unhandled rejection.
|
|
109
|
+
const settled = observedSettlement(run.id, run.outcome, registry).then(
|
|
110
|
+
(settlement): SettledRace => ({ kind: "settled", settlement }),
|
|
111
|
+
(reason): SettledRace => ({ kind: "settled", settlement: { kind: "rejected", reason } }),
|
|
112
|
+
);
|
|
113
|
+
const first = await Promise.race([
|
|
114
|
+
settled,
|
|
115
|
+
view.exit.then((exit) => ({ kind: "exit", exit }) as const),
|
|
116
|
+
]);
|
|
117
|
+
if (first.kind === "exit" && first.exit === "detached") {
|
|
118
|
+
options.announce();
|
|
119
|
+
return detachedResult(run);
|
|
120
|
+
}
|
|
121
|
+
if (first.kind === "settled") view.settle(viewResult(first.settlement));
|
|
122
|
+
const exit = await view.exit;
|
|
123
|
+
if (exit === "detached") {
|
|
124
|
+
options.announce();
|
|
125
|
+
return detachedResult(run);
|
|
126
|
+
}
|
|
127
|
+
if (signal?.aborted === true) throw new Error("yaag_run: cancelled");
|
|
128
|
+
return finalResult(run.id, await settled.then(({ settlement }) => settlement));
|
|
129
|
+
} finally {
|
|
130
|
+
signal?.removeEventListener("abort", abort);
|
|
131
|
+
view.close("dismissed");
|
|
132
|
+
view.dispose();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
type SettledRace = { readonly kind: "settled"; readonly settlement: RunSettlement };
|
|
137
|
+
|
|
138
|
+
function finalResult(id: string, settlement: RunSettlement): ForegroundResult {
|
|
139
|
+
if (settlement.kind === "rejected") throw toError(settlement.reason);
|
|
140
|
+
const { outcome } = settlement;
|
|
141
|
+
if (outcome.code !== 0) throw new Error(failure(outcome.code, outcome.stderr));
|
|
142
|
+
return {
|
|
143
|
+
content: [{ type: "text", text: outcome.stdout.trimEnd() }],
|
|
144
|
+
details: { summary: outcome.summary, id, result: outcome.stdout.trimEnd() },
|
|
145
|
+
usage: toUsage(outcome.summary),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function detachedResult(run: LiveRun): ForegroundResult {
|
|
150
|
+
return {
|
|
151
|
+
content: [{ type: "text", text: `Run ${run.id} continues in the background.` }],
|
|
152
|
+
details: { summary: run.summary, id: run.id },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure Run labels for the `/yaag` Run picker. Kept apart from the command so
|
|
3
|
+
* the list can be tested without a live UI.
|
|
4
|
+
*/
|
|
5
|
+
import type { RunSummary } from "@yaag/runtime";
|
|
6
|
+
import type { RegisteredRun } from "./run-registry.ts";
|
|
7
|
+
|
|
8
|
+
/** One Run the picker can open: its id, latest Summary fold, and liveness. */
|
|
9
|
+
export interface PickableRun {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly summary: RunSummary;
|
|
12
|
+
readonly live: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Every registered Run, live and finished, in registration order. */
|
|
16
|
+
export function pickableRuns(runs: readonly RegisteredRun[]): readonly PickableRun[] {
|
|
17
|
+
return runs.map((run) => ({
|
|
18
|
+
id: run.run.id,
|
|
19
|
+
summary: run.run.summary,
|
|
20
|
+
live: run.state === "live",
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A one-line Run label, e.g. `r1 — review-fanout (running, 3 Agents)`. */
|
|
25
|
+
export function runPickerLabel(run: PickableRun): string {
|
|
26
|
+
const count = Object.keys(run.summary.agents).length;
|
|
27
|
+
const plural = count === 1 ? "Agent" : "Agents";
|
|
28
|
+
const state = run.summary.runState === "ended" ? (run.summary.outcome ?? "ended") : "running";
|
|
29
|
+
const program = run.summary.program === "" ? "program" : run.summary.program;
|
|
30
|
+
return `${run.id} — ${program} (${state}, ${count} ${plural})`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The Run a picker label names, or undefined when no Run matches it. */
|
|
34
|
+
export function runFromLabel(
|
|
35
|
+
runs: readonly PickableRun[],
|
|
36
|
+
label: string | undefined,
|
|
37
|
+
): PickableRun | undefined {
|
|
38
|
+
if (label === undefined) return undefined;
|
|
39
|
+
return runs.find((run) => runPickerLabel(run) === label);
|
|
40
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { EndedRunSummary, RunSummary } from "@yaag/runtime";
|
|
2
|
+
import type { RunOutcome } from "./spawn-run.ts";
|
|
3
|
+
|
|
4
|
+
/** A Run in flight, including its stop capability and latest Summary fold. */
|
|
5
|
+
export interface LiveRun {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly stop: () => void;
|
|
8
|
+
readonly outcome: Promise<RunOutcome>;
|
|
9
|
+
summary: RunSummary;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The first outcome observed for a retained Run. */
|
|
13
|
+
export type RunSettlement =
|
|
14
|
+
| { readonly kind: "fulfilled"; readonly outcome: RunOutcome }
|
|
15
|
+
| { readonly kind: "rejected"; readonly reason: unknown };
|
|
16
|
+
|
|
17
|
+
/** A completed Run retained for the lifetime of its Host Session. */
|
|
18
|
+
export interface FinishedRun {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly summary: RunSummary;
|
|
21
|
+
readonly outcome: RunSettlement;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The retained state of a registered Run. */
|
|
25
|
+
export type RegisteredRun =
|
|
26
|
+
| { readonly state: "live"; readonly run: LiveRun }
|
|
27
|
+
| { readonly state: "finished"; readonly run: FinishedRun };
|
|
28
|
+
|
|
29
|
+
/** What the registry knows about an id. */
|
|
30
|
+
export type RunStatus = RegisteredRun | { readonly state: "unknown" };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Per-session source of truth for every Run started by the Host Session.
|
|
34
|
+
*
|
|
35
|
+
* It mints monotonically increasing ids, retains live stop capabilities, and
|
|
36
|
+
* preserves final outcomes for the rest of the session. Concurrent Runs remain
|
|
37
|
+
* independently controllable (ADR-0008).
|
|
38
|
+
*/
|
|
39
|
+
export class RunRegistry {
|
|
40
|
+
#counter = 0;
|
|
41
|
+
readonly #live = new Map<string, LiveRun>();
|
|
42
|
+
readonly #runs = new Map<string, RegisteredRun>();
|
|
43
|
+
|
|
44
|
+
/** The next Run id for this session; it is never reused. */
|
|
45
|
+
mint(): string {
|
|
46
|
+
this.#counter += 1;
|
|
47
|
+
return `r${this.#counter}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Every currently-live Run, in registration order. */
|
|
51
|
+
get live(): readonly LiveRun[] {
|
|
52
|
+
return [...this.#live.values()];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Every registered Run, live and finished, in registration order. */
|
|
56
|
+
get runs(): readonly RegisteredRun[] {
|
|
57
|
+
return [...this.#runs.values()];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Every registered Run id, in registration order. */
|
|
61
|
+
get knownIds(): readonly string[] {
|
|
62
|
+
return [...this.#runs.keys()];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Registers a newly-started Run and synchronously observes its settlement. */
|
|
66
|
+
add(run: LiveRun): void {
|
|
67
|
+
this.#live.set(run.id, run);
|
|
68
|
+
this.#runs.set(run.id, { state: "live", run });
|
|
69
|
+
void run.outcome.then(
|
|
70
|
+
(outcome) => this.finish(run.id, outcome),
|
|
71
|
+
(reason: unknown) => this.reject(run.id, reason),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Looks up a live capability or the retained completed Run. */
|
|
76
|
+
lookup(id: string): RunStatus {
|
|
77
|
+
return this.#runs.get(id) ?? { state: "unknown" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Retains a Run's first observed final outcome and removes only its live
|
|
82
|
+
* capability. Repeated observers are harmless and cannot overwrite history.
|
|
83
|
+
*/
|
|
84
|
+
finish(id: string, outcome: RunOutcome): void {
|
|
85
|
+
this.#settle(id, outcome.summary, { kind: "fulfilled", outcome });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Retains a rejected child outcome with its latest terminal accounting projection. */
|
|
89
|
+
reject(id: string, reason: unknown): void {
|
|
90
|
+
const record = this.#runs.get(id);
|
|
91
|
+
if (record === undefined || record.state === "finished") return;
|
|
92
|
+
this.#settle(id, failedSummary(record.run.summary), { kind: "rejected", reason });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#settle(id: string, summary: RunSummary, outcome: RunSettlement): void {
|
|
96
|
+
const record = this.#runs.get(id);
|
|
97
|
+
if (record === undefined || record.state === "finished") return;
|
|
98
|
+
this.#live.delete(id);
|
|
99
|
+
this.#runs.set(id, { state: "finished", run: { id, summary, outcome } });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function failedSummary(summary: RunSummary): EndedRunSummary {
|
|
104
|
+
if (summary.runState === "ended")
|
|
105
|
+
return summary.ok ? { ...summary, outcome: "failed", ok: false } : summary;
|
|
106
|
+
return { ...summary, runState: "ended", outcome: "failed", ok: false, endedAt: null };
|
|
107
|
+
}
|