pi-taskflow 0.0.22 → 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.
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Shared Context Tree — the file-based blackboard + supervision-tree store.
3
+ *
4
+ * This module is the IPC substrate that lets isolated subagent processes share
5
+ * context with each other (a horizontal blackboard) and report results upward
6
+ * so a parent can react (a vertical supervision tree). It deliberately reuses
7
+ * the SAME atomic-write + file-lock primitives as the run store (`store.ts`),
8
+ * so it inherits the project's "all file ops are atomic" invariant for free.
9
+ *
10
+ * On-disk layout, rooted at PI_TASKFLOW_CTX_DIR (one directory per run):
11
+ *
12
+ * <ctxDir>/
13
+ * ├── tree.json the node tree (who spawned whom + status)
14
+ * ├── tree.json.lock lock guarding tree.json RMW cycles
15
+ * ├── findings/
16
+ * │ ├── <nodeId>.json findings written by one node (last-write-wins per key)
17
+ * │ └── <nodeId>.json.lock
18
+ * ├── reports/
19
+ * │ └── <nodeId>.json a node's upward report ({summary, structured?})
20
+ * └── pending/
21
+ * └── <nodeId>-<seq>.json a ctx_spawn intent the runtime will pick up
22
+ *
23
+ * Why per-node findings files (not one shared findings.json): sibling subagents
24
+ * run concurrently. Giving each node its OWN file means concurrent writers never
25
+ * contend on the same lock — a node only locks its own file. A reader unions the
26
+ * relevant nodes' files (its ancestors + completed siblings). This is the same
27
+ * "shard by writer" trick the run index uses to avoid a global write bottleneck.
28
+ */
29
+
30
+ import * as crypto from "node:crypto";
31
+ import * as fs from "node:fs";
32
+ import * as path from "node:path";
33
+ import { validateRunId, withLock, writeFileAtomic } from "./store.ts";
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Guards (size + key charset). A subagent is untrusted input from the LLM's
37
+ // point of view; cap what it can write so a runaway tool call can't fill the
38
+ // disk or smuggle a path-traversal key.
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /** Max bytes for a single findings value (after JSON.stringify). */
42
+ export const MAX_VALUE_BYTES = 256 * 1024; // 256 KB
43
+ /** Max bytes for a single report summary string. */
44
+ export const MAX_REPORT_BYTES = 256 * 1024;
45
+ /** Max bytes for a report's structured payload (after JSON.stringify). */
46
+ export const MAX_STRUCTURED_BYTES = 256 * 1024;
47
+ /** Max bytes for a single ctx_spawn task prompt. */
48
+ export const MAX_TASK_BYTES = 64 * 1024;
49
+ /** Max number of keys one node may write. */
50
+ export const MAX_KEYS_PER_NODE = 256;
51
+ /** Max assignments a single ctx_spawn call may queue. */
52
+ export const MAX_SPAWN_ASSIGNMENTS = 16;
53
+ /** Max bytes for a single ctx_spawn `subflow` payload (after JSON.stringify). */
54
+ export const MAX_SUBFLOW_BYTES = 256 * 1024; // 256 KB
55
+
56
+ /** A findings/report key must be a short, traversal-safe token. */
57
+ const KEY_RE = /^[A-Za-z0-9._-]{1,128}$/;
58
+
59
+ export function isValidKey(key: string): boolean {
60
+ return typeof key === "string" && KEY_RE.test(key) && !key.includes("..");
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Types
65
+ // ---------------------------------------------------------------------------
66
+
67
+ export type NodeStatus = "running" | "done" | "failed";
68
+
69
+ /** One node in the supervision tree (one subagent task). */
70
+ export interface TreeNode {
71
+ nodeId: string;
72
+ phaseId: string;
73
+ parentNodeId?: string;
74
+ status: NodeStatus;
75
+ createdAt: number;
76
+ updatedAt: number;
77
+ }
78
+
79
+ export interface ContextTree {
80
+ nodes: TreeNode[];
81
+ }
82
+
83
+ /** A node's findings — a flat string→JSON map. Last-write-wins per key. */
84
+ export type FindingsMap = Record<string, unknown>;
85
+
86
+ export interface NodeReport {
87
+ nodeId: string;
88
+ summary: string;
89
+ structured?: unknown;
90
+ at: number;
91
+ }
92
+
93
+ /**
94
+ * A queued ctx_spawn intent, picked up by the runtime after the node finishes.
95
+ * Each assignment is EITHER a flat task OR an inline sub-flow (DAG) — never both.
96
+ *
97
+ * - `task` : a single prompt string (the agent named by `agent` runs it).
98
+ * - `subflow` : an inline Taskflow ({phases:[...]} or a bare phases array)
99
+ * the runtime validates + runs as a nested sub-flow. Inner
100
+ * phases without their own `agent` fall back to `defaultAgent`.
101
+ *
102
+ * `agent` (flat) means "who executes this task"; `defaultAgent` (subflow) means
103
+ * "fallback agent for inner phases" — different semantics, hence different fields.
104
+ */
105
+ export interface SpawnAssignment {
106
+ task?: string;
107
+ agent?: string;
108
+ subflow?: unknown;
109
+ defaultAgent?: string;
110
+ }
111
+
112
+ export interface PendingSpawn {
113
+ parentNodeId: string;
114
+ assignments: SpawnAssignment[];
115
+ at: number;
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Path helpers
120
+ // ---------------------------------------------------------------------------
121
+
122
+ function treePath(ctxDir: string): string {
123
+ return path.join(ctxDir, "tree.json");
124
+ }
125
+ function treeLockPath(ctxDir: string): string {
126
+ return path.join(ctxDir, "tree.json.lock");
127
+ }
128
+ function findingsDir(ctxDir: string): string {
129
+ return path.join(ctxDir, "findings");
130
+ }
131
+ function findingsPath(ctxDir: string, nodeId: string): string {
132
+ return path.join(findingsDir(ctxDir), `${nodeId}.json`);
133
+ }
134
+ function findingsLockPath(ctxDir: string, nodeId: string): string {
135
+ return path.join(findingsDir(ctxDir), `${nodeId}.json.lock`);
136
+ }
137
+ function reportsDir(ctxDir: string): string {
138
+ return path.join(ctxDir, "reports");
139
+ }
140
+ function reportPath(ctxDir: string, nodeId: string): string {
141
+ return path.join(reportsDir(ctxDir), `${nodeId}.json`);
142
+ }
143
+ function pendingDir(ctxDir: string): string {
144
+ return path.join(ctxDir, "pending");
145
+ }
146
+
147
+ /** Build the per-run ctx directory path under a runs root. */
148
+ export function ctxDirFor(runsRoot: string, runId: string): string {
149
+ if (!validateRunId(runId)) throw new Error(`Unsafe runId for ctx dir: ${runId}`);
150
+ return path.join(runsRoot, "ctx", runId);
151
+ }
152
+
153
+ /**
154
+ * Ensure the ctx directory tree exists. Idempotent; safe to call repeatedly.
155
+ * Returns the same ctxDir for chaining.
156
+ */
157
+ export function initCtxDir(ctxDir: string): string {
158
+ fs.mkdirSync(ctxDir, { recursive: true });
159
+ fs.mkdirSync(findingsDir(ctxDir), { recursive: true });
160
+ fs.mkdirSync(reportsDir(ctxDir), { recursive: true });
161
+ fs.mkdirSync(pendingDir(ctxDir), { recursive: true });
162
+ return ctxDir;
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Tree
167
+ // ---------------------------------------------------------------------------
168
+
169
+ export function readTree(ctxDir: string): ContextTree {
170
+ try {
171
+ const raw = fs.readFileSync(treePath(ctxDir), "utf-8");
172
+ const parsed = JSON.parse(raw) as ContextTree;
173
+ if (parsed && Array.isArray(parsed.nodes)) return parsed;
174
+ } catch {
175
+ /* missing/corrupt → empty tree */
176
+ }
177
+ return { nodes: [] };
178
+ }
179
+
180
+ /**
181
+ * Register (or update) a node in the tree. IDEMPOTENT — upserts by nodeId so a
182
+ * resume that re-runs a phase does not duplicate tree entries (which would
183
+ * double-count ancestor findings). This is the C3 resume-safety fix.
184
+ */
185
+ export function registerNode(
186
+ ctxDir: string,
187
+ nodeId: string,
188
+ phaseId: string,
189
+ parentNodeId: string | undefined,
190
+ status: NodeStatus = "running",
191
+ ): void {
192
+ if (!validateRunId(nodeId)) throw new Error(`Unsafe nodeId: ${nodeId}`);
193
+ withLock(treeLockPath(ctxDir), () => {
194
+ const tree = readTree(ctxDir);
195
+ const now = Date.now();
196
+ const idx = tree.nodes.findIndex((n) => n.nodeId === nodeId);
197
+ if (idx >= 0) {
198
+ const existing = tree.nodes[idx]!;
199
+ tree.nodes[idx] = {
200
+ ...existing,
201
+ phaseId,
202
+ parentNodeId,
203
+ status,
204
+ updatedAt: now,
205
+ };
206
+ } else {
207
+ tree.nodes.push({ nodeId, phaseId, parentNodeId, status, createdAt: now, updatedAt: now });
208
+ }
209
+ writeFileAtomic(treePath(ctxDir), JSON.stringify(tree, null, 2));
210
+ });
211
+ }
212
+
213
+ export function setNodeStatus(ctxDir: string, nodeId: string, status: NodeStatus): void {
214
+ withLock(treeLockPath(ctxDir), () => {
215
+ const tree = readTree(ctxDir);
216
+ const node = tree.nodes.find((n) => n.nodeId === nodeId);
217
+ if (!node) return;
218
+ node.status = status;
219
+ node.updatedAt = Date.now();
220
+ writeFileAtomic(treePath(ctxDir), JSON.stringify(tree, null, 2));
221
+ });
222
+ }
223
+
224
+ /** Compute a node's depth (root = 0) by walking the parent chain. */
225
+ export function nodeDepth(tree: ContextTree, nodeId: string): number {
226
+ let depth = 0;
227
+ let current = tree.nodes.find((n) => n.nodeId === nodeId);
228
+ const seen = new Set<string>();
229
+ while (current?.parentNodeId && !seen.has(current.nodeId)) {
230
+ seen.add(current.nodeId);
231
+ depth++;
232
+ const parentId = current.parentNodeId;
233
+ current = tree.nodes.find((n) => n.nodeId === parentId);
234
+ }
235
+ return depth;
236
+ }
237
+
238
+ /** Return the ancestor chain nodeIds for a node (excluding itself), in nearest-first order (parent, grandparent, …). */
239
+ export function ancestorIds(tree: ContextTree, nodeId: string): string[] {
240
+ const out: string[] = [];
241
+ const seen = new Set<string>([nodeId]);
242
+ let current = tree.nodes.find((n) => n.nodeId === nodeId);
243
+ while (current?.parentNodeId && !seen.has(current.parentNodeId)) {
244
+ out.push(current.parentNodeId);
245
+ seen.add(current.parentNodeId);
246
+ const parentId = current.parentNodeId;
247
+ current = tree.nodes.find((n) => n.nodeId === parentId);
248
+ }
249
+ return out;
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Findings (the horizontal blackboard)
254
+ // ---------------------------------------------------------------------------
255
+
256
+ export function readNodeFindings(ctxDir: string, nodeId: string): FindingsMap {
257
+ if (!validateRunId(nodeId)) return {}; // defense-in-depth: never build a path from an unsafe id
258
+ try {
259
+ const raw = fs.readFileSync(findingsPath(ctxDir, nodeId), "utf-8");
260
+ const parsed = JSON.parse(raw) as FindingsMap;
261
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
262
+ } catch {
263
+ /* missing/corrupt → empty */
264
+ }
265
+ return {};
266
+ }
267
+
268
+ /**
269
+ * Write one finding (last-write-wins per key) into THIS node's findings file.
270
+ * Only locks the node's own file → concurrent siblings never contend.
271
+ * Throws on bad key / oversized value / too many keys (caller surfaces as tool error).
272
+ */
273
+ export function writeFinding(ctxDir: string, nodeId: string, key: string, value: unknown): void {
274
+ if (!validateRunId(nodeId)) throw new Error(`Unsafe nodeId: ${nodeId}`);
275
+ if (!isValidKey(key)) throw new Error(`Invalid finding key '${key}' (allowed: [A-Za-z0-9._-], <=128 chars, no '..').`);
276
+ const serialized = JSON.stringify(value ?? null);
277
+ if (Buffer.byteLength(serialized, "utf-8") > MAX_VALUE_BYTES) {
278
+ throw new Error(`Finding '${key}' exceeds ${MAX_VALUE_BYTES} bytes.`);
279
+ }
280
+ fs.mkdirSync(findingsDir(ctxDir), { recursive: true });
281
+ withLock(findingsLockPath(ctxDir, nodeId), () => {
282
+ const findings = readNodeFindings(ctxDir, nodeId);
283
+ if (!(key in findings) && Object.keys(findings).length >= MAX_KEYS_PER_NODE) {
284
+ throw new Error(`Node '${nodeId}' exceeds ${MAX_KEYS_PER_NODE} findings keys.`);
285
+ }
286
+ findings[key] = JSON.parse(serialized);
287
+ writeFileAtomic(findingsPath(ctxDir, nodeId), JSON.stringify(findings, null, 2));
288
+ });
289
+ }
290
+
291
+ /**
292
+ * Read the findings visible to a node: its OWN findings unioned with its
293
+ * ancestors' and all sibling/other nodes' findings that are already `done`.
294
+ * "done" visibility prevents reading a half-written blackboard from a sibling
295
+ * that is still running (eventual consistency: you see a sibling's findings
296
+ * once it has reported completion). The node's own findings are always visible.
297
+ *
298
+ * On key conflicts: nearer scope wins (own > ancestors > completed others),
299
+ * matching intuition that a node trusts its own/closer notes most.
300
+ *
301
+ * @param key optional — return only that key's value (or undefined).
302
+ */
303
+ export function readVisibleFindings(
304
+ ctxDir: string,
305
+ nodeId: string,
306
+ key?: string,
307
+ ): FindingsMap | unknown {
308
+ if (!validateRunId(nodeId)) return key !== undefined ? undefined : {};
309
+ const tree = readTree(ctxDir);
310
+ const ancestors = new Set(ancestorIds(tree, nodeId));
311
+ // Build layered maps; merge order = lowest priority first.
312
+ const completedOthers: FindingsMap = {};
313
+ const ancestorFindings: FindingsMap = {};
314
+ for (const n of tree.nodes) {
315
+ if (n.nodeId === nodeId) continue;
316
+ const f = readNodeFindings(ctxDir, n.nodeId);
317
+ if (ancestors.has(n.nodeId)) {
318
+ Object.assign(ancestorFindings, f);
319
+ } else if (n.status === "done") {
320
+ Object.assign(completedOthers, f);
321
+ }
322
+ }
323
+ const own = readNodeFindings(ctxDir, nodeId);
324
+ const merged: FindingsMap = { ...completedOthers, ...ancestorFindings, ...own };
325
+ if (key !== undefined) {
326
+ if (!isValidKey(key)) return undefined;
327
+ return merged[key];
328
+ }
329
+ return merged;
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Reports (the vertical upward channel)
334
+ // ---------------------------------------------------------------------------
335
+
336
+ export function writeReport(ctxDir: string, nodeId: string, summary: string, structured?: unknown): void {
337
+ if (!validateRunId(nodeId)) throw new Error(`Unsafe nodeId: ${nodeId}`);
338
+ if (Buffer.byteLength(String(summary ?? ""), "utf-8") > MAX_REPORT_BYTES) {
339
+ throw new Error(`Report summary exceeds ${MAX_REPORT_BYTES} bytes.`);
340
+ }
341
+ if (structured !== undefined && Buffer.byteLength(JSON.stringify(structured ?? null), "utf-8") > MAX_STRUCTURED_BYTES) {
342
+ throw new Error(`Report 'structured' payload exceeds ${MAX_STRUCTURED_BYTES} bytes.`);
343
+ }
344
+ fs.mkdirSync(reportsDir(ctxDir), { recursive: true });
345
+ // No lock: each node owns its own report file and is a single process, so the
346
+ // pure-overwrite writeFileAtomic is race-free here (unlike findings, which do
347
+ // read-modify-write and therefore lock).
348
+ const report: NodeReport = { nodeId, summary: String(summary ?? ""), structured, at: Date.now() };
349
+ writeFileAtomic(reportPath(ctxDir, nodeId), JSON.stringify(report, null, 2));
350
+ }
351
+
352
+ export function readReport(ctxDir: string, nodeId: string): NodeReport | undefined {
353
+ if (!validateRunId(nodeId)) return undefined;
354
+ try {
355
+ const raw = fs.readFileSync(reportPath(ctxDir, nodeId), "utf-8");
356
+ const parsed = JSON.parse(raw) as NodeReport;
357
+ if (parsed && typeof parsed.summary === "string") return parsed;
358
+ } catch {
359
+ /* none */
360
+ }
361
+ return undefined;
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Pending spawns (ctx_spawn intents → runtime supervision loop)
366
+ // ---------------------------------------------------------------------------
367
+
368
+ /** Queue a ctx_spawn intent. Each call writes a unique file the runtime picks up. */
369
+ export function queueSpawn(ctxDir: string, parentNodeId: string, assignments: SpawnAssignment[]): number {
370
+ if (!validateRunId(parentNodeId)) throw new Error(`Unsafe nodeId: ${parentNodeId}`);
371
+ if (!Array.isArray(assignments) || assignments.length === 0) {
372
+ throw new Error("ctx_spawn requires a non-empty assignments array.");
373
+ }
374
+ if (assignments.length > MAX_SPAWN_ASSIGNMENTS) {
375
+ throw new Error(`ctx_spawn limited to ${MAX_SPAWN_ASSIGNMENTS} assignments per call.`);
376
+ }
377
+ const clean: SpawnAssignment[] = assignments.map((a) => {
378
+ if (!a || typeof a !== "object") {
379
+ throw new Error("Each ctx_spawn assignment must be an object with 'task' or 'subflow'.");
380
+ }
381
+ const hasTask = typeof a.task === "string" && a.task.trim().length > 0;
382
+ const hasSubflow = a.subflow !== undefined && a.subflow !== null;
383
+ // XOR: exactly one of task / subflow. Check subflow first so a pure-subflow
384
+ // assignment (no `task`) is never rejected by the task-required branch.
385
+ if (hasSubflow) {
386
+ if (hasTask) {
387
+ throw new Error("A ctx_spawn assignment has both 'task' and 'subflow' — provide exactly one.");
388
+ }
389
+ const bytes = Buffer.byteLength(JSON.stringify(a.subflow), "utf-8");
390
+ if (bytes > MAX_SUBFLOW_BYTES) {
391
+ throw new Error(`ctx_spawn subflow exceeds ${MAX_SUBFLOW_BYTES} bytes.`);
392
+ }
393
+ return { subflow: a.subflow, defaultAgent: typeof a.defaultAgent === "string" ? a.defaultAgent : undefined };
394
+ }
395
+ if (hasTask) {
396
+ if (Buffer.byteLength(a.task as string, "utf-8") > MAX_TASK_BYTES) {
397
+ throw new Error(`ctx_spawn task exceeds ${MAX_TASK_BYTES} bytes.`);
398
+ }
399
+ return { task: a.task as string, agent: typeof a.agent === "string" ? a.agent : undefined };
400
+ }
401
+ throw new Error("Each ctx_spawn assignment needs exactly one of 'task' (non-empty string) or 'subflow' (object).");
402
+ });
403
+ fs.mkdirSync(pendingDir(ctxDir), { recursive: true });
404
+ // Unique per call: time + crypto-random so two concurrent queueSpawn calls
405
+ // from the same parent in the same ms cannot collide (and overwrite).
406
+ const seq = `${Date.now().toString(36)}-${crypto.randomBytes(6).toString("hex")}`;
407
+ const payload: PendingSpawn = { parentNodeId, assignments: clean, at: Date.now() };
408
+ writeFileAtomic(path.join(pendingDir(ctxDir), `${parentNodeId}-${seq}.json`), JSON.stringify(payload, null, 2));
409
+ return clean.length;
410
+ }
411
+
412
+ /**
413
+ * Drain (read + delete) all pending spawn intents queued by a parent node.
414
+ * Returns the flattened assignment list. Used by the runtime supervision loop
415
+ * after a node's subagent finishes.
416
+ *
417
+ * INVARIANT: only call this AFTER the parent subagent process has exited. The
418
+ * read-then-unlink is not directory-locked, so a concurrent queueSpawn from a
419
+ * still-running parent could be missed. The runtime drains post-exit, so no
420
+ * concurrent writer exists.
421
+ */
422
+ export function drainPendingSpawns(ctxDir: string, parentNodeId: string): SpawnAssignment[] {
423
+ if (!validateRunId(parentNodeId)) return [];
424
+ const dir = pendingDir(ctxDir);
425
+ let files: string[];
426
+ try {
427
+ files = fs.readdirSync(dir).filter((f) => f.startsWith(`${parentNodeId}-`) && f.endsWith(".json"));
428
+ } catch {
429
+ return [];
430
+ }
431
+ const out: SpawnAssignment[] = [];
432
+ for (const f of files.sort()) {
433
+ const full = path.join(dir, f);
434
+ try {
435
+ const parsed = JSON.parse(fs.readFileSync(full, "utf-8")) as PendingSpawn;
436
+ if (parsed && Array.isArray(parsed.assignments)) out.push(...parsed.assignments);
437
+ } catch {
438
+ /* skip corrupt */
439
+ }
440
+ try {
441
+ fs.unlinkSync(full);
442
+ } catch {
443
+ /* already gone */
444
+ }
445
+ }
446
+ return out;
447
+ }
@@ -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;
@@ -292,6 +302,21 @@ async function runFlow(
292
302
  }
293
303
 
294
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
+
295
320
  // ---- Register per-saved-flow shortcut commands on session start ----
296
321
  const registerSavedFlowCommands = (ctx: ExtensionContext) => {
297
322
  const flows = listFlows(ctx.cwd);
@@ -912,6 +937,116 @@ export default function (pi: ExtensionAPI) {
912
937
 
913
938
  // --- helpers ---
914
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
+
915
1050
  function errorResult(action: string, message: string): ToolResult {
916
1051
  return {
917
1052
  content: [{ type: "text", text: message }],