@buihongduc132/pi-acp-agents 0.3.1 → 0.4.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/index.ts CHANGED
@@ -3,13 +3,14 @@ import { join } from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import type { AgentToolResult, ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
5
  import { Type } from "typebox";
6
- import { type AcpWidgetState, createAcpWidget } from "./src/acp-widget.js";
6
+ import { type AcpWidgetState, type AcpWidgetDag, createAcpWidget, dagIndexEntryToWidgetDag } from "./src/acp-widget.js";
7
7
  import { createAdapter } from "./src/adapter-factory.js";
8
8
  import { loadConfig } from "./src/config/config.js";
9
- import type { AcpArchivedSessionMetadata, AcpConfig, AcpPromptResult, AcpSessionHandle, AcpWorkerStatus } from "./src/config/types.js";
9
+ import type { AcpArchivedSessionMetadata, AcpConfig, AcpPromptResult, AcpSessionHandle, AcpWorkerStatus, DagIndexEntry } from "./src/config/types.js";
10
10
  import { AgentCoordinator } from "./src/coordination/coordinator.js";
11
11
  import { WorkerDispatcher, type WorkerDispatcherDeps } from "./src/coordination/worker-dispatcher.js";
12
12
  import { AcpCircuitBreaker } from "./src/core/circuit-breaker.js";
13
+ import { AsyncExecutor } from "./src/core/async-executor.js";
13
14
  import { HealthMonitor } from "./src/core/health-monitor.js";
14
15
  import { getSessionAutoCloseReason } from "./src/core/session-lifecycle.js";
15
16
  import { SessionManager } from "./src/core/session-manager.js";
@@ -22,7 +23,12 @@ import { AcpTaskStore, type AcpTaskStatus } from "./src/management/task-store.js
22
23
  import { WorkerStore } from "./src/management/worker-store.js";
23
24
  import { SessionArchiveStore } from "./src/management/session-archive-store.js";
24
25
  import { SessionNameStore } from "./src/management/session-name-store.js";
26
+ import { SessionStoreFactory } from "./src/management/session-store-factory.js";
25
27
  import { ensureRuntimeDir } from "./src/management/runtime-paths.js";
28
+ import { DagStore } from "./src/dag/dag-store.js";
29
+ import { DagValidator } from "./src/dag/dag-validator.js";
30
+ import { DagExecutor, type DagCancelSummary } from "./src/dag/dag-executor.js";
31
+ import { TemplateResolver } from "./src/dag/template-resolver.js";
26
32
  import { loadSettings, isToolEnabled, type AcpToolSettings } from "./src/settings/config.js";
27
33
  import { configureToolSettings } from "./src/settings/configure-tui.js";
28
34
 
@@ -81,13 +87,33 @@ export default function (pi: ExtensionAPI) {
81
87
  const logger = createFileLogger(logsDir);
82
88
  const runtimePaths = ensureRuntimeDir(config.runtimeDir);
83
89
  const eventLog = new AcpEventLog(runtimePaths.rootDir);
84
- const taskStore = new AcpTaskStore(runtimePaths.rootDir);
85
- const workerStore = new WorkerStore(runtimePaths.rootDir);
86
- const mailboxManager = new MailboxManager(runtimePaths.rootDir);
87
- const governanceStore = new GovernanceStore(runtimePaths.rootDir);
88
- const sessionArchiveStore = new SessionArchiveStore(runtimePaths.rootDir);
89
90
  const sessionNameStore = new SessionNameStore(runtimePaths.rootDir);
90
- governanceStore.setModelPolicy(config.modelPolicy ?? {});
91
+
92
+ // Session-scoped stores — lazily created per host session ID
93
+ const storeFactory = new SessionStoreFactory(runtimePaths.rootDir);
94
+ let hostSessionId: string | undefined;
95
+
96
+ // Capture host session ID on session start (fires before any tool call)
97
+ pi.on("session_start", (_event, ctx) => {
98
+ hostSessionId = ctx.sessionManager.getSessionId();
99
+ // Apply governance policy to the session-scoped governance store
100
+ storeFactory.get(hostSessionId).governanceStore.setModelPolicy(config.modelPolicy ?? {});
101
+ });
102
+
103
+ /** Get session-scoped stores for the current host session. */
104
+ function getStores() {
105
+ const sid = hostSessionId ?? process.env.PI_SESSION_ID ?? "default";
106
+ return storeFactory.get(sid);
107
+ }
108
+
109
+ /** Convenience accessors for closures that need stores without ctx. */
110
+ const taskStore = () => getStores().taskStore;
111
+ const workerStore = () => getStores().workerStore;
112
+ const mailboxManager = () => getStores().mailboxManager;
113
+ const governanceStore = () => getStores().governanceStore;
114
+
115
+ // SessionArchiveStore is GLOBAL (catalogs all sessions) — not session-scoped
116
+ const sessionArchiveStore = new SessionArchiveStore(runtimePaths.rootDir);
91
117
 
92
118
  const cb = new AcpCircuitBreaker(
93
119
  config.circuitBreakerMaxFailures ?? 3,
@@ -95,6 +121,66 @@ export default function (pi: ExtensionAPI) {
95
121
  config.stallTimeoutMs ?? 300_000,
96
122
  );
97
123
 
124
+ // DAG orchestration — file-backed store, validator, template resolver, and
125
+ // wave-based executor. Wired with existing infrastructure singletons below.
126
+ // The coordinator and async executor are created lazily per tool call where
127
+ // they already exist; the DagExecutor consults the coordinator on each step
128
+ // dispatch, so a placeholder is passed and the real coordinator is supplied
129
+ // at execute time inside acp_dag_submit.
130
+ //
131
+ // Task 3.3: `dagStore` is deliberately declared here (same closure scope as
132
+ // `workerStore` above and the `getWidgetState()` builder below) so it is
133
+ // directly reachable from getWidgetState() when it maps `dagStore.listAll()`
134
+ // into AcpWidgetState.dags. No hoisting is required — the instance lives at
135
+ // the extension-factory scope for the lifetime of the extension, matching
136
+ // the pattern already used by `workerStore`.
137
+ const dagStore = new DagStore({
138
+ dagDir: runtimePaths.dagDir,
139
+ dagIndexFile: runtimePaths.dagIndexFile,
140
+ });
141
+ const dagValidator = new DagValidator();
142
+ const dagTemplateResolver = new TemplateResolver({
143
+ truncateChars: config.dagOutputTruncateChars ?? 8000,
144
+ });
145
+
146
+ // ── Resume-on-startup hook (task 7.3, specs/dag-resume "Resume from last
147
+ // checkpoint after pi restart") ──
148
+ // On extension load, discover DAGs persisted in `running` state and resume
149
+ // each from its last checkpoint. The resume pass is fire-and-forget: the
150
+ // coordinator/executor construction and resumeAll() run inside an async
151
+ // IIFE so a failure (e.g. unreadable runtime dir) is caught and logged
152
+ // rather than thrown into the synchronous extension load path.
153
+ (async () => {
154
+ try {
155
+ const resumeCoordinator = new AgentCoordinator(config, process.cwd(), {
156
+ isHealthyFn: (name) => cb.isHealthy(name),
157
+ recordSuccessFn: (name) => cb.recordSuccess(name),
158
+ recordFailureFn: (name) => cb.recordFailure(name),
159
+ });
160
+ const resumeAsyncExecutor = new AsyncExecutor(resumeCoordinator, runtimePaths.rootDir);
161
+ const resumeExecutor = new DagExecutor({
162
+ store: dagStore,
163
+ resolver: dagTemplateResolver,
164
+ coordinator: resumeCoordinator,
165
+ asyncExecutor: resumeAsyncExecutor,
166
+ circuitBreaker: cb,
167
+ logger,
168
+ eventLog,
169
+ });
170
+ // Mark stale DAGs before resuming — a DAG with no step transitions
171
+ // for longer than dagStaleTimeoutMs is transitioned to `stale` and
172
+ // excluded from resume (specs/dag-stale-detection).
173
+ const staleTimeoutMs = config.dagStaleTimeoutMs ?? 3_600_000;
174
+ resumeExecutor.markStale(staleTimeoutMs);
175
+
176
+ await resumeExecutor.resumeAll();
177
+ } catch (err) {
178
+ const message = err instanceof Error ? err.message : String(err);
179
+ logger.error("DAG resume-on-startup failed", { error: message });
180
+ eventLog.append("dag_resume_failed", { error: message });
181
+ }
182
+ })();
183
+
98
184
  function archiveSession(handle: AcpSessionHandle): AcpArchivedSessionMetadata {
99
185
  return sessionArchiveStore.upsert(handle);
100
186
  }
@@ -185,7 +271,7 @@ export default function (pi: ExtensionAPI) {
185
271
  const heartbeatDeps = {
186
272
  resolveWorkerName: (sid: string) => workerSessionMap.get(sid),
187
273
  touch: (name: string, deltas?: { tokenDelta?: number; toolCallDelta?: number }) =>
188
- workerStore.touch(name, deltas),
274
+ workerStore().touch(name, deltas),
189
275
  logParseError: (entry: { workerName: string; sessionId: string; error: string }) =>
190
276
  eventLog.append("heartbeat_parse_error", entry),
191
277
  };
@@ -253,12 +339,12 @@ export default function (pi: ExtensionAPI) {
253
339
 
254
340
  if (workerAutoClaim) {
255
341
  const dispatchDeps: WorkerDispatcherDeps = {
256
- workerStore: workerStore as unknown as WorkerDispatcherDeps["workerStore"],
257
- taskStore: taskStore as unknown as WorkerDispatcherDeps["taskStore"],
342
+ get workerStore() { return workerStore() as unknown as WorkerDispatcherDeps["workerStore"]; },
343
+ get taskStore() { return taskStore() as unknown as WorkerDispatcherDeps["taskStore"]; },
258
344
  eventLog,
259
345
  busySessions,
260
346
  getSessionIdForWorker: (workerName: string) => {
261
- const worker = workerStore.get(workerName);
347
+ const worker = workerStore().get(workerName);
262
348
  if (!worker) return undefined;
263
349
  // Find sessionId from the workerSessionMap by reversing the lookup
264
350
  for (const [sid, name] of workerSessionMap.entries()) {
@@ -309,7 +395,7 @@ export default function (pi: ExtensionAPI) {
309
395
  configuredAliases: config.agent_aliases ? Object.keys(config.agent_aliases) : [],
310
396
  defaultAgent: config.defaultAgent,
311
397
  activity: { ...widgetActivity },
312
- workers: workerStore.list().map((w) => {
398
+ workers: workerStore().list().map((w) => {
313
399
  const now = Date.now();
314
400
  const ageSec = Math.floor((now - new Date(w.lastActivityAt).getTime()) / 1000);
315
401
  const derived = deriveWorkerStatus(w);
@@ -324,6 +410,16 @@ export default function (pi: ExtensionAPI) {
324
410
  currentTaskId: w.currentTaskId,
325
411
  };
326
412
  }),
413
+ // Task 3.2 / 3.4: populate `dags` from DagStore.listAll() — filter out
414
+ // `pending`, sort by `updatedAt` desc, cap at 5 entries. Field-name
415
+ // remapping DagIndexEntry → AcpWidgetDag is centralized and documented in
416
+ // `dagIndexEntryToWidgetDag()` (see src/acp-widget.ts).
417
+ dags: dagStore
418
+ .listAll()
419
+ .filter((e) => e.status !== "pending")
420
+ .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0))
421
+ .slice(0, 5)
422
+ .map((e) => dagIndexEntryToWidgetDag(e)),
327
423
  });
328
424
 
329
425
  const widgetFactory = createAcpWidget({ getState: getWidgetState });
@@ -710,7 +806,7 @@ ${pr.text}`;
710
806
  }),
711
807
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
712
808
  config = loadConfig();
713
- governanceStore.setModelPolicy(config.modelPolicy ?? {});
809
+ governanceStore().setModelPolicy(config.modelPolicy ?? {});
714
810
  if (params.session_id || params.session_name) {
715
811
  let target;
716
812
  try {
@@ -844,7 +940,7 @@ ${pr.text}`;
844
940
  // Bulk operation
845
941
  if (params.task_id === "*") {
846
942
  const filter = params.filter ?? "";
847
- const updated = taskStore.updateWhere(filter, (t: any) => {
943
+ const updated = taskStore().updateWhere(filter, (t: any) => {
848
944
  if (params.status) t.status = params.status;
849
945
  if (params.assignee !== undefined) t.assignee = params.assignee || null;
850
946
  if (params.result) t.result = params.result;
@@ -854,7 +950,7 @@ ${pr.text}`;
854
950
  }
855
951
 
856
952
  // Single task
857
- const updated = taskStore.update(params.task_id, (t: any) => {
953
+ const updated = taskStore().update(params.task_id, (t: any) => {
858
954
  if (params.status) t.status = params.status;
859
955
  if (params.assignee !== undefined) t.assignee = params.assignee || null;
860
956
  if (params.result) t.result = params.result;
@@ -892,7 +988,7 @@ ${pr.text}`;
892
988
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
893
989
  if (params.action === "send") {
894
990
  const kind: "dm" | "steer" | "broadcast" = params.to === "*" ? "broadcast" : ((params.kind as "dm" | "steer" | "broadcast" | undefined) ?? "dm");
895
- const result = mailboxManager.send({
991
+ const result = mailboxManager().send({
896
992
  from: params.from ?? "user",
897
993
  to: params.to ?? "",
898
994
  message: params.message ?? "",
@@ -903,11 +999,11 @@ ${pr.text}`;
903
999
 
904
1000
  if (params.action === "list") {
905
1001
  if (params.recipient) {
906
- const messages = mailboxManager.listFor(params.recipient);
1002
+ const messages = mailboxManager().listFor(params.recipient);
907
1003
  return { content: [textContent(`${messages.length} messages for ${params.recipient}.`)], details: { messages } };
908
1004
  }
909
1005
  // List all
910
- const messages = mailboxManager.listAll?.() ?? [];
1006
+ const messages = mailboxManager().listAll?.() ?? [];
911
1007
  return { content: [textContent(`${messages.length} total messages.`)], details: { messages } };
912
1008
  }
913
1009
 
@@ -931,7 +1027,7 @@ ${pr.text}`;
931
1027
  deps: Type.Optional(Type.Array(Type.String(), { description: "Task IDs this task depends on" })),
932
1028
  }),
933
1029
  async execute(_toolCallId, params) {
934
- const task = taskStore.create({ subject: params.subject, description: params.description, assignee: params.assignee, deps: params.deps });
1030
+ const task = taskStore().create({ subject: params.subject, description: params.description, assignee: params.assignee, deps: params.deps });
935
1031
  eventLog.append("task_create", { taskId: task.id, assignee: task.assignee });
936
1032
  return { content: [textContent(formatJson(task))], details: task };
937
1033
  },
@@ -1143,7 +1239,7 @@ ${pr.text}`;
1143
1239
  }
1144
1240
 
1145
1241
  // Check for duplicate name
1146
- const existing = workerStore.get(name);
1242
+ const existing = workerStore().get(name);
1147
1243
  if (existing) {
1148
1244
  return {
1149
1245
  content: [textContent(`Worker '${name}' already exists`)],
@@ -1166,7 +1262,7 @@ ${pr.text}`;
1166
1262
  if (params.thinking) await adapter.setMode(params.thinking);
1167
1263
  const handle = makeSessionHandle(sessionId, agentName, effectiveCwd, adapter);
1168
1264
  // Register in WorkerStore
1169
- const worker = workerStore.register({ name, sessionId, agentName });
1265
+ const worker = workerStore().register({ name, sessionId, agentName });
1170
1266
  // Register session → worker mapping for heartbeat consumer
1171
1267
  workerSessionMap.set(sessionId, name);
1172
1268
  // Store adapter for dispatcher access
@@ -1222,7 +1318,7 @@ ${pr.text}`;
1222
1318
  }),
1223
1319
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1224
1320
  // Fetch all workers, then filter by derived status if requested
1225
- const allWorkers = workerStore.list();
1321
+ const allWorkers = workerStore().list();
1226
1322
  const rawFilter = params.filter as string | undefined;
1227
1323
  const workers = rawFilter
1228
1324
  ? allWorkers.filter((w) => {
@@ -1294,7 +1390,7 @@ ${pr.text}`;
1294
1390
  const message = requireString(params.message, "message");
1295
1391
 
1296
1392
  // 4.5: Return error if worker not found
1297
- const worker = workerStore.get(name);
1393
+ const worker = workerStore().get(name);
1298
1394
  if (!worker) {
1299
1395
  return { content: [textContent(`Worker '${name}' not found`)], details: { error: "worker_not_found", name } };
1300
1396
  }
@@ -1309,7 +1405,7 @@ ${pr.text}`;
1309
1405
  try {
1310
1406
  await adapter.cancel();
1311
1407
  // Cancel succeeded — queue steer for next prompt after worker returns to idle
1312
- workerStore.updateMetadata(name, { pendingSteer: message });
1408
+ workerStore().updateMetadata(name, { pendingSteer: message });
1313
1409
  eventLog.append("worker_steer_interrupt", { name, sessionId, message });
1314
1410
  refreshWidget(ctx);
1315
1411
  return {
@@ -1318,7 +1414,7 @@ ${pr.text}`;
1318
1414
  };
1319
1415
  } catch (err) {
1320
1416
  // Interrupt failed — queue as next-prompt-prefix with warning
1321
- workerStore.updateMetadata(name, { pendingSteer: message });
1417
+ workerStore().updateMetadata(name, { pendingSteer: message });
1322
1418
  eventLog.append("worker_steer_queued", { name, sessionId, message, reason: "interrupt_failed" });
1323
1419
  refreshWidget(ctx);
1324
1420
  return {
@@ -1329,7 +1425,7 @@ ${pr.text}`;
1329
1425
  }
1330
1426
 
1331
1427
  // Idle or no adapter — queue as next-prompt-prefix (4.4)
1332
- workerStore.updateMetadata(name, { pendingSteer: message });
1428
+ workerStore().updateMetadata(name, { pendingSteer: message });
1333
1429
  eventLog.append("worker_steer_queued", { name, sessionId, message, reason: isBusy ? "no_adapter" : "idle" });
1334
1430
  refreshWidget(ctx);
1335
1431
  return { content: [textContent(`Steer message queued for worker '${name}': ${message}`)], details: { name, message, queued: true } };
@@ -1353,9 +1449,9 @@ ${pr.text}`;
1353
1449
  // Determine which workers to shut down
1354
1450
  let targets: string[];
1355
1451
  if (params.all) {
1356
- targets = workerStore.list().filter((w) => w.status !== "offline").map((w) => w.name);
1452
+ targets = workerStore().list().filter((w) => w.status !== "offline").map((w) => w.name);
1357
1453
  } else if (params.name) {
1358
- const worker = workerStore.get(params.name);
1454
+ const worker = workerStore().get(params.name);
1359
1455
  if (!worker) {
1360
1456
  return { content: [textContent(`Worker '${params.name}' not found`)], details: { error: "worker_not_found" } };
1361
1457
  }
@@ -1367,7 +1463,7 @@ ${pr.text}`;
1367
1463
  const results: Array<{ name: string; ok: boolean; error?: string }> = [];
1368
1464
 
1369
1465
  for (const name of targets) {
1370
- const worker = workerStore.get(name);
1466
+ const worker = workerStore().get(name);
1371
1467
  if (!worker) continue;
1372
1468
 
1373
1469
  try {
@@ -1376,11 +1472,11 @@ ${pr.text}`;
1376
1472
  const startTime = Date.now();
1377
1473
  while (worker.currentTaskId && (Date.now() - startTime) < shutdownTimeoutMs) {
1378
1474
  await new Promise((r) => setTimeout(r, 500));
1379
- const fresh = workerStore.get(name);
1475
+ const fresh = workerStore().get(name);
1380
1476
  if (fresh && !fresh.currentTaskId) break;
1381
1477
  }
1382
1478
  // Check if still busy after timeout
1383
- const afterWait = workerStore.get(name);
1479
+ const afterWait = workerStore().get(name);
1384
1480
  if (afterWait?.currentTaskId) {
1385
1481
  results.push({ name, ok: false, error: `Shutdown timed out; worker '${name}' still busy. Use acp_worker_kill to force.` });
1386
1482
  continue;
@@ -1398,7 +1494,7 @@ ${pr.text}`;
1398
1494
  busySessions.delete(sessionId);
1399
1495
 
1400
1496
  // Mark worker offline
1401
- workerStore.updateStatus(name, "offline");
1497
+ workerStore().updateStatus(name, "offline");
1402
1498
 
1403
1499
  // 6.2: Emit worker_shutdown event
1404
1500
  eventLog.append("worker_shutdown", { name, sessionId });
@@ -1430,7 +1526,7 @@ ${pr.text}`;
1430
1526
  }),
1431
1527
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1432
1528
  const name = requireString(params.name, "name");
1433
- const worker = workerStore.get(name);
1529
+ const worker = workerStore().get(name);
1434
1530
  if (!worker) {
1435
1531
  return { content: [textContent(`Worker '${name}' not found`)], details: { error: "worker_not_found" } };
1436
1532
  }
@@ -1448,13 +1544,13 @@ ${pr.text}`;
1448
1544
  // Unassign active tasks (set to pending)
1449
1545
  if (worker.currentTaskId) {
1450
1546
  try {
1451
- taskStore.update(worker.currentTaskId, (t) => { t.status = "pending"; });
1547
+ taskStore().update(worker.currentTaskId, (t) => { t.status = "pending"; });
1452
1548
  } catch { /* ignore */ }
1453
- workerStore.unassignTask(name);
1549
+ workerStore().unassignTask(name);
1454
1550
  }
1455
1551
 
1456
1552
  // Mark worker offline
1457
- workerStore.updateStatus(name, "offline");
1553
+ workerStore().updateStatus(name, "offline");
1458
1554
 
1459
1555
  // 6.2: Emit worker_shutdown event
1460
1556
  eventLog.append("worker_shutdown", { name, sessionId, forced: true });
@@ -1473,7 +1569,7 @@ ${pr.text}`;
1473
1569
  promptSnippet: "acp_worker_prune — prune stale workers",
1474
1570
  parameters: Type.Object({}),
1475
1571
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
1476
- const workers = workerStore.list();
1572
+ const workers = workerStore().list();
1477
1573
  const pruned: string[] = [];
1478
1574
 
1479
1575
  for (const w of workers) {
@@ -1483,12 +1579,12 @@ ${pr.text}`;
1483
1579
  // Unassign active tasks
1484
1580
  if (w.currentTaskId) {
1485
1581
  try {
1486
- taskStore.update(w.currentTaskId, (t) => { t.status = "pending"; });
1582
+ taskStore().update(w.currentTaskId, (t) => { t.status = "pending"; });
1487
1583
  } catch { /* ignore */ }
1488
- workerStore.unassignTask(w.name);
1584
+ workerStore().unassignTask(w.name);
1489
1585
  }
1490
1586
  // Mark offline
1491
- workerStore.updateStatus(w.name, "offline");
1587
+ workerStore().updateStatus(w.name, "offline");
1492
1588
  pruned.push(w.name);
1493
1589
  }
1494
1590
  }
@@ -1501,6 +1597,178 @@ ${pr.text}`;
1501
1597
  },
1502
1598
  });
1503
1599
 
1600
+ // ── DAG tools ────────────────────────────────────────────────────────
1601
+
1602
+ if (isToolEnabled(toolSettings, "acp_dag_submit")) pi.registerTool({
1603
+ name: "acp_dag_submit",
1604
+ label: "ACP DAG Submit",
1605
+ description: "Submit a complete DAG (directed acyclic graph) of ACP agent tasks in a single call. Validates statically (cycles, dangling refs, duplicate IDs, agent availability, reserved IDs), creates the DAG, starts wave-based background execution, and returns the dagId immediately.",
1606
+ promptSnippet: "acp_dag_submit — submit a DAG of ACP agent tasks and start background execution",
1607
+ parameters: Type.Object({
1608
+ tasks: Type.Array(Type.Object({
1609
+ id: Type.String({ description: "Unique step identifier" }),
1610
+ agent: Type.String({ description: "Agent name (must exist in agent_servers config)" }),
1611
+ prompt: Type.String({ description: "Prompt text. May contain {<step-id>.output}, {<step-id>.status}, {dag.args.<key>} template variables." }),
1612
+ dependsOn: Type.Optional(Type.Array(Type.String(), { description: "Step IDs this step depends on (default: [])" })),
1613
+ gate: Type.Optional(Type.Union([Type.Literal("needs"), Type.Literal("after")], { description: "Gate type for ALL dependencies. needs = success-gate (default), after = completion-gate" })),
1614
+ }), { description: "DAG task definitions" }),
1615
+ args: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Workflow-level arguments for {dag.args.*} template variables" })),
1616
+ options: Type.Optional(Type.Object({
1617
+ failFast: Type.Optional(Type.Boolean({ description: "On failure, skip transitive dependents. Default: true" })),
1618
+ maxRetries: Type.Optional(Type.Number({ description: "Retry attempts per step on failure. Default: 0" })),
1619
+ })),
1620
+ cwd: Type.Optional(Type.String({ description: "Working directory for all DAG steps" })),
1621
+ }),
1622
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1623
+ const tasks = params.tasks;
1624
+ if (!Array.isArray(tasks) || tasks.length === 0) {
1625
+ return { content: [textContent("acp_dag_submit requires a non-empty \"tasks\" array.")], details: { error: "no tasks" } };
1626
+ }
1627
+
1628
+ // 1. Static validation against the configured agent set.
1629
+ const agentNames = new Set(Object.keys(config.agent_servers));
1630
+ const validation = dagValidator.validate(tasks, agentNames);
1631
+ if (!validation.valid) {
1632
+ const message = `DAG validation failed: ${validation.errors.join("; ")}`;
1633
+ eventLog.append("dag_submit_rejected", { errors: validation.errors });
1634
+ return { content: [textContent(message)], details: { error: "validation_failed", violations: validation.errors } };
1635
+ }
1636
+
1637
+ // 2. Create the DAG record via the file-backed store.
1638
+ const record = dagStore.create({
1639
+ tasks,
1640
+ args: params.args,
1641
+ options: params.options ?? {},
1642
+ });
1643
+
1644
+ // 3. Build a per-call coordinator + executor with the current cwd and
1645
+ // circuit breaker, then kick off execution in the background
1646
+ // (fire-and-forget). The dagId is returned immediately.
1647
+ const coordinator = new AgentCoordinator(config, params.cwd ?? ctx.cwd, {
1648
+ isHealthyFn: (name) => cb.isHealthy(name),
1649
+ recordSuccessFn: (name) => cb.recordSuccess(name),
1650
+ recordFailureFn: (name) => cb.recordFailure(name),
1651
+ });
1652
+ // Existing AsyncExecutor singleton wired into the DagExecutor (task
1653
+ // 7.1). The wave loop is driven directly by the executor (design.md
1654
+ // D2 / task 5.3), but the AsyncExecutor is retained on the instance
1655
+ // for integration with the rest of the background-dispatch infra.
1656
+ const asyncExecutor = new AsyncExecutor(coordinator, runtimePaths.rootDir);
1657
+ const dagExecutor = new DagExecutor({
1658
+ store: dagStore,
1659
+ resolver: dagTemplateResolver,
1660
+ coordinator,
1661
+ asyncExecutor,
1662
+ circuitBreaker: cb,
1663
+ logger,
1664
+ eventLog,
1665
+ });
1666
+
1667
+ // Fire-and-forget: errors are captured per step into the DAG state file.
1668
+ dagExecutor.execute(record.dagId).catch((err) => {
1669
+ logger.error(`acp_dag_submit background execution failed for dagId=${record.dagId}`, { error: err instanceof Error ? err.message : String(err) });
1670
+ eventLog.append("dag_execute_failed", { dagId: record.dagId, error: err instanceof Error ? err.message : String(err) });
1671
+ });
1672
+
1673
+ eventLog.append("dag_submitted", { dagId: record.dagId, stepCount: tasks.length });
1674
+ return {
1675
+ content: [textContent(`Submitted DAG "${record.dagId}" with ${tasks.length} step(s). Execution started in the background.`)],
1676
+ details: { dagId: record.dagId, stepCount: tasks.length },
1677
+ };
1678
+ },
1679
+ });
1680
+
1681
+ if (isToolEnabled(toolSettings, "acp_dag_status")) pi.registerTool({
1682
+ name: "acp_dag_status",
1683
+ label: "ACP DAG Status",
1684
+ description: "Query the execution state of a DAG. With a dagId, returns the full DAG state (status, all steps with their statuses, outputs, errors, dependencies, wave progress). Without a dagId, lists all DAGs with summary status.",
1685
+ promptSnippet: "acp_dag_status — get full DAG state or list all DAGs",
1686
+ parameters: Type.Object({
1687
+ dagId: Type.Optional(Type.String({ description: "DAG ID to inspect. Omit to list all DAGs." })),
1688
+ }),
1689
+ async execute(_toolCallId, params): Promise<AgentToolResult<{ dagId?: string; status?: string; currentWave?: number; totalWaves?: number; dags?: DagIndexEntry[]; count?: number; error?: string }>> {
1690
+ const dagId = params.dagId?.trim();
1691
+
1692
+ // Listing mode: no dagId provided → return all DAGs from the index.
1693
+ if (!dagId) {
1694
+ const dags = dagStore.listAll();
1695
+ return {
1696
+ content: [textContent(formatJson({ dags }))],
1697
+ details: { dags, count: dags.length },
1698
+ };
1699
+ }
1700
+
1701
+ // Detail mode: dagId provided → return the full DAG record.
1702
+ const record = dagStore.get(dagId);
1703
+ if (!record) {
1704
+ return {
1705
+ content: [textContent(`DAG "${dagId}" not found`)],
1706
+ details: { error: "not_found", dagId },
1707
+ };
1708
+ }
1709
+
1710
+ return {
1711
+ content: [textContent(formatJson(record))],
1712
+ details: { dagId, status: record.status, currentWave: record.currentWave, totalWaves: record.totalWaves },
1713
+ };
1714
+ },
1715
+ });
1716
+
1717
+ if (isToolEnabled(toolSettings, "acp_dag_cancel")) pi.registerTool({
1718
+ name: "acp_dag_cancel",
1719
+ label: "ACP DAG Cancel",
1720
+ description: "Cancel a running DAG. Aborts in-flight agent sessions, marks all pending/running steps as cancelled, transitions the DAG to cancelled, and returns a summary of the cancellation.",
1721
+ promptSnippet: "acp_dag_cancel — cancel a running DAG and return a cancellation summary",
1722
+ parameters: Type.Object({
1723
+ dagId: Type.String({ description: "DAG ID to cancel" }),
1724
+ }),
1725
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx): Promise<AgentToolResult<{ dagId: string; completed?: number; aborted?: number; cancelled?: number; error?: string }>> {
1726
+ const dagId = (params.dagId ?? "").toString().trim();
1727
+ if (!dagId) {
1728
+ return {
1729
+ content: [textContent("acp_dag_cancel requires a non-empty \"dagId\".")],
1730
+ details: { dagId, error: "no_dagId" },
1731
+ };
1732
+ }
1733
+
1734
+ // Build a per-call coordinator + executor (same wiring as
1735
+ // acp_dag_submit). DagExecutor.cancel() reads the persisted step states
1736
+ // from the DagStore to tally the summary and abort in-flight sessions.
1737
+ const coordinator = new AgentCoordinator(config, ctx.cwd, {
1738
+ isHealthyFn: (name) => cb.isHealthy(name),
1739
+ recordSuccessFn: (name) => cb.recordSuccess(name),
1740
+ recordFailureFn: (name) => cb.recordFailure(name),
1741
+ });
1742
+ const asyncExecutor = new AsyncExecutor(coordinator, runtimePaths.rootDir);
1743
+ const dagExecutor = new DagExecutor({
1744
+ store: dagStore,
1745
+ resolver: dagTemplateResolver,
1746
+ coordinator,
1747
+ asyncExecutor,
1748
+ circuitBreaker: cb,
1749
+ logger,
1750
+ eventLog,
1751
+ });
1752
+
1753
+ try {
1754
+ const summary: DagCancelSummary = await dagExecutor.cancel(dagId);
1755
+ eventLog.append("dag_cancelled", { dagId, ...summary });
1756
+ return {
1757
+ content: [textContent(formatJson({ dagId, ...summary }))],
1758
+ details: { dagId, ...summary },
1759
+ };
1760
+ } catch (err) {
1761
+ const message = err instanceof Error ? err.message : String(err);
1762
+ logger.error(`acp_dag_cancel failed for dagId=${dagId}`, { error: message });
1763
+ eventLog.append("dag_cancel_failed", { dagId, error: message });
1764
+ return {
1765
+ content: [textContent(message)],
1766
+ details: { dagId, error: "cancel_failed" },
1767
+ };
1768
+ }
1769
+ },
1770
+ });
1771
+
1504
1772
  pi.registerCommand("acp-doctor", {
1505
1773
  description: "Compatibility alias for /acp runtime doctor",
1506
1774
  async handler(_args, ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buihongduc132/pi-acp-agents",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Pi extension: ACP agent client — spawn and control ACP-compatible agents (Gemini CLI, etc.) from within pi",
5
5
  "keywords": [
6
6
  "pi-package",