pi-taskflow 0.0.21 → 0.0.23
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 +91 -0
- package/README.md +174 -46
- package/extensions/approval-view.ts +11 -57
- package/extensions/context-store.ts +447 -0
- package/extensions/index.ts +142 -3
- package/extensions/interpolate.ts +18 -7
- package/extensions/runner.ts +96 -3
- package/extensions/runs-view.ts +69 -3
- package/extensions/runtime.ts +331 -16
- package/extensions/schema.ts +34 -6
- package/extensions/store.ts +17 -4
- package/extensions/workspace.ts +206 -0
- package/package.json +6 -2
- package/skills/taskflow/SKILL.md +104 -0
package/extensions/index.ts
CHANGED
|
@@ -44,6 +44,16 @@ import {
|
|
|
44
44
|
} from "./store.ts";
|
|
45
45
|
import { CacheStore } from "./cache.ts";
|
|
46
46
|
import { safeParse } from "./interpolate.ts";
|
|
47
|
+
import {
|
|
48
|
+
isValidKey,
|
|
49
|
+
queueSpawn,
|
|
50
|
+
readVisibleFindings,
|
|
51
|
+
readTree,
|
|
52
|
+
nodeDepth,
|
|
53
|
+
writeFinding,
|
|
54
|
+
writeReport,
|
|
55
|
+
} from "./context-store.ts";
|
|
56
|
+
import { MAX_DYNAMIC_NESTING } from "./schema.ts";
|
|
47
57
|
|
|
48
58
|
interface TaskflowDetails {
|
|
49
59
|
state?: RunState;
|
|
@@ -206,7 +216,6 @@ async function runFlow(
|
|
|
206
216
|
},
|
|
207
217
|
done,
|
|
208
218
|
() => tui.terminal.rows,
|
|
209
|
-
tui.terminal,
|
|
210
219
|
);
|
|
211
220
|
const onAbort = () => done("reject");
|
|
212
221
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
@@ -293,6 +302,21 @@ async function runFlow(
|
|
|
293
302
|
}
|
|
294
303
|
|
|
295
304
|
export default function (pi: ExtensionAPI) {
|
|
305
|
+
// ---- Dual identity ----------------------------------------------------
|
|
306
|
+
// When this extension is loaded INSIDE a subagent process that the taskflow
|
|
307
|
+
// runtime spawned with Shared Context Tree enabled, PI_TASKFLOW_CTX_DIR +
|
|
308
|
+
// PI_TASKFLOW_NODE_ID are present. In that case we register the ctx_* tools
|
|
309
|
+
// (the blackboard + supervision API) instead of the host `taskflow` tool —
|
|
310
|
+
// a subagent has no business orchestrating its own taskflows, and the host
|
|
311
|
+
// tool's heavy machinery is irrelevant there. When the env is absent we are
|
|
312
|
+
// the host: register `taskflow` + `/tf` exactly as before (zero change).
|
|
313
|
+
const ctxDir = process.env.PI_TASKFLOW_CTX_DIR;
|
|
314
|
+
const nodeId = process.env.PI_TASKFLOW_NODE_ID;
|
|
315
|
+
if (ctxDir && nodeId) {
|
|
316
|
+
registerCtxTools(pi, ctxDir, nodeId);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
296
320
|
// ---- Register per-saved-flow shortcut commands on session start ----
|
|
297
321
|
const registerSavedFlowCommands = (ctx: ExtensionContext) => {
|
|
298
322
|
const flows = listFlows(ctx.cwd);
|
|
@@ -799,8 +823,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
799
823
|
);
|
|
800
824
|
return;
|
|
801
825
|
}
|
|
802
|
-
const result = await ctx.ui.custom<RunHistoryResult | undefined>((
|
|
803
|
-
|
|
826
|
+
const result = await ctx.ui.custom<RunHistoryResult | undefined>((tui, theme, _kb, done) => {
|
|
827
|
+
const comp = new RunHistoryComponent(runs, theme, (r) => done(r), {
|
|
828
|
+
refresh: () => listRuns(ctx.cwd, 50),
|
|
829
|
+
requestRender: () => tui.requestRender(),
|
|
830
|
+
intervalMs: 1000,
|
|
831
|
+
});
|
|
832
|
+
return comp;
|
|
804
833
|
});
|
|
805
834
|
if (result?.action === "resume") {
|
|
806
835
|
if (ctx.isIdle()) {
|
|
@@ -908,6 +937,116 @@ export default function (pi: ExtensionAPI) {
|
|
|
908
937
|
|
|
909
938
|
// --- helpers ---
|
|
910
939
|
|
|
940
|
+
/**
|
|
941
|
+
* Register the Shared Context Tree tools inside a subagent process. These read
|
|
942
|
+
* & write the per-run blackboard at `ctxDir` on behalf of node `nodeId`.
|
|
943
|
+
*
|
|
944
|
+
* - ctx_read : read findings visible to this node (own + ancestors + completed others)
|
|
945
|
+
* - ctx_write : write a finding (last-write-wins per key) so siblings can reuse it
|
|
946
|
+
* - ctx_report : report a result upward to the parent
|
|
947
|
+
* - ctx_spawn : queue child tasks the runtime picks up after this node finishes
|
|
948
|
+
*/
|
|
949
|
+
function registerCtxTools(pi: ExtensionAPI, ctxDir: string, nodeId: string) {
|
|
950
|
+
const textResult = (text: string, isError = false): ToolResult => ({
|
|
951
|
+
content: [{ type: "text", text }],
|
|
952
|
+
details: { action: "ctx" },
|
|
953
|
+
...(isError ? { isError: true } : {}),
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
pi.registerTool({
|
|
957
|
+
name: "ctx_read",
|
|
958
|
+
label: "Context Read",
|
|
959
|
+
description:
|
|
960
|
+
"Read shared findings from the taskflow blackboard (what sibling/ancestor agents already discovered). Pass a key to read one value, or omit to list all visible findings. Use this BEFORE re-reading files another agent may have already mapped.",
|
|
961
|
+
parameters: Type.Object({
|
|
962
|
+
key: Type.Optional(Type.String({ description: "Specific finding key to read; omit to get all visible findings." })),
|
|
963
|
+
}),
|
|
964
|
+
async execute(_id, params) {
|
|
965
|
+
try {
|
|
966
|
+
const out = readVisibleFindings(ctxDir, nodeId, params.key);
|
|
967
|
+
return textResult(typeof out === "string" ? out : JSON.stringify(out ?? null, null, 2));
|
|
968
|
+
} catch (e) {
|
|
969
|
+
return textResult(`ctx_read failed: ${e instanceof Error ? e.message : String(e)}`, true);
|
|
970
|
+
}
|
|
971
|
+
},
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
pi.registerTool({
|
|
975
|
+
name: "ctx_write",
|
|
976
|
+
label: "Context Write",
|
|
977
|
+
description:
|
|
978
|
+
"Write a finding to the shared taskflow blackboard so sibling/descendant agents can reuse it without re-reading files. Key must be [A-Za-z0-9._-] (<=128 chars). Value is any JSON. Last write wins per key.",
|
|
979
|
+
parameters: Type.Object({
|
|
980
|
+
key: Type.String({ description: "Finding key, e.g. 'endpoints' or 'auth.summary'." }),
|
|
981
|
+
value: Type.Unknown({ description: "The value to store (string, number, object, or array)." }),
|
|
982
|
+
}),
|
|
983
|
+
async execute(_id, params) {
|
|
984
|
+
if (!isValidKey(params.key)) {
|
|
985
|
+
return textResult(`ctx_write rejected: invalid key '${params.key}'.`, true);
|
|
986
|
+
}
|
|
987
|
+
try {
|
|
988
|
+
writeFinding(ctxDir, nodeId, params.key, params.value);
|
|
989
|
+
return textResult(`Stored finding '${params.key}'.`);
|
|
990
|
+
} catch (e) {
|
|
991
|
+
return textResult(`ctx_write failed: ${e instanceof Error ? e.message : String(e)}`, true);
|
|
992
|
+
}
|
|
993
|
+
},
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
pi.registerTool({
|
|
997
|
+
name: "ctx_report",
|
|
998
|
+
label: "Context Report",
|
|
999
|
+
description:
|
|
1000
|
+
"Report your result upward to the parent task. Provide a concise summary and optional structured JSON. The parent (and downstream phases) will see this report.",
|
|
1001
|
+
parameters: Type.Object({
|
|
1002
|
+
summary: Type.String({ description: "Concise summary of what you accomplished / found." }),
|
|
1003
|
+
structured: Type.Optional(Type.Unknown({ description: "Optional structured result (JSON)." })),
|
|
1004
|
+
}),
|
|
1005
|
+
async execute(_id, params) {
|
|
1006
|
+
try {
|
|
1007
|
+
writeReport(ctxDir, nodeId, params.summary, params.structured);
|
|
1008
|
+
return textResult("Report recorded.");
|
|
1009
|
+
} catch (e) {
|
|
1010
|
+
return textResult(`ctx_report failed: ${e instanceof Error ? e.message : String(e)}`, true);
|
|
1011
|
+
}
|
|
1012
|
+
},
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
pi.registerTool({
|
|
1016
|
+
name: "ctx_spawn",
|
|
1017
|
+
label: "Context Spawn",
|
|
1018
|
+
description:
|
|
1019
|
+
"Delegate sub-tasks to NEW child agents. After you finish, the runtime runs each child (isolated context) and folds their reports back into your output. Use when you discover the work needs to fan out. Each assignment is EITHER {task, agent?} for one flat task, OR {subflow, defaultAgent?} where subflow is an inline plan {phases:[...]} (a dependency-bearing DAG: phases can use dependsOn / map / gate / reduce). Use a subflow when the delegated work itself has multiple coordinated steps.",
|
|
1020
|
+
parameters: Type.Object({
|
|
1021
|
+
assignments: Type.Array(
|
|
1022
|
+
Type.Object({
|
|
1023
|
+
task: Type.Optional(Type.String({ description: "A single child task prompt (use this OR subflow, not both)." })),
|
|
1024
|
+
agent: Type.Optional(Type.String({ description: "Agent name for a flat task (optional)." })),
|
|
1025
|
+
subflow: Type.Optional(Type.Unknown({ description: "An inline Taskflow plan {phases:[...]} or a bare phases array, run as a nested validated sub-flow." })),
|
|
1026
|
+
defaultAgent: Type.Optional(Type.String({ description: "Fallback agent for subflow phases that don't name their own (optional)." })),
|
|
1027
|
+
}),
|
|
1028
|
+
{ description: "Child tasks to spawn (1..16). Each is a flat {task} or a {subflow} DAG." },
|
|
1029
|
+
),
|
|
1030
|
+
}),
|
|
1031
|
+
async execute(_id, params) {
|
|
1032
|
+
// Depth cap: walk the parent chain in the tree to find this node's depth.
|
|
1033
|
+
try {
|
|
1034
|
+
const depth = nodeDepth(readTree(ctxDir), nodeId);
|
|
1035
|
+
if (depth >= MAX_DYNAMIC_NESTING) {
|
|
1036
|
+
return textResult(
|
|
1037
|
+
`ctx_spawn rejected: depth ${depth} >= MAX_DYNAMIC_NESTING (${MAX_DYNAMIC_NESTING}). Do the work yourself.`,
|
|
1038
|
+
true,
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
const n = queueSpawn(ctxDir, nodeId, params.assignments);
|
|
1042
|
+
return textResult(`Queued ${n} child task(s); they will run after you finish and their reports will be appended to your output.`);
|
|
1043
|
+
} catch (e) {
|
|
1044
|
+
return textResult(`ctx_spawn failed: ${e instanceof Error ? e.message : String(e)}`, true);
|
|
1045
|
+
}
|
|
1046
|
+
},
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
|
|
911
1050
|
function errorResult(action: string, message: string): ToolResult {
|
|
912
1051
|
return {
|
|
913
1052
|
content: [{ type: "text", text: message }],
|
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
* {previous.output} alias for the immediately-preceding completed phase output
|
|
9
9
|
* {item} / {item.f} map loop variable (or custom name via phase.as)
|
|
10
10
|
*
|
|
11
|
-
* Unknown placeholders are left intact
|
|
12
|
-
*
|
|
11
|
+
* Unknown placeholders are left intact rather than throwing, so a
|
|
12
|
+
* partially-specified task still runs. The unresolved refs are returned in
|
|
13
|
+
* `missing[]`; the runtime surfaces them as a phase warning (see
|
|
14
|
+
* `warnUnresolvedRefs` in runtime.ts) — logged and persisted to
|
|
15
|
+
* `PhaseState.warnings`.
|
|
13
16
|
*/
|
|
14
17
|
|
|
15
18
|
export interface InterpolationContext {
|
|
@@ -123,13 +126,21 @@ export function safeParse(text: string): unknown {
|
|
|
123
126
|
} catch {
|
|
124
127
|
// noop
|
|
125
128
|
}
|
|
126
|
-
// Extract from
|
|
127
|
-
|
|
128
|
-
|
|
129
|
+
// Extract from fenced blocks. Outputs often contain multiple fences
|
|
130
|
+
// (e.g. a ```typescript evidence block before the ```json payload), so try
|
|
131
|
+
// every fence — json-tagged blocks first, then untagged/other blocks.
|
|
132
|
+
const fenceRe = /```(\w*)[ \t]*\r?\n?([\s\S]*?)```/g;
|
|
133
|
+
const fenced: { lang: string; body: string }[] = [];
|
|
134
|
+
let fm: RegExpExecArray | null;
|
|
135
|
+
while ((fm = fenceRe.exec(trimmed)) !== null) {
|
|
136
|
+
fenced.push({ lang: fm[1].toLowerCase(), body: fm[2].trim() });
|
|
137
|
+
}
|
|
138
|
+
const ordered = [...fenced.filter((b) => b.lang === "json"), ...fenced.filter((b) => b.lang !== "json")];
|
|
139
|
+
for (const block of ordered) {
|
|
129
140
|
try {
|
|
130
|
-
return JSON.parse(
|
|
141
|
+
return JSON.parse(block.body);
|
|
131
142
|
} catch {
|
|
132
|
-
// noop
|
|
143
|
+
// noop — try the next fence
|
|
133
144
|
}
|
|
134
145
|
}
|
|
135
146
|
// Extract the first balanced [...] or {...}
|
package/extensions/runner.ts
CHANGED
|
@@ -60,6 +60,14 @@ export interface RunOptions {
|
|
|
60
60
|
* the prior behaviour (no idle timeout). Defaults to DEFAULT_IDLE_TIMEOUT_MS.
|
|
61
61
|
*/
|
|
62
62
|
idleTimeoutMs?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Shared Context Tree (opt-in). When set, the spawned subagent receives
|
|
65
|
+
* PI_TASKFLOW_CTX_DIR + PI_TASKFLOW_NODE_ID in its environment and is loaded
|
|
66
|
+
* with this extension via `--extension`, so it can register the ctx_* tools
|
|
67
|
+
* (read/write/report/spawn) that read & write the per-run blackboard.
|
|
68
|
+
*/
|
|
69
|
+
ctxDir?: string;
|
|
70
|
+
nodeId?: string;
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
/**
|
|
@@ -71,6 +79,41 @@ export interface RunOptions {
|
|
|
71
79
|
*/
|
|
72
80
|
const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60_000;
|
|
73
81
|
|
|
82
|
+
/** The Shared Context Tree tool names a subagent may call when sharing is on. */
|
|
83
|
+
export const CTX_TOOL_NAMES = ["ctx_read", "ctx_write", "ctx_report", "ctx_spawn"] as const;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Guidance appended to a subagent's system prompt when the Shared Context Tree
|
|
87
|
+
* is enabled for its phase. Registering the ctx_* tools makes them AVAILABLE;
|
|
88
|
+
* this block is what makes the model actually USE them with the right discipline
|
|
89
|
+
* (read-before-you-explore; publish reusable findings; report up; delegate when
|
|
90
|
+
* work fans out). Kept short and imperative on purpose.
|
|
91
|
+
*/
|
|
92
|
+
export const CTX_TOOLS_GUIDANCE = [
|
|
93
|
+
"## Shared Context Tree (you are part of a coordinated team of agents)",
|
|
94
|
+
"",
|
|
95
|
+
"You are one agent in a tree working a shared goal, with a shared blackboard",
|
|
96
|
+
"and an upward report channel. Use these tools deliberately \u2014 they save tokens",
|
|
97
|
+
"and prevent the team from duplicating work:",
|
|
98
|
+
"",
|
|
99
|
+
"- ctx_read(key?): BEFORE exploring the codebase or re-reading files, call",
|
|
100
|
+
" ctx_read with no arguments to see what teammates already discovered. If a",
|
|
101
|
+
" finding you need already exists, REUSE it instead of re-deriving it.",
|
|
102
|
+
"- ctx_write(key, value): when you discover something other agents will likely",
|
|
103
|
+
" need (a file map, an endpoint list, an interface, a config value), publish it",
|
|
104
|
+
" under a short key (e.g. 'endpoints', 'db.schema'). Keep values concise and",
|
|
105
|
+
" structured (JSON) so others can consume them directly.",
|
|
106
|
+
"- ctx_report(summary, structured?): when you finish, report your result upward",
|
|
107
|
+
" so the parent task and downstream steps can see it. Lead with the outcome.",
|
|
108
|
+
"- ctx_spawn(assignments[]): if you discover the work should fan out into",
|
|
109
|
+
" independent sub-tasks, delegate them as child agents. They run after you",
|
|
110
|
+
" finish and their reports are folded back into your output. Only spawn when it",
|
|
111
|
+
" genuinely parallelizes \u2014 otherwise just do the work yourself.",
|
|
112
|
+
"",
|
|
113
|
+
"Default habit: ctx_read first, do the work (reusing shared findings), ctx_write",
|
|
114
|
+
"anything reusable, then ctx_report your result.",
|
|
115
|
+
].join("\n");
|
|
116
|
+
|
|
74
117
|
export function isFailed(r: RunResult): boolean {
|
|
75
118
|
return r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
|
|
76
119
|
}
|
|
@@ -281,6 +324,25 @@ function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
|
281
324
|
return { command: "pi", args };
|
|
282
325
|
}
|
|
283
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Resolve the path to this extension's entry file, so a spawned subagent can be
|
|
329
|
+
* launched with `--extension <path>` and register the ctx_* tools. Returns
|
|
330
|
+
* undefined if it cannot be resolved (the subagent then simply runs without the
|
|
331
|
+
* ctx tools — fail-open: context sharing degrades to "no sharing").
|
|
332
|
+
*/
|
|
333
|
+
export function ctxExtensionPath(): string | undefined {
|
|
334
|
+
const override = process.env.PI_TASKFLOW_EXT_PATH;
|
|
335
|
+
if (override) return override;
|
|
336
|
+
try {
|
|
337
|
+
const here = path.dirname(new URL(import.meta.url).pathname);
|
|
338
|
+
const entry = path.join(here, "index.ts");
|
|
339
|
+
if (fs.existsSync(entry)) return entry;
|
|
340
|
+
} catch {
|
|
341
|
+
/* fall through */
|
|
342
|
+
}
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
|
|
284
346
|
/**
|
|
285
347
|
* Run a single subagent task. Resolves the agent from `agents` by name and
|
|
286
348
|
* spawns an isolated pi process, returning structured output + usage.
|
|
@@ -310,7 +372,15 @@ export async function runAgentTask(
|
|
|
310
372
|
|
|
311
373
|
const model = opts.model ?? agent.model;
|
|
312
374
|
const thinking = opts.thinking ?? agent.thinking ?? globalThinking;
|
|
313
|
-
const
|
|
375
|
+
const ctxEnabledEarly = Boolean(opts.ctxDir && opts.nodeId);
|
|
376
|
+
let tools = opts.tools ?? agent.tools;
|
|
377
|
+
// If the agent restricts tools to a whitelist, the ctx_* tools we register
|
|
378
|
+
// would be filtered out by `--tools` even though they're registered. When
|
|
379
|
+
// context sharing is on, extend the whitelist so the subagent can actually
|
|
380
|
+
// call them. (No whitelist = all tools available = nothing to do.)
|
|
381
|
+
if (ctxEnabledEarly && tools && tools.length > 0) {
|
|
382
|
+
tools = [...new Set([...tools, ...CTX_TOOL_NAMES])];
|
|
383
|
+
}
|
|
314
384
|
|
|
315
385
|
const args: string[] = ["--mode", "json", "-p", "--no-session"];
|
|
316
386
|
if (model) args.push("--model", model);
|
|
@@ -332,18 +402,40 @@ export async function runAgentTask(
|
|
|
332
402
|
};
|
|
333
403
|
|
|
334
404
|
try {
|
|
335
|
-
|
|
405
|
+
const ctxEnabled = Boolean(opts.ctxDir && opts.nodeId);
|
|
406
|
+
// Build the appended system prompt = the agent's own prompt PLUS, when the
|
|
407
|
+
// Shared Context Tree is enabled for this phase, a guidance block that tells
|
|
408
|
+
// the subagent the ctx_* tools exist and the discipline for using them.
|
|
409
|
+
// Without this the model only sees terse tool descriptions and rarely uses
|
|
410
|
+
// them proactively (capability != usage).
|
|
411
|
+
const appendedPrompt = [agent.systemPrompt.trim(), ctxEnabled ? CTX_TOOLS_GUIDANCE : ""]
|
|
412
|
+
.filter(Boolean)
|
|
413
|
+
.join("\n\n");
|
|
414
|
+
if (appendedPrompt) {
|
|
336
415
|
// Allocate the temp dir + path BEFORE any fallible I/O so that if
|
|
337
416
|
// writeFile throws, tmpPromptDir/tmpPromptPath are already set and
|
|
338
417
|
// the finally block can clean up the directory (F-004).
|
|
339
418
|
tmpPromptDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-taskflow-"));
|
|
340
419
|
const safeName = agent.name.replace(/[^\w.-]+/g, "_");
|
|
341
420
|
tmpPromptPath = path.join(tmpPromptDir, `prompt-${safeName}.md`);
|
|
342
|
-
await writePromptToTempFile(tmpPromptPath,
|
|
421
|
+
await writePromptToTempFile(tmpPromptPath, appendedPrompt);
|
|
343
422
|
args.push("--append-system-prompt", tmpPromptPath);
|
|
344
423
|
}
|
|
345
424
|
args.push(`Task: ${task}`);
|
|
346
425
|
|
|
426
|
+
// Shared Context Tree opt-in: load THIS extension into the subagent so it
|
|
427
|
+
// can register the ctx_* tools, and pass the blackboard dir + node id via
|
|
428
|
+
// env. `--extension` is the explicit, self-documenting fallback that does
|
|
429
|
+
// not rely on the subagent auto-discovering user/project extensions in
|
|
430
|
+
// `-p` mode. The env vars drive the dual-identity branch in index.ts.
|
|
431
|
+
const ctxEnv: Record<string, string> = {};
|
|
432
|
+
if (opts.ctxDir && opts.nodeId) {
|
|
433
|
+
const selfPath = ctxExtensionPath();
|
|
434
|
+
if (selfPath) args.push("--extension", selfPath);
|
|
435
|
+
ctxEnv.PI_TASKFLOW_CTX_DIR = opts.ctxDir;
|
|
436
|
+
ctxEnv.PI_TASKFLOW_NODE_ID = opts.nodeId;
|
|
437
|
+
}
|
|
438
|
+
|
|
347
439
|
let wasAborted = false;
|
|
348
440
|
let idleTimedOut = false;
|
|
349
441
|
let killedBySignal: string | undefined;
|
|
@@ -353,6 +445,7 @@ export async function runAgentTask(
|
|
|
353
445
|
cwd: opts.cwd ?? defaultCwd,
|
|
354
446
|
shell: false,
|
|
355
447
|
stdio: ["ignore", "pipe", "pipe"],
|
|
448
|
+
env: { ...process.env, ...ctxEnv },
|
|
356
449
|
});
|
|
357
450
|
if (proc.pid) activeChildren.add(proc.pid);
|
|
358
451
|
let buffer = "";
|
package/extensions/runs-view.ts
CHANGED
|
@@ -40,6 +40,19 @@ function isResumable(r: RunState): boolean {
|
|
|
40
40
|
return r.status === "paused" || r.status === "failed";
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** Detect whether a refreshed run list differs from the current one in any way
|
|
44
|
+
* the panel renders (status, updatedAt, phase progress, membership). */
|
|
45
|
+
function hasChanged(prev: RunState[], next: RunState[]): boolean {
|
|
46
|
+
if (prev.length !== next.length) return true;
|
|
47
|
+
const byId = new Map(prev.map((r) => [r.runId, r]));
|
|
48
|
+
for (const n of next) {
|
|
49
|
+
const p = byId.get(n.runId);
|
|
50
|
+
if (!p) return true;
|
|
51
|
+
if (p.status !== n.status || p.updatedAt !== n.updatedAt) return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
43
56
|
export class RunHistoryComponent {
|
|
44
57
|
private runs: RunState[];
|
|
45
58
|
private theme: Theme;
|
|
@@ -48,14 +61,62 @@ export class RunHistoryComponent {
|
|
|
48
61
|
private mode: "list" | "detail" = "list";
|
|
49
62
|
private cachedWidth?: number;
|
|
50
63
|
private cachedLines?: string[];
|
|
64
|
+
/** Live-refresh wiring: re-read run state from disk while the panel is open
|
|
65
|
+
* so background (detached) runs show live progress without reopening. */
|
|
66
|
+
private timer?: ReturnType<typeof setInterval>;
|
|
67
|
+
private refresh?: () => RunState[];
|
|
68
|
+
private requestRender?: () => void;
|
|
51
69
|
|
|
52
|
-
constructor(
|
|
70
|
+
constructor(
|
|
71
|
+
runs: RunState[],
|
|
72
|
+
theme: Theme,
|
|
73
|
+
onDone: (result?: RunHistoryResult) => void,
|
|
74
|
+
/** Optional live-refresh hooks. When both are provided the panel polls
|
|
75
|
+
* `refresh()` on an interval and calls `requestRender()` if anything changed. */
|
|
76
|
+
live?: { refresh: () => RunState[]; requestRender: () => void; intervalMs?: number },
|
|
77
|
+
) {
|
|
53
78
|
if (!runs.length) {
|
|
54
79
|
throw new Error("RunHistoryComponent requires at least one run");
|
|
55
80
|
}
|
|
56
81
|
this.runs = runs;
|
|
57
82
|
this.theme = theme;
|
|
58
83
|
this.onDone = onDone;
|
|
84
|
+
if (live) {
|
|
85
|
+
this.refresh = live.refresh;
|
|
86
|
+
this.requestRender = live.requestRender;
|
|
87
|
+
const intervalMs = Math.max(250, live.intervalMs ?? 1000);
|
|
88
|
+
this.timer = setInterval(() => this.poll(), intervalMs);
|
|
89
|
+
// Don't keep the event loop alive just for the panel refresh.
|
|
90
|
+
(this.timer as { unref?: () => void }).unref?.();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Re-read run state; if anything changed, refresh the cached render. */
|
|
95
|
+
private poll(): void {
|
|
96
|
+
if (!this.refresh) return;
|
|
97
|
+
let next: RunState[];
|
|
98
|
+
try {
|
|
99
|
+
next = this.refresh();
|
|
100
|
+
} catch {
|
|
101
|
+
return; // transient read/lock error — try again next tick
|
|
102
|
+
}
|
|
103
|
+
if (!next.length) return;
|
|
104
|
+
if (!hasChanged(this.runs, next)) return;
|
|
105
|
+
// Preserve the user's selection by runId across refreshes.
|
|
106
|
+
const selectedId = this.runs[this.selected]?.runId;
|
|
107
|
+
this.runs = next;
|
|
108
|
+
const idx = next.findIndex((r) => r.runId === selectedId);
|
|
109
|
+
this.selected = idx >= 0 ? idx : Math.min(this.selected, next.length - 1);
|
|
110
|
+
this.invalidate();
|
|
111
|
+
this.requestRender?.();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Stop the refresh timer when the panel closes. */
|
|
115
|
+
dispose(): void {
|
|
116
|
+
if (this.timer) {
|
|
117
|
+
clearInterval(this.timer);
|
|
118
|
+
this.timer = undefined;
|
|
119
|
+
}
|
|
59
120
|
}
|
|
60
121
|
|
|
61
122
|
handleInput(data: string): void {
|
|
@@ -104,7 +165,8 @@ export class RunHistoryComponent {
|
|
|
104
165
|
for (const l of renderProgress(run, th).split("\n")) lines.push(truncateToWidth(l, width));
|
|
105
166
|
lines.push("");
|
|
106
167
|
const hint = isResumable(run) ? "Esc back · r resume" : "Esc back";
|
|
107
|
-
|
|
168
|
+
const liveTag = this.timer && run.status === "running" ? th.fg("success", " ● live") : "";
|
|
169
|
+
lines.push(truncateToWidth(` ${th.fg("dim", hint)}${liveTag}`, width));
|
|
108
170
|
lines.push("");
|
|
109
171
|
this.cachedWidth = width;
|
|
110
172
|
this.cachedLines = lines;
|
|
@@ -129,7 +191,11 @@ export class RunHistoryComponent {
|
|
|
129
191
|
});
|
|
130
192
|
|
|
131
193
|
lines.push("");
|
|
132
|
-
|
|
194
|
+
const anyRunning = this.runs.some((r) => r.status === "running");
|
|
195
|
+
const liveHint = this.timer && anyRunning ? th.fg("success", " ● live") : "";
|
|
196
|
+
lines.push(
|
|
197
|
+
truncateToWidth(` ${th.fg("dim", "↑↓ select · Enter details · r resume · q close")}${liveHint}`, width),
|
|
198
|
+
);
|
|
133
199
|
lines.push("");
|
|
134
200
|
|
|
135
201
|
this.cachedWidth = width;
|