neko-harness 0.3.0 → 0.3.2

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/dist/gateway.js CHANGED
@@ -517,7 +517,10 @@ function summarizeToolCall(name, args) {
517
517
  const record = args ?? {};
518
518
  switch (name) {
519
519
  case "subagent":
520
- return { args: `${record.type}: ${record.title}`, detail: String(record.task ?? "") };
520
+ return {
521
+ args: record.action === "list" ? "List agents" : `${record.agentId ?? record.type}: ${record.title}`,
522
+ detail: String(record.task ?? "")
523
+ };
521
524
  case "find_files":
522
525
  case "search_text":
523
526
  return {
@@ -557,6 +560,53 @@ function toolResultText(content) {
557
560
  return content.filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text ?? "").join(`
558
561
  `);
559
562
  }
563
+ var WRITE_DIFF_MAX_LINES = 2000;
564
+ var WRITE_DIFF_MAX_BYTES = 64 * 1024;
565
+ function countDiffLines(diff) {
566
+ let added = 0;
567
+ let removed = 0;
568
+ for (const line of diff.split(`
569
+ `)) {
570
+ if (line.startsWith("+") && !line.startsWith("+++"))
571
+ added++;
572
+ else if (line.startsWith("-") && !line.startsWith("---"))
573
+ removed++;
574
+ }
575
+ return { added, removed };
576
+ }
577
+ function editResultDiff(details) {
578
+ if (typeof details !== "object" || details === null)
579
+ return null;
580
+ const diff = details.diff;
581
+ return typeof diff === "string" ? diff : null;
582
+ }
583
+ function synthesizedWriteDiff(args) {
584
+ const record = args ?? {};
585
+ if (typeof record.content !== "string")
586
+ return null;
587
+ const content = record.content;
588
+ const shown = [];
589
+ let offset = 0;
590
+ let bytes = 0;
591
+ while (offset < content.length && shown.length < WRITE_DIFF_MAX_LINES) {
592
+ const newline = content.indexOf(`
593
+ `, offset);
594
+ const end = newline === -1 ? content.length : newline;
595
+ const overhead = shown.length === 0 ? 2 : 3;
596
+ const remaining = WRITE_DIFF_MAX_BYTES - bytes - overhead;
597
+ if (end - offset > remaining)
598
+ break;
599
+ const line = content.slice(offset, end);
600
+ const lineBytes = Buffer.byteLength(line, "utf8");
601
+ if (lineBytes > remaining)
602
+ break;
603
+ shown.push(`+ ${line}`);
604
+ bytes += lineBytes + overhead;
605
+ offset = newline === -1 ? content.length : end + 1;
606
+ }
607
+ return { diff: shown.join(`
608
+ `), added: shown.length, removed: 0, truncated: offset < content.length };
609
+ }
560
610
  function totalUsage(state) {
561
611
  const total = {
562
612
  input: 0,
@@ -589,6 +639,8 @@ function projectChatView(view, modelLabel, options = {}) {
589
639
  if (message.role !== "toolResult")
590
640
  continue;
591
641
  const diagnostics = entry.data?.diagnostics ?? [];
642
+ const diff = message.toolName === "edit" ? editResultDiff(message.details) : null;
643
+ const counts = diff === null ? null : countDiffLines(diff);
592
644
  results.set(message.toolCallId, {
593
645
  type: "tool",
594
646
  key: `t${message.toolCallId}`,
@@ -605,7 +657,10 @@ function projectChatView(view, modelLabel, options = {}) {
605
657
  ].filter(Boolean).join(`
606
658
  `),
607
659
  durationMs: message.durationMs ?? null,
608
- note: null
660
+ note: null,
661
+ diff,
662
+ diffAdded: counts?.added ?? null,
663
+ diffRemoved: counts?.removed ?? null
609
664
  });
610
665
  }
611
666
  }
@@ -640,6 +695,7 @@ function projectChatView(view, modelLabel, options = {}) {
640
695
  const defaultImagePrompt = entry.kind === NekoInput.kind ? savedDefaultImagePrompt(message.content, entry.data?.defaultImagePrompt) : undefined;
641
696
  items.push({
642
697
  type: "user",
698
+ ...entry.data?.subagentRoundId ? { subagentRoundId: entry.data.subagentRoundId } : {},
643
699
  key: `e${entry.id}`,
644
700
  id: entry.id,
645
701
  text: defaultImagePrompt && Array.isArray(message.content) ? userText(message.content.slice(1)) : userText(message.content),
@@ -662,6 +718,7 @@ function projectChatView(view, modelLabel, options = {}) {
662
718
  } else {
663
719
  const summary = summarizeToolCall(block.name, block.arguments);
664
720
  const slot = live.tools?.find((s) => s.callId === block.id);
721
+ const synthesized = block.name === "write" ? synthesizedWriteDiff(block.arguments) : null;
665
722
  items.push({
666
723
  type: "tool",
667
724
  key: `t${block.id}`,
@@ -674,7 +731,11 @@ function projectChatView(view, modelLabel, options = {}) {
674
731
  output: slot?.output ?? null,
675
732
  result: null,
676
733
  durationMs: null,
677
- note: ran ? null : "Not run: response interrupted."
734
+ note: ran ? null : "Not run: response interrupted.",
735
+ diff: synthesized?.diff ?? null,
736
+ diffAdded: synthesized?.added ?? null,
737
+ diffRemoved: synthesized?.removed ?? null,
738
+ diffTruncated: synthesized?.truncated ?? false
678
739
  });
679
740
  }
680
741
  }
@@ -722,7 +783,17 @@ function projectChatView(view, modelLabel, options = {}) {
722
783
  }
723
784
  function withArgs(item, name, args) {
724
785
  const summary = summarizeToolCall(name, args);
725
- return { ...item, name, summary: summary.args, detail: summary.detail ?? null };
786
+ const synthesized = name === "write" ? synthesizedWriteDiff(args) : null;
787
+ return {
788
+ ...item,
789
+ name,
790
+ summary: summary.args,
791
+ detail: summary.detail ?? null,
792
+ diff: synthesized?.diff ?? item.diff,
793
+ diffAdded: synthesized?.added ?? item.diffAdded,
794
+ diffRemoved: synthesized?.removed ?? item.diffRemoved,
795
+ diffTruncated: synthesized?.truncated ?? false
796
+ };
726
797
  }
727
798
  function assistantItem(message, id) {
728
799
  let thinking = "";
@@ -775,13 +846,18 @@ ${block.text}`;
775
846
  if (block.type === "toolCall") {
776
847
  const slot = live.tools?.find((candidate) => candidate.callId === block.id);
777
848
  const summary = summarizeToolCall(block.name, block.arguments);
849
+ const synthesized = block.name === "write" ? synthesizedWriteDiff(block.arguments) : null;
778
850
  toolCalls.push({
779
851
  callId: block.id,
780
852
  name: block.name,
781
853
  summary: summary.args,
782
854
  detail: summary.detail ?? null,
783
855
  phase: slot === undefined || slot.status === "pending" ? "pending" : slot.status,
784
- output: slot?.status === "running" ? slot.output ?? null : null
856
+ output: slot?.status === "running" ? slot.output ?? null : null,
857
+ diff: synthesized?.diff ?? null,
858
+ diffAdded: synthesized?.added ?? null,
859
+ diffRemoved: synthesized?.removed ?? null,
860
+ diffTruncated: synthesized?.truncated ?? false
785
861
  });
786
862
  }
787
863
  }
@@ -840,7 +916,7 @@ import {
840
916
  Harness,
841
917
  LiveDoc as importLiveDoc,
842
918
  MemoryStorage,
843
- UsageDoc,
919
+ UsageDoc as UsageDoc2,
844
920
  watchEvents
845
921
  } from "@earendil-works/pi-durable";
846
922
  import { createNodePhotonImages } from "@earendil-works/pi-durable/images/node";
@@ -1220,9 +1296,58 @@ import {
1220
1296
  configure,
1221
1297
  defineDoc,
1222
1298
  defineExtension as defineExtension3,
1223
- defineTool as defineTool2
1299
+ defineTask,
1300
+ defineTool as defineTool2,
1301
+ LiveDoc,
1302
+ UsageDoc
1224
1303
  } from "@earendil-works/pi-durable";
1225
1304
  import { Type as Type2 } from "typebox";
1305
+ import { Value } from "typebox/value";
1306
+
1307
+ // packages/core/src/subagent-usage.ts
1308
+ var emptySubagentUsage = () => ({ models: {}, tools: {} });
1309
+ function subtractSubagentUsage(end, start) {
1310
+ const result = emptySubagentUsage();
1311
+ for (const bucket of ["models", "tools"]) {
1312
+ for (const [key, usage] of Object.entries(end[bucket])) {
1313
+ const before = start[bucket][key];
1314
+ const cost = usage.cost;
1315
+ const next = {
1316
+ input: usage.input - (before?.input ?? 0),
1317
+ output: usage.output - (before?.output ?? 0),
1318
+ cacheRead: usage.cacheRead - (before?.cacheRead ?? 0),
1319
+ cacheWrite: usage.cacheWrite - (before?.cacheWrite ?? 0),
1320
+ totalTokens: usage.totalTokens - (before?.totalTokens ?? 0),
1321
+ cost: {
1322
+ input: cost.input - (before?.cost.input ?? 0),
1323
+ output: cost.output - (before?.cost.output ?? 0),
1324
+ cacheRead: cost.cacheRead - (before?.cost.cacheRead ?? 0),
1325
+ cacheWrite: cost.cacheWrite - (before?.cost.cacheWrite ?? 0),
1326
+ total: cost.total - (before?.cost.total ?? 0)
1327
+ }
1328
+ };
1329
+ if (usage.reasoning !== undefined)
1330
+ next.reasoning = usage.reasoning - (before?.reasoning ?? 0);
1331
+ if (usage.cacheWrite1h !== undefined)
1332
+ next.cacheWrite1h = usage.cacheWrite1h - (before?.cacheWrite1h ?? 0);
1333
+ result[bucket][key] = next;
1334
+ }
1335
+ }
1336
+ return result;
1337
+ }
1338
+ function subagentUsageTotals(state) {
1339
+ const totals = totalUsage(state);
1340
+ return {
1341
+ input: totals.input,
1342
+ output: totals.output,
1343
+ cacheRead: totals.cacheRead,
1344
+ cacheWrite: totals.cacheWrite,
1345
+ cost: totals.cost.total
1346
+ };
1347
+ }
1348
+
1349
+ // packages/core/src/subagents.ts
1350
+ var agentIdFor = (childId) => `agent-${childId}`;
1226
1351
  var DelegationsDoc = defineDoc({
1227
1352
  kind: "app.neko.delegations",
1228
1353
  version: 1,
@@ -1231,6 +1356,14 @@ var DelegationsDoc = defineDoc({
1231
1356
  fork: "initial",
1232
1357
  initial: () => ({ items: [] })
1233
1358
  });
1359
+ var SubagentsDoc = defineDoc({
1360
+ kind: "app.neko.subagents",
1361
+ version: 1,
1362
+ scope: "conversation",
1363
+ history: "latest",
1364
+ fork: "initial",
1365
+ initial: () => ({ items: [] })
1366
+ });
1234
1367
  var SubagentDoc = defineDoc({
1235
1368
  kind: "app.neko.subagent",
1236
1369
  version: 1,
@@ -1250,157 +1383,401 @@ var ROLE_PROMPTS = {
1250
1383
  plan: "You are neko's read-only planning subagent. Read evidence and propose implementation steps, trade-offs and verification. Do not modify files or execute commands.",
1251
1384
  coder: "You are neko's coding subagent. Implement only the assigned scope and verify it. Coordinate through the main agent; do not overwrite unrelated changes. Report changed files and verification results."
1252
1385
  };
1253
- var SUBAGENT_DELIVERY = " Work only from the assigned task and your project resources; you cannot see the main conversation. Do not delegate or ask the user directly. Your final message must be a self-contained delivery to the main agent, including evidence, results and limitations. Skills never expand your allowed tools.";
1386
+ var SUBAGENT_DELIVERY = " Work from the assigned task, your own prior conversation context and project resources; you cannot see the main conversation. Do not delegate or ask the user directly. Your final message must be a self-contained delivery to the main agent, including evidence, results and limitations. Skills never expand your allowed tools.";
1254
1387
  var READ_TOOLS = new Set(["read", "find_files", "search_text", "web_search", "web_extract"]);
1255
1388
  async function refreshDelegations(tx, id) {
1256
1389
  const doc = await tx.doc(DelegationsDoc, id);
1390
+ const identities = await tx.doc(SubagentsDoc, id);
1257
1391
  for (const item of doc.items) {
1392
+ item.agentId ??= agentIdFor(item.childId);
1393
+ item.round ??= doc.items.filter((d) => d.childId === item.childId && d.taskId <= item.taskId).length;
1258
1394
  if (item.historyThrough !== undefined)
1259
1395
  continue;
1260
- if (item.status === "completed" || item.status === "failed" || item.status === "cancelled")
1261
- continue;
1396
+ if (item.inputEntryId === undefined) {
1397
+ const submission = await tx.submissionByRequest(item.childId, `subagent:${item.taskId}`);
1398
+ if (submission?.type === "input" && submission.entry !== undefined)
1399
+ item.inputEntryId = submission.entry;
1400
+ }
1401
+ let identity = identities.items.find((a) => a.childId === item.childId);
1402
+ if (!identity) {
1403
+ const role = await tx.doc(SubagentDoc, item.childId);
1404
+ if (role.parentId !== id || role.role !== item.role)
1405
+ continue;
1406
+ identity = {
1407
+ agentId: item.agentId,
1408
+ parentId: id,
1409
+ childId: item.childId,
1410
+ role: item.role,
1411
+ cwd: "",
1412
+ title: item.title,
1413
+ createdAt: item.createdAt,
1414
+ activeRoundId: item.taskId
1415
+ };
1416
+ identities.items.push(identity);
1417
+ identity = identities.items.at(-1);
1418
+ }
1262
1419
  const task = await tx.task(item.taskId);
1263
- if (task?.state.status !== "terminal")
1264
- continue;
1265
- item.status = task.state.outcome.status === "aborted" ? "cancelled" : "failed";
1266
- item.endedAt = Date.now();
1420
+ const runner = item.runnerId === undefined ? undefined : await tx.task(item.runnerId);
1421
+ const live = await tx.doc(LiveDoc, item.childId);
1422
+ const idle = live.run === undefined && !live.compactions?.length;
1423
+ if (task?.state.status === "terminal") {
1424
+ if (item.status === "queued" || item.status === "running") {
1425
+ item.status = task.state.outcome.status === "aborted" ? "cancelled" : "failed";
1426
+ item.endedAt ??= Date.now();
1427
+ }
1428
+ if (identity.activeRoundId === item.taskId && idle && (!runner || runner.state.status === "terminal"))
1429
+ identity.activeRoundId = null;
1430
+ }
1267
1431
  }
1268
1432
  return JSON.parse(JSON.stringify(doc.items));
1269
1433
  }
1270
- async function admit(api, cwd, role, context) {
1271
- for (;; ) {
1272
- context.abortSignal?.throwIfAborted();
1273
- const acquired = await api.commit(async (tx) => {
1274
- const slots = await tx.doc(SlotsDoc);
1275
- const live = [];
1276
- for (const slot of slots.items) {
1277
- const task = await tx.task(slot.taskId);
1278
- if (task && task.state.status !== "terminal")
1279
- live.push(slot);
1280
- }
1281
- slots.items = live;
1282
- if (live.some((slot) => slot.taskId === api.taskId))
1283
- return true;
1284
- if (live.filter((slot) => slot.parentId === api.conversationId).length >= 3 || role === "coder" && live.some((slot) => slot.coder && slot.cwd === cwd))
1285
- return false;
1286
- slots.items.push({ taskId: api.taskId, parentId: api.conversationId, cwd, coder: role === "coder" });
1287
- return true;
1288
- }, context);
1289
- if (acquired)
1290
- return;
1291
- await new Promise((resolve, reject) => {
1292
- const cleanup = () => {
1293
- clearTimeout(timer);
1294
- context.abortSignal?.removeEventListener("abort", abort);
1295
- };
1296
- const abort = () => {
1297
- cleanup();
1298
- reject(context.abortSignal?.reason ?? new Error("Cancelled"));
1299
- };
1300
- const timer = setTimeout(() => {
1301
- cleanup();
1302
- resolve();
1303
- }, 100);
1304
- context.abortSignal?.addEventListener("abort", abort, { once: true });
1305
- if (context.abortSignal?.aborted)
1306
- abort();
1307
- });
1308
- }
1434
+ function summarizeSubagents(rounds, identities = []) {
1435
+ const groups = new Map;
1436
+ for (const round of rounds) {
1437
+ const id = round.agentId ?? agentIdFor(round.childId);
1438
+ groups.set(id, [...groups.get(id) ?? [], round]);
1439
+ }
1440
+ return [...groups].map(([agentId, entries]) => {
1441
+ const first = entries[0];
1442
+ const last = entries.at(-1);
1443
+ const identity = identities.find((a) => a.agentId === agentId);
1444
+ const active = entries.find((r) => identity ? r.taskId === identity.activeRoundId : r.status === "running" || r.status === "queued");
1445
+ return {
1446
+ agentId,
1447
+ parentId: first.parentId,
1448
+ childId: first.childId,
1449
+ role: first.role,
1450
+ title: identity?.title ?? first.title,
1451
+ status: active ? active.status === "queued" ? "queued" : "running" : "idle",
1452
+ activeRoundId: active?.taskId ?? null,
1453
+ lastRoundId: last.taskId,
1454
+ lastReport: last.report?.slice(0, 512) ?? null,
1455
+ roundCount: entries.length,
1456
+ taskTitle: (active ?? last).title,
1457
+ approvalCount: active?.approvalCount ?? 0,
1458
+ createdAt: first.createdAt,
1459
+ ...first.historyThrough !== undefined ? { historyThrough: first.historyThrough, historyUsage: first.historyUsage } : {}
1460
+ };
1461
+ });
1309
1462
  }
1463
+ async function acquireSlot(tx, round, cwd) {
1464
+ const slots = await tx.doc(SlotsDoc);
1465
+ const live = [];
1466
+ for (const slot of slots.items) {
1467
+ const task = await tx.task(slot.taskId);
1468
+ if (task && task.state.status !== "terminal")
1469
+ live.push(slot);
1470
+ }
1471
+ slots.items = live;
1472
+ if (live.some((slot) => slot.taskId === round.taskId))
1473
+ return true;
1474
+ if (live.filter((slot) => slot.parentId === round.parentId).length >= 3 || round.role === "coder" && live.some((slot) => slot.coder && slot.cwd === cwd))
1475
+ return false;
1476
+ slots.items.push({ taskId: round.taskId, parentId: round.parentId, cwd, coder: round.role === "coder" });
1477
+ return true;
1478
+ }
1479
+ var actionParameters = Type2.Union([
1480
+ Type2.Object({
1481
+ action: Type2.Optional(Type2.Literal("create")),
1482
+ type: Type2.Enum(["explore", "plan", "coder"]),
1483
+ title: Type2.String({ minLength: 1, maxLength: 80 }),
1484
+ task: Type2.String({ minLength: 1, maxLength: 32768 })
1485
+ }, { additionalProperties: false }),
1486
+ Type2.Object({
1487
+ action: Type2.Literal("continue"),
1488
+ agentId: Type2.String(),
1489
+ title: Type2.String({ minLength: 1, maxLength: 80 }),
1490
+ task: Type2.String({ minLength: 1, maxLength: 32768 })
1491
+ }, { additionalProperties: false }),
1492
+ Type2.Object({
1493
+ action: Type2.Literal("list"),
1494
+ cursor: Type2.Optional(Type2.Integer({ minimum: 0 })),
1495
+ limit: Type2.Optional(Type2.Integer({ minimum: 1, maximum: 100 }))
1496
+ }, { additionalProperties: false })
1497
+ ]);
1498
+ var parameters = Type2.Object({
1499
+ action: Type2.Optional(Type2.String({ enum: ["create", "continue", "list"], description: "Defaults to create." })),
1500
+ type: Type2.Optional(Type2.String({ enum: ["explore", "plan", "coder"], description: "Required for create only." })),
1501
+ agentId: Type2.Optional(Type2.String({ description: "Required for continue only; use an ID returned by create/list." })),
1502
+ title: Type2.Optional(Type2.String({ minLength: 1, maxLength: 80, description: "Required for create/continue." })),
1503
+ task: Type2.Optional(Type2.String({ minLength: 1, maxLength: 32768, description: "Required for create/continue." })),
1504
+ cursor: Type2.Optional(Type2.Integer({ minimum: 0, description: "List pagination cursor only." })),
1505
+ limit: Type2.Optional(Type2.Integer({ minimum: 1, maximum: 100, description: "List page size only." }))
1506
+ }, { additionalProperties: false });
1507
+ var structuredOutputSchema = Type2.Union([
1508
+ Type2.Object({
1509
+ agentId: Type2.String(),
1510
+ roundId: Type2.Integer(),
1511
+ status: Type2.Enum(["completed", "failed", "cancelled"]),
1512
+ report: Type2.String()
1513
+ }, { additionalProperties: false }),
1514
+ Type2.Object({
1515
+ items: Type2.Array(Type2.Object({
1516
+ agentId: Type2.String(),
1517
+ parentId: Type2.Integer(),
1518
+ childId: Type2.Integer(),
1519
+ role: Type2.Enum(["explore", "plan", "coder"]),
1520
+ title: Type2.String(),
1521
+ status: Type2.Enum(["idle", "queued", "running"]),
1522
+ activeRoundId: Type2.Union([Type2.Integer(), Type2.Null()]),
1523
+ lastRoundId: Type2.Integer(),
1524
+ lastReport: Type2.Optional(Type2.Union([Type2.String(), Type2.Null()])),
1525
+ roundCount: Type2.Integer(),
1526
+ taskTitle: Type2.String(),
1527
+ approvalCount: Type2.Integer(),
1528
+ createdAt: Type2.Number()
1529
+ }, { additionalProperties: false })),
1530
+ next: Type2.Union([Type2.Integer(), Type2.Null()])
1531
+ }, { additionalProperties: false })
1532
+ ]);
1310
1533
  function createSubagentExtension(prepare, discard) {
1534
+ const finish = async (tx, input, status, report) => {
1535
+ const round = (await tx.doc(DelegationsDoc, input.parentId)).items.find((r) => r.taskId === input.taskId);
1536
+ round.status = status;
1537
+ round.report = report;
1538
+ round.endedAt ??= Date.now();
1539
+ round.usageEnd = JSON.parse(JSON.stringify(await tx.doc(UsageDoc, input.childId)));
1540
+ round.roundUsage = round.usageStart ? subagentUsageTotals(subtractSubagentUsage(round.usageEnd, round.usageStart)) : round.startedAt === null ? subagentUsageTotals(emptySubagentUsage()) : null;
1541
+ };
1542
+ const RoundTask = defineTask({
1543
+ name: "app.neko.subagent-round",
1544
+ version: 1,
1545
+ initial: () => ({ phase: "admission" }),
1546
+ phases: {
1547
+ admission: async (task, runtime, ctx) => {
1548
+ const env = await runtime.env(ctx);
1549
+ const canonical = env && await env.canonicalPath(task.input.cwd, ctx);
1550
+ const slotCwd = canonical?.ok ? canonical.value : task.input.cwd;
1551
+ let admitted = false;
1552
+ while (!admitted) {
1553
+ await runtime.commit(async (tx) => {
1554
+ const round = (await tx.doc(DelegationsDoc, task.input.parentId)).items.find((r) => r.taskId === task.input.taskId);
1555
+ if (!await acquireSlot(tx, round, slotCwd))
1556
+ return;
1557
+ admitted = true;
1558
+ const duplicate = await tx.submissionByRequest(task.input.childId, `subagent:${round.taskId}`);
1559
+ if (!duplicate && !round.usageStart)
1560
+ round.usageStart = JSON.parse(JSON.stringify(await tx.doc(UsageDoc, task.input.childId)));
1561
+ round.status = "running";
1562
+ round.startedAt ??= Date.now();
1563
+ return { status: "running", checkpoint: { phase: "deliver" } };
1564
+ }, ctx);
1565
+ if (!admitted)
1566
+ await runtime.sleep(runtime.now() + 100, ctx);
1567
+ }
1568
+ },
1569
+ deliver: async (task, runtime, ctx) => {
1570
+ const input = task.input;
1571
+ const child = await runtime.conversation(input.childId, ctx);
1572
+ const requestId = `subagent:${input.taskId}`;
1573
+ try {
1574
+ let duplicate = false;
1575
+ await runtime.commit(async (tx) => {
1576
+ duplicate = !!await tx.submissionByRequest(input.childId, requestId);
1577
+ }, ctx);
1578
+ if (!duplicate)
1579
+ await prepare(input.childId, requestId, input.cwd);
1580
+ const submission = await child.submit({
1581
+ type: "input",
1582
+ content: input.task,
1583
+ requestId,
1584
+ whenBusy: "reject",
1585
+ entry: {
1586
+ kind: NekoInput.kind,
1587
+ data: { at: Date.now(), prompt: input.task, images: [], subagentRoundId: input.taskId }
1588
+ }
1589
+ }, ctx);
1590
+ const placed = await submission.status(ctx);
1591
+ await runtime.commit(async (tx) => {
1592
+ if (placed.type === "input" && placed.entry !== undefined)
1593
+ (await tx.doc(DelegationsDoc, input.parentId)).items.find((r) => r.taskId === input.taskId).inputEntryId = placed.entry;
1594
+ return { status: "running", checkpoint: { phase: "settle" } };
1595
+ }, ctx);
1596
+ } catch (error) {
1597
+ discard(input.childId, requestId);
1598
+ if (ctx.abortSignal?.aborted)
1599
+ throw error;
1600
+ await child.abort(ctx);
1601
+ const report = `Subagent failed: ${String(error)}`;
1602
+ await runtime.commit(async (tx) => {
1603
+ await finish(tx, input, "failed", report);
1604
+ return { status: "terminal", outcome: { status: "completed", result: { status: "failed", report } } };
1605
+ }, ctx);
1606
+ }
1607
+ },
1608
+ settle: async (task, runtime, ctx) => {
1609
+ const input = task.input;
1610
+ const child = await runtime.conversation(input.childId, ctx);
1611
+ const submission = await child.submit({ type: "input", content: input.task, requestId: `subagent:${input.taskId}` }, ctx);
1612
+ const settled = await submission.wait(ctx);
1613
+ await child.waitForIdle(ctx);
1614
+ await runtime.commit(async (tx) => {
1615
+ let report = "";
1616
+ let status = "failed";
1617
+ const round = (await tx.doc(DelegationsDoc, input.parentId)).items.find((r) => r.taskId === input.taskId);
1618
+ if (settled.type === "input" && settled.entry !== undefined)
1619
+ round.inputEntryId = settled.entry;
1620
+ if (settled.status === "done" && settled.type === "input") {
1621
+ round.answerEntryId = settled.answer;
1622
+ const message = (await tx.entry(AssistantEntry, settled.answer))?.model?.[0];
1623
+ report = message?.role === "assistant" ? message.content.flatMap((b) => b.type === "text" ? [b.text] : []).join("") : "";
1624
+ status = "completed";
1625
+ } else if (settled.status === "unanswered") {
1626
+ status = settled.reason === "aborted" ? "cancelled" : "failed";
1627
+ report = `Subagent ${status}: ${settled.reason}`;
1628
+ }
1629
+ await finish(tx, input, status, report);
1630
+ return { status: "terminal", outcome: { status: "completed", result: { status, report } } };
1631
+ }, ctx);
1632
+ }
1633
+ },
1634
+ abort: async (task, runtime, ctx) => {
1635
+ const input = task.input;
1636
+ let own = false;
1637
+ await runtime.commit(async (tx) => {
1638
+ own = (await tx.doc(SubagentsDoc, input.parentId)).items.some((a) => a.childId === input.childId && a.activeRoundId === input.taskId);
1639
+ }, ctx);
1640
+ if (own)
1641
+ await (await runtime.conversation(input.childId, ctx))?.abort(ctx);
1642
+ await runtime.commit(async (tx) => {
1643
+ await finish(tx, input, "cancelled", "Subagent cancelled");
1644
+ return { status: "terminal", outcome: { status: "aborted" } };
1645
+ }, ctx);
1646
+ }
1647
+ });
1311
1648
  const extension = defineExtension3({
1312
1649
  name: "neko-subagents",
1650
+ tasks: [RoundTask],
1313
1651
  tools: [
1314
1652
  defineTool2({
1315
1653
  name: "subagent",
1316
- description: "Delegate a self-contained task to an independent foreground subagent. explore: investigation, Bash requires user approval; plan: read-only implementation planning; coder: scoped changes and verification. Include all needed context, constraints and expected delivery. Use only when isolation or parallel independent work helps; handle simple tasks directly. Returns the final report; the main agent waits and must verify/integrate it.",
1317
- parameters: Type2.Object({
1318
- type: Type2.Union([Type2.Literal("explore"), Type2.Literal("plan"), Type2.Literal("coder")]),
1319
- title: Type2.String({ minLength: 1, maxLength: 80 }),
1320
- task: Type2.String({ minLength: 1, maxLength: 32768 })
1321
- }),
1654
+ description: "Manage persistent foreground subagents. create (default): type explore/plan/coder, title, task. continue: agentId, title, task; reuses its own context, only while idle. list: discover this session's agents, optional cursor/limit. Explore Bash requires per-call user approval. Keep constraints explicit; verify and integrate final reports. Subagents cannot delegate.",
1655
+ parameters,
1656
+ structuredOutputSchema,
1322
1657
  replay: "safe",
1323
- execute: async (args, api, context) => {
1324
- if (!args.task.trim() || !args.title.trim() || Buffer.byteLength(args.task) > 32768)
1325
- throw new Error("Task/title must be nonempty; task limit is 32 KiB.");
1658
+ execute: async (input, api, context) => {
1659
+ if (!Value.Check(actionParameters, input))
1660
+ throw new Error("Invalid subagent arguments. create requires type/title/task; continue requires agentId/title/task; list accepts only cursor/limit. Do not mix fields from different actions.");
1661
+ const args = input;
1326
1662
  if ((await api.snapshot(SubagentDoc, api.conversationId, context))?.role)
1327
1663
  throw new Error("Subagents cannot delegate.");
1664
+ if (args.action === "list") {
1665
+ const list = await api.commit(async (tx) => {
1666
+ const rounds = await refreshDelegations(tx, api.conversationId);
1667
+ return summarizeSubagents(rounds, (await tx.doc(SubagentsDoc, api.conversationId)).items).filter((a) => a.historyThrough === undefined);
1668
+ }, context);
1669
+ const offset = args.cursor ?? 0;
1670
+ const items = list.slice(offset, offset + (args.limit ?? 50));
1671
+ const result = { items, next: offset + items.length < list.length ? offset + items.length : null };
1672
+ return { output: [{ type: "text", text: JSON.stringify(result) }], structuredOutput: result };
1673
+ }
1674
+ if (!args.task.trim() || !args.title.trim() || Buffer.byteLength(args.task) > 32768)
1675
+ throw new Error("Task/title must be nonempty; task limit is 32 KiB.");
1328
1676
  const agent = await api.agent(context);
1329
- const childId = await api.commit(async (tx) => {
1330
- const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
1331
- if (existing)
1332
- return existing.id;
1333
- const child = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
1334
- await configure(tx, child.id, {
1335
- model: agent.model,
1336
- thinkingLevel: agent.thinkingLevel,
1337
- cwd: agent.cwd,
1338
- extensions: agent.extensions.filter((e) => e.name !== extension.name),
1339
- tools: agent.tools.filter((tool) => tool.name !== "subagent" && (args.type === "coder" || READ_TOOLS.has(tool.name) || args.type === "explore" && tool.name === "bash"))
1340
- });
1341
- Object.assign(await tx.doc(SubagentDoc, child.id), { role: args.type, parentId: api.conversationId });
1342
- (await tx.doc(DelegationsDoc, api.conversationId)).items.push({
1677
+ const round = await api.commit(async (tx) => {
1678
+ const rounds = await refreshDelegations(tx, api.conversationId);
1679
+ const receipt = rounds.find((r) => r.taskId === api.taskId);
1680
+ if (receipt?.runnerId !== undefined)
1681
+ return receipt;
1682
+ const identities = await tx.doc(SubagentsDoc, api.conversationId);
1683
+ let identity;
1684
+ if (args.action === "continue") {
1685
+ identity = identities.items.find((a) => a.agentId === args.agentId);
1686
+ if (!identity)
1687
+ throw new Error("Unknown subagent or it belongs to another conversation.");
1688
+ const info = await tx.doc(SubagentDoc, identity.childId);
1689
+ if (info.parentId !== api.conversationId || info.role !== identity.role)
1690
+ throw new Error("Subagent ownership mismatch.");
1691
+ if (identity.activeRoundId !== null)
1692
+ throw new Error("Subagent is busy; continue only when idle.");
1693
+ } else if (receipt) {
1694
+ identity = identities.items.find((a) => a.childId === receipt.childId);
1695
+ }
1696
+ const existing = identity ? undefined : (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
1697
+ const child = identity ? await tx.conversation(identity.childId) : existing;
1698
+ if (!identity) {
1699
+ const created = child ?? await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
1700
+ const role = args.action === "continue" ? "explore" : args.type;
1701
+ await configure(tx, created.id, {
1702
+ model: agent.model,
1703
+ thinkingLevel: agent.thinkingLevel,
1704
+ cwd: agent.cwd,
1705
+ extensions: agent.extensions.filter((e) => e.name !== extension.name),
1706
+ tools: agent.tools.filter((t) => t.name !== "subagent" && (role === "coder" || READ_TOOLS.has(t.name) || role === "explore" && t.name === "bash"))
1707
+ });
1708
+ Object.assign(await tx.doc(SubagentDoc, created.id), { role, parentId: api.conversationId });
1709
+ identity = {
1710
+ agentId: agentIdFor(created.id),
1711
+ parentId: api.conversationId,
1712
+ childId: created.id,
1713
+ role,
1714
+ cwd: agent.cwd ?? "",
1715
+ title: args.title,
1716
+ createdAt: Date.now(),
1717
+ activeRoundId: null
1718
+ };
1719
+ identities.items.push(identity);
1720
+ }
1721
+ const record = receipt ?? {
1343
1722
  parentId: api.conversationId,
1344
1723
  taskId: api.taskId,
1345
1724
  callId: api.callId,
1346
- childId: child.id,
1347
- role: args.type,
1725
+ childId: identity.childId,
1726
+ agentId: identity.agentId,
1727
+ round: rounds.filter((r) => r.childId === identity.childId).length + 1,
1728
+ role: identity.role,
1348
1729
  title: args.title,
1349
1730
  createdAt: Date.now(),
1350
1731
  startedAt: null,
1351
1732
  endedAt: null,
1352
1733
  status: "queued",
1353
1734
  report: null
1354
- });
1355
- return child.id;
1356
- }, context);
1357
- await api.details({ conversationId: childId, role: args.type, title: args.title }, context);
1358
- const cwd = api.env ? await api.env.canonicalPath(agent.cwd ?? api.env.cwd, context) : undefined;
1359
- await admit(api, cwd?.ok ? cwd.value : agent.cwd ?? "", args.type, context);
1360
- await api.commit(async (tx) => {
1361
- const item = (await tx.doc(DelegationsDoc, api.conversationId)).items.find((d) => d.taskId === api.taskId);
1362
- item.status = "running";
1363
- item.startedAt ??= Date.now();
1735
+ };
1736
+ identities.items.find((a) => a.agentId === identity.agentId).activeRoundId = api.taskId;
1737
+ const runner = await tx.createTask(RoundTask, {
1738
+ parentId: api.conversationId,
1739
+ childId: identity.childId,
1740
+ taskId: api.taskId,
1741
+ task: args.task,
1742
+ cwd: identity.cwd || agent.cwd || ""
1743
+ }, { ownership: { kind: "task", taskId: api.taskId } });
1744
+ record.runnerId = runner;
1745
+ const doc = await tx.doc(DelegationsDoc, api.conversationId);
1746
+ if (receipt)
1747
+ Object.assign(doc.items.find((r) => r.taskId === receipt.taskId), record);
1748
+ else
1749
+ doc.items.push(record);
1750
+ return record;
1364
1751
  }, context);
1365
- const child = await api.conversation(childId, context);
1366
- const requestId = `subagent:${api.taskId}`;
1367
- const duplicate = await api.commit((tx) => tx.submissionByRequest(childId, requestId), context);
1368
- if (!duplicate)
1369
- await prepare(childId, requestId, agent.cwd ?? "");
1370
- let submission;
1371
- try {
1372
- submission = await child.submit({
1373
- type: "input",
1374
- content: args.task,
1375
- requestId,
1376
- entry: { kind: NekoInput.kind, data: { at: Date.now(), prompt: args.task, images: [] } }
1377
- }, context);
1378
- } catch (error) {
1379
- discard(childId, requestId);
1380
- throw error;
1381
- }
1382
- const settled = await submission.wait(context);
1383
- let report = "";
1384
- let status = "failed";
1385
- if (settled.status === "done" && settled.type === "input") {
1386
- const entry = await api.commit((tx) => tx.entry(AssistantEntry, settled.answer), context);
1387
- const message = entry?.model?.[0];
1388
- report = message?.role === "assistant" ? message.content.flatMap((b) => b.type === "text" ? [b.text] : []).join("") : "";
1389
- status = "completed";
1390
- } else if (settled.status === "unanswered") {
1391
- status = settled.reason === "aborted" ? "cancelled" : "failed";
1392
- report = `Subagent ${status}: ${settled.reason}`;
1393
- }
1394
- await api.commit(async (tx) => {
1395
- const item = (await tx.doc(DelegationsDoc, api.conversationId)).items.find((d) => d.taskId === api.taskId);
1396
- item.status = status;
1397
- item.report = report;
1398
- item.endedAt = Date.now();
1752
+ await api.details({
1753
+ conversationId: round.childId,
1754
+ agentId: round.agentId,
1755
+ roundId: round.taskId,
1756
+ role: round.role,
1757
+ title: round.title
1399
1758
  }, context);
1759
+ const settled = await api.waitForTask(round.runnerId, context);
1760
+ const outcome = settled.state.outcome;
1761
+ const result = outcome.status === "completed" ? outcome.result : { status: outcome.status === "aborted" ? "cancelled" : "failed", report: `Subagent ${outcome.status}` };
1400
1762
  return {
1401
- output: [{ type: "text", text: report || "Subagent completed without a text report." }],
1402
- isError: status !== "completed",
1403
- details: { conversationId: childId, role: args.type, title: args.title, status }
1763
+ output: [
1764
+ {
1765
+ type: "text",
1766
+ text: `Agent: ${round.agentId}
1767
+ Status: ${result.status}
1768
+ ${result.report || "Subagent completed without a text report."}`
1769
+ }
1770
+ ],
1771
+ isError: result.status !== "completed",
1772
+ structuredOutput: { agentId: round.agentId, roundId: round.taskId, ...result },
1773
+ details: {
1774
+ conversationId: round.childId,
1775
+ agentId: round.agentId,
1776
+ roundId: round.taskId,
1777
+ role: round.role,
1778
+ title: round.title,
1779
+ status: result.status
1780
+ }
1404
1781
  };
1405
1782
  }
1406
1783
  })
@@ -1409,15 +1786,70 @@ function createSubagentExtension(prepare, discard) {
1409
1786
  return extension;
1410
1787
  }
1411
1788
  async function listDelegations(harness, parent, context) {
1789
+ const rounds = await harness.commit((tx) => refreshDelegations(tx, parent), context);
1790
+ for (const round of rounds) {
1791
+ if (round.historyThrough !== undefined)
1792
+ continue;
1793
+ const task = await harness.getTask(round.taskId, context);
1794
+ if (task?.state.status !== "terminal")
1795
+ continue;
1796
+ const child = await harness.conversation(round.childId, context);
1797
+ const live = await harness.snapshot(LiveDoc, round.childId, context);
1798
+ const identity = (await harness.snapshot(SubagentsDoc, parent, context))?.items.find((a) => a.childId === round.childId);
1799
+ if (identity?.activeRoundId === round.taskId && live?.run !== undefined)
1800
+ await child?.abort(context);
1801
+ }
1412
1802
  return harness.commit((tx) => refreshDelegations(tx, parent), context);
1413
1803
  }
1804
+ async function installSubagentCoordinator(harness, parents, context) {
1805
+ const known = new Set(parents);
1806
+ let running = false;
1807
+ let dirty = false;
1808
+ const reconcile = async () => {
1809
+ if (running) {
1810
+ dirty = true;
1811
+ return;
1812
+ }
1813
+ running = true;
1814
+ try {
1815
+ do {
1816
+ dirty = false;
1817
+ for (const id of known)
1818
+ await listDelegations(harness, id, context);
1819
+ } while (dirty);
1820
+ } finally {
1821
+ running = false;
1822
+ }
1823
+ };
1824
+ for (const parent of parents) {
1825
+ await harness.commit((tx) => refreshDelegations(tx, parent), context);
1826
+ for (const identity of (await harness.snapshot(SubagentsDoc, parent, context))?.items ?? []) {
1827
+ if (identity.cwd)
1828
+ continue;
1829
+ const child = await harness.conversation(identity.childId, context);
1830
+ const cwd = (await child?.agent(context))?.cwd;
1831
+ if (cwd)
1832
+ await harness.commit(async (tx) => {
1833
+ (await tx.doc(SubagentsDoc, parent)).items.find((a) => a.agentId === identity.agentId).cwd = cwd;
1834
+ }, context);
1835
+ }
1836
+ }
1837
+ const stop = harness.subscribeCommits(({ changes }) => {
1838
+ for (const c of changes)
1839
+ if (c.type === "document" && c.record.kind === "app.neko.delegations" && c.conversationId !== undefined)
1840
+ known.add(c.conversationId);
1841
+ if (changes.some((c) => c.type === "task" && c.value.state.status === "terminal"))
1842
+ reconcile().catch(() => {});
1843
+ });
1844
+ harness.closed.finally(stop).catch(() => {});
1845
+ }
1414
1846
 
1415
1847
  // packages/core/src/prompt/prompt.ts
1416
1848
  var KEYS = ["preamble", "rules", "project_context", "skills", "cwd"];
1417
1849
  var PREAMBLE = "You are an expert coding assistant operating inside neko, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.";
1418
1850
  var TOOL_GUIDELINES = {
1419
1851
  subagent: [
1420
- "Delegate only independent, sufficiently scoped work when isolation or parallelism helps. Handle simple tasks directly. Pass necessary context and user constraints; research-only requests must not become coding tasks. Verify and integrate reports. Do not modify files concurrently with a coder assigned those files."
1852
+ "Delegate only independent, sufficiently scoped work when isolation or parallelism helps. Handle simple tasks directly. Pass necessary context and user constraints; research-only requests must not become coding tasks. For related follow-up work, list and continue a suitable idle subagent using its stable agentId; busy means no task was accepted. Do not change its role. Verify and integrate reports. Do not modify files concurrently with a coder assigned those files."
1421
1853
  ],
1422
1854
  find_files: [
1423
1855
  "Use find_files to discover files; query is a literal substring. Narrow the directory when results are partial."
@@ -1627,9 +2059,9 @@ import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
1627
2059
  import {
1628
2060
  defineDoc as defineDoc2,
1629
2061
  defineExtension as defineExtension5,
1630
- defineTask,
2062
+ defineTask as defineTask2,
1631
2063
  InboxDoc,
1632
- LiveDoc
2064
+ LiveDoc as LiveDoc2
1633
2065
  } from "@earendil-works/pi-durable";
1634
2066
  var SessionMetadataDoc = defineDoc2({
1635
2067
  kind: "app.neko.session-metadata",
@@ -1694,7 +2126,7 @@ function mergeUsage(base, extra) {
1694
2126
  function namingTask() {
1695
2127
  let active = 0;
1696
2128
  const waits = new Set;
1697
- return defineTask({
2129
+ return defineTask2({
1698
2130
  name: "app.neko.name",
1699
2131
  version: 1,
1700
2132
  initial: () => ({ phase: "wait", until: Date.now() + 100 }),
@@ -1908,7 +2340,7 @@ class SessionMetadataManager {
1908
2340
  const answer = record.status === "done" ? (await this.storage.entry(record.answer, this.context))?.entry.model?.[0] : undefined;
1909
2341
  return { record, data, answer: answer?.role === "assistant" ? answer : undefined };
1910
2342
  }));
1911
- const live = await this.harness.snapshot(LiveDoc, id, this.context);
2343
+ const live = await this.harness.snapshot(LiveDoc2, id, this.context);
1912
2344
  await this.harness.commit(async (tx) => {
1913
2345
  const d = await tx.doc(SessionMetadataDoc, id);
1914
2346
  for (const { record, data, answer } of updates) {
@@ -2091,7 +2523,8 @@ function createApprovalExtension(service) {
2091
2523
  const child = await approvals.harness.conversation(api.conversationId, context);
2092
2524
  const agent = await child.agent(context);
2093
2525
  const cwd = await realpath(agent.cwd);
2094
- const delegation = (await api.snapshot(DelegationsDoc, parent, context))?.items.find((d) => d.childId === api.conversationId && d.historyThrough === undefined);
2526
+ const identity = (await api.snapshot(SubagentsDoc, parent, context))?.items.find((a) => a.childId === api.conversationId);
2527
+ const delegation = (await api.snapshot(DelegationsDoc, parent, context))?.items.find((d) => d.childId === api.conversationId && d.historyThrough === undefined && d.taskId === identity?.activeRoundId);
2095
2528
  if (!delegation)
2096
2529
  return { block: "Missing delegation" };
2097
2530
  await approvals.ensure(parent, {
@@ -2804,6 +3237,7 @@ async function createNekoRuntime(options = {}) {
2804
3237
  approvals = new ToolApprovals(harness, context);
2805
3238
  const existing = await scanConversations();
2806
3239
  try {
3240
+ await installSubagentCoordinator(harness, existing.map((r) => r.id), context);
2807
3241
  await prepareApprovalConversations(harness, existing.map((r) => r.id), approvalExtension, context);
2808
3242
  for (const record of existing) {
2809
3243
  const target = await harness.conversation(record.id, context);
@@ -2988,7 +3422,7 @@ async function createNekoRuntime(options = {}) {
2988
3422
  return watchEvents(harness, target.id, context);
2989
3423
  },
2990
3424
  async usage() {
2991
- return mergeUsage(await harness.snapshot(UsageDoc, target.id, context) ?? { models: {}, tools: {} }, (await harness.snapshot(SessionMetadataDoc, target.id, context))?.namingUsage ?? { models: {}, tools: {} });
3425
+ return mergeUsage(await harness.snapshot(UsageDoc2, target.id, context) ?? { models: {}, tools: {} }, (await harness.snapshot(SessionMetadataDoc, target.id, context))?.namingUsage ?? { models: {}, tools: {} });
2992
3426
  },
2993
3427
  compactionRecord() {
2994
3428
  return readCompaction(harness, storage, target.id, context);
@@ -3037,7 +3471,7 @@ async function createNekoRuntime(options = {}) {
3037
3471
  async globalUsage() {
3038
3472
  let total = { models: {}, tools: {} };
3039
3473
  for (const record of await scanConversations()) {
3040
- total = mergeUsage(total, await harness.snapshot(UsageDoc, record.id, context) ?? { models: {}, tools: {} });
3474
+ total = mergeUsage(total, await harness.snapshot(UsageDoc2, record.id, context) ?? { models: {}, tools: {} });
3041
3475
  total = mergeUsage(total, (await harness.snapshot(SessionMetadataDoc, record.id, context))?.namingUsage ?? { models: {}, tools: {} });
3042
3476
  }
3043
3477
  return total;
@@ -3072,14 +3506,24 @@ async function createNekoRuntime(options = {}) {
3072
3506
  }
3073
3507
  cursor = page.next;
3074
3508
  } while (cursor);
3509
+ const frozen = new Map;
3075
3510
  for (const reference of references) {
3076
3511
  if (!calls.has(reference.callId))
3077
3512
  continue;
3078
- const latest = await tx.scanEntries({ conversationId: reference.childId }, 1);
3079
- const usage = totalUsage(await tx.doc(UsageDoc, reference.childId));
3513
+ let snapshot = frozen.get(reference.childId);
3514
+ if (!snapshot) {
3515
+ const latest = await tx.scanEntries({ conversationId: reference.childId }, 1);
3516
+ snapshot = {
3517
+ through: latest.items[0]?.id ?? 0,
3518
+ state: JSON.parse(JSON.stringify(await tx.doc(UsageDoc2, reference.childId)))
3519
+ };
3520
+ frozen.set(reference.childId, snapshot);
3521
+ }
3522
+ const usage = totalUsage(snapshot.state);
3080
3523
  inherited.push({
3081
3524
  ...reference,
3082
- historyThrough: reference.historyThrough ?? latest.items[0]?.id ?? 0,
3525
+ historyThrough: reference.historyThrough ?? snapshot.through,
3526
+ roundUsage: reference.roundUsage ?? (reference.usageStart ? subagentUsageTotals(subtractSubagentUsage(snapshot.state, reference.usageStart)) : null),
3083
3527
  historyUsage: reference.historyUsage ?? {
3084
3528
  input: usage.input,
3085
3529
  output: usage.output,
@@ -3315,8 +3759,8 @@ import { dirname as dirname5, join as join7 } from "path";
3315
3759
  import { fileURLToPath as fileURLToPath2 } from "url";
3316
3760
 
3317
3761
  // packages/core/src/application-version.ts
3318
- var RUNNING_VERSION = "0.3.0";
3319
- var APPLICATION_PROTOCOL = 3;
3762
+ var RUNNING_VERSION = "0.3.2";
3763
+ var APPLICATION_PROTOCOL = 4;
3320
3764
  function stableVersion(value) {
3321
3765
  return typeof value === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value) && value.split(".").every((n) => Number.isSafeInteger(Number(n)));
3322
3766
  }
@@ -3509,10 +3953,20 @@ var call = Type4.Object({
3509
3953
  summary: str,
3510
3954
  detail: nullableString,
3511
3955
  phase,
3512
- output: nullableString
3956
+ output: nullableString,
3957
+ diff: nullableString,
3958
+ diffAdded: Type4.Union([num, nil]),
3959
+ diffRemoved: Type4.Union([num, nil]),
3960
+ diffTruncated: Type4.Optional(bool)
3513
3961
  });
3514
3962
  var DelegationSchema = Type4.Object({
3515
3963
  parentId: integer,
3964
+ agentId: Type4.Optional(str),
3965
+ round: Type4.Optional(integer),
3966
+ runnerId: Type4.Optional(integer),
3967
+ inputEntryId: Type4.Optional(integer),
3968
+ answerEntryId: Type4.Optional(integer),
3969
+ roundUsage: Type4.Optional(Type4.Union([usage, nil])),
3516
3970
  taskId: integer,
3517
3971
  callId: str,
3518
3972
  childId: integer,
@@ -3527,8 +3981,27 @@ var DelegationSchema = Type4.Object({
3527
3981
  historyThrough: Type4.Optional(integer),
3528
3982
  historyUsage: Type4.Optional(usage)
3529
3983
  });
3984
+ var SubagentSummarySchema = Type4.Object({
3985
+ agentId: str,
3986
+ parentId: integer,
3987
+ childId: integer,
3988
+ role: Type4.Enum(["explore", "plan", "coder"]),
3989
+ title: str,
3990
+ status: Type4.Enum(["idle", "queued", "running"]),
3991
+ activeRoundId: Type4.Union([integer, nil]),
3992
+ lastRoundId: integer,
3993
+ lastReport: Type4.Optional(nullableString),
3994
+ roundCount: integer,
3995
+ taskTitle: str,
3996
+ approvalCount: integer,
3997
+ createdAt: num,
3998
+ historyThrough: Type4.Optional(integer),
3999
+ historyUsage: Type4.Optional(usage)
4000
+ });
3530
4001
  var ChatViewSchema = Type4.Object({
3531
4002
  historyBefore: Type4.Optional(Type4.Union([integer, nil])),
4003
+ agents: Type4.Optional(Type4.Array(SubagentSummarySchema)),
4004
+ rounds: Type4.Optional(Type4.Array(DelegationSchema)),
3532
4005
  subagents: Type4.Optional(Type4.Array(DelegationSchema)),
3533
4006
  taskUsage: Type4.Optional(usage),
3534
4007
  model: str,
@@ -3592,7 +4065,11 @@ var ChatViewSchema = Type4.Object({
3592
4065
  output: nullableString,
3593
4066
  result: nullableString,
3594
4067
  durationMs: Type4.Union([num, nil]),
3595
- note: nullableString
4068
+ note: nullableString,
4069
+ diff: nullableString,
4070
+ diffAdded: Type4.Union([num, nil]),
4071
+ diffRemoved: Type4.Union([num, nil]),
4072
+ diffTruncated: Type4.Optional(bool)
3596
4073
  }),
3597
4074
  Type4.Object({ type: Type4.Literal("system"), order: Type4.Optional(num), key: str, id: integer, text: str })
3598
4075
  ])),
@@ -3684,7 +4161,12 @@ var NekoModels = contract("neko.models", {
3684
4161
  var imageChunk = Type4.Object({ data: str, total: integer, mimeType: str });
3685
4162
  var NekoSubagent = contract("neko.subagent", {
3686
4163
  version: 1,
3687
- input: Type4.Object({ sessionId: str, attachmentId: str, taskId: integer }),
4164
+ input: Type4.Object({
4165
+ sessionId: str,
4166
+ attachmentId: str,
4167
+ taskId: Type4.Optional(integer),
4168
+ agentId: Type4.Optional(str)
4169
+ }),
3688
4170
  variants: {
3689
4171
  ready: {
3690
4172
  state: { revision: integer },
@@ -3697,7 +4179,7 @@ var NekoSubagent = contract("neko.subagent", {
3697
4179
  }), Type4.Object({ token: integer, data: str, next: Type4.Union([integer, nil]) })),
3698
4180
  getImage: rpc(Type4.Object({ entryId: integer, contentIndex: integer, offset: integer }), imageChunk),
3699
4181
  decideApproval: rpc(Type4.Object({ id: integer, version: str, decision: Type4.Enum(["allowed", "denied"]) }), Type4.Enum(["pending", "allowed", "denied", "cancelled"])),
3700
- stop: rpc(nil, nil)
4182
+ stop: rpc(Type4.Union([nil, Type4.Object({ taskId: integer })]), nil)
3701
4183
  }
3702
4184
  }
3703
4185
  }
@@ -3839,7 +4321,7 @@ import {
3839
4321
  resource as resource2
3840
4322
  } from "@earendil-works/chord";
3841
4323
  import { withAbortSignal as withAbortSignal3 } from "@earendil-works/chord/context";
3842
- import { UsageDoc as UsageDoc2 } from "@earendil-works/pi-durable";
4324
+ import { UsageDoc as UsageDoc4 } from "@earendil-works/pi-durable";
3843
4325
  import { Type as Type6 } from "typebox";
3844
4326
 
3845
4327
  // packages/gateway/src/image-uploads.ts
@@ -3946,6 +4428,7 @@ import {
3946
4428
  opaque,
3947
4429
  resource
3948
4430
  } from "@earendil-works/chord";
4431
+ import { UsageDoc as UsageDoc3 } from "@earendil-works/pi-durable";
3949
4432
  import { Type as Type5 } from "typebox";
3950
4433
  async function openReader(runtime, childId, parentId, context, through, historyUsage) {
3951
4434
  const child = await runtime.harness.conversation(childId, context);
@@ -4008,7 +4491,7 @@ async function openReader(runtime, childId, parentId, context, through, historyU
4008
4491
  return () => {};
4009
4492
  const stop = native.subscribe(() => fn(null));
4010
4493
  const stopApprovals = runtime.harness.subscribeCommits(({ changes }) => {
4011
- if (changes.some((c) => c.type === "document" && c.record.kind === "app.neko.approvals" && c.conversationId === parentId))
4494
+ if (changes.some((c) => c.type === "document" && ["app.neko.approvals", "app.neko.delegations", "app.neko.subagents"].includes(c.record.kind) && c.conversationId === parentId))
4012
4495
  fn(null);
4013
4496
  });
4014
4497
  return () => {
@@ -4019,7 +4502,12 @@ async function openReader(runtime, childId, parentId, context, through, historyU
4019
4502
  async refresh() {
4020
4503
  const page = await pageAt();
4021
4504
  const approvals = through === undefined ? (await runtime.approvals.list(parentId, context)).filter((a) => a.childId === childId) : [];
4022
- revision = remember({ ...project(page.entries), approvals, historyBefore: page.next });
4505
+ const currentUsage = await runtime.harness.snapshot(UsageDoc3, childId, context);
4506
+ const rounds = (await listDelegations(runtime.harness, parentId, context)).filter((r) => r.childId === childId).map(({ usageStart, usageEnd: _end, ...round }) => ({
4507
+ ...round,
4508
+ roundUsage: round.roundUsage ?? (through === undefined && usageStart && currentUsage ? subagentUsageTotals(subtractSubagentUsage(currentUsage, usageStart)) : null)
4509
+ }));
4510
+ revision = remember({ ...project(page.entries), rounds, approvals, historyBefore: page.next });
4023
4511
  return revision;
4024
4512
  },
4025
4513
  async chunk(input) {
@@ -4066,7 +4554,7 @@ async function openReader(runtime, childId, parentId, context, through, historyU
4066
4554
  }
4067
4555
  var ChildProjection = local("neko.subagent-projection", {
4068
4556
  version: 1,
4069
- input: Type5.Object({ parentId: Type5.Integer(), taskId: Type5.Integer() }),
4557
+ input: Type5.Object({ parentId: Type5.Integer(), agentId: Type5.String() }),
4070
4558
  members: {
4071
4559
  revision: Type5.Integer(),
4072
4560
  reader: opaque("neko.subagent-reader@1")
@@ -4079,7 +4567,7 @@ function installSubagentHost(host, runtime, context) {
4079
4567
  reader: resource({
4080
4568
  rev: 1,
4081
4569
  async open() {
4082
- const entry = (await listDelegations(runtime.harness, input.parentId, context)).find((d) => d.taskId === input.taskId);
4570
+ const entry = (await listDelegations(runtime.harness, input.parentId, context)).find((d) => input.agentId.startsWith("round-") ? d.taskId === Number(input.agentId.slice(6)) : (d.agentId ?? agentIdFor(d.childId)) === input.agentId);
4083
4571
  if (!entry)
4084
4572
  throw new ChordError("gone", "Subagent unavailable");
4085
4573
  const r = await openReader(runtime, entry.childId, input.parentId, context, entry.historyThrough, entry.historyUsage);
@@ -4109,13 +4597,13 @@ function installSubagentHost(host, runtime, context) {
4109
4597
  throw new ChordError("forbidden", "Attachment expired");
4110
4598
  if (!await runtime.getConversation(Number(input.sessionId)))
4111
4599
  throw new ChordError("gone", "Session unavailable");
4112
- const entry = (await listDelegations(runtime.harness, Number(input.sessionId), context)).find((d) => d.taskId === input.taskId);
4600
+ const entry = (await listDelegations(runtime.harness, Number(input.sessionId), context)).find((d) => input.agentId !== undefined ? (d.agentId ?? agentIdFor(d.childId)) === input.agentId : d.taskId === input.taskId);
4113
4601
  if (!entry)
4114
4602
  throw new ChordError("forbidden", "Subagent does not belong to this session");
4115
4603
  return entry;
4116
4604
  };
4117
4605
  const { target } = self.resources({ target: resource({ rev: 1, deps: [owner], open: check }) });
4118
- const shared = self.get(ChildProjection, { parentId: Number(input.sessionId), taskId: input.taskId }, { principal: hostPrincipal });
4606
+ const shared = self.get(ChildProjection, { parentId: Number(input.sessionId), agentId: input.agentId ?? `round-${input.taskId}` }, { principal: hostPrincipal });
4119
4607
  const { authorizedRevision } = self.state({ authorizedRevision: cell(Type5.Integer(), 0) });
4120
4608
  self.listen({
4121
4609
  authorizedView: listener(shared.revision, async (value, ctx) => {
@@ -4144,14 +4632,26 @@ function installSubagentHost(host, runtime, context) {
4144
4632
  const entry = await check();
4145
4633
  if (entry.historyThrough !== undefined)
4146
4634
  throw new ChordError("forbidden", "Fork history is read-only");
4147
- return runtime.approvals.decide(Number(input.sessionId), entry.taskId, id, version, decision, context);
4635
+ const approval = (await runtime.approvals.list(Number(input.sessionId), context)).find((a) => a.id === id && a.childId === entry.childId);
4636
+ if (!approval)
4637
+ throw new ChordError("forbidden", "Approval does not belong to this agent");
4638
+ return runtime.approvals.decide(Number(input.sessionId), approval.delegationTaskId, id, version, decision, context);
4148
4639
  },
4149
- stop: async () => {
4640
+ stop: async (request) => {
4150
4641
  const entry = await check();
4151
4642
  if (entry.historyThrough !== undefined)
4152
4643
  throw new ChordError("forbidden", "Fork history is read-only");
4153
- await runtime.harness.abortTask(entry.taskId, context);
4154
- await runtime.harness.waitForTask(entry.taskId, context);
4644
+ const taskId = request?.taskId ?? input.taskId;
4645
+ if (taskId === undefined)
4646
+ throw new ChordError("invalid", "Select the exact active round to stop");
4647
+ const valid = await runtime.harness.commit(async (tx) => {
4648
+ const identity = (await tx.doc(SubagentsDoc, Number(input.sessionId))).items.find((a) => a.childId === entry.childId);
4649
+ return identity?.activeRoundId === taskId;
4650
+ }, context);
4651
+ if (!valid)
4652
+ return null;
4653
+ await runtime.harness.abortTask(taskId, context);
4654
+ await runtime.harness.waitForTask(taskId, context);
4155
4655
  return null;
4156
4656
  }
4157
4657
  };
@@ -4198,7 +4698,7 @@ async function openProjection(runtime, session, context) {
4198
4698
  const stopMetadata = runtime.onDirectoryChange(() => changed?.(null));
4199
4699
  let childIds = new Set;
4200
4700
  const stopChildren = runtime.harness.subscribeCommits(({ changes }) => {
4201
- if (changes.some((change) => change.type === "document" && (["app.neko.delegations", "app.neko.approvals"].includes(change.record.kind) && change.conversationId === session.id || change.record.kind === "pi.usage" && change.conversationId !== undefined && childIds.has(change.conversationId))))
4701
+ if (changes.some((change) => change.type === "document" && (["app.neko.delegations", "app.neko.approvals", "app.neko.subagents"].includes(change.record.kind) && change.conversationId === session.id || change.record.kind === "pi.usage" && change.conversationId !== undefined && childIds.has(change.conversationId))))
4202
4702
  changed?.(null);
4203
4703
  });
4204
4704
  return {
@@ -4230,15 +4730,19 @@ async function openProjection(runtime, session, context) {
4230
4730
  entry.approvalCount = entry.historyThrough === undefined ? approvals.filter((a) => a.delegationTaskId === entry.taskId && a.status === "pending").length : 0;
4231
4731
  childIds = new Set(subagents.filter((entry) => entry.historyThrough === undefined).map((entry) => entry.childId));
4232
4732
  let aggregate = await session.usage();
4233
- for (const child of subagents.filter((entry) => entry.historyThrough === undefined))
4234
- aggregate = mergeUsage(aggregate, await runtime.harness.snapshot(UsageDoc2, child.childId, context) ?? {
4733
+ for (const child of new Map(subagents.filter((entry) => entry.historyThrough === undefined).map((r) => [r.childId, r])).values())
4734
+ aggregate = mergeUsage(aggregate, await runtime.harness.snapshot(UsageDoc4, child.childId, context) ?? {
4235
4735
  models: {},
4236
4736
  tools: {}
4237
4737
  });
4238
4738
  const u = totalUsage(aggregate);
4239
4739
  return {
4240
4740
  ...projected,
4241
- subagents: subagents.map((entry) => ({ ...entry, report: entry.report?.slice(0, 512) ?? null })),
4741
+ agents: summarizeSubagents(subagents, (await runtime.harness.snapshot(SubagentsDoc, session.id, context))?.items),
4742
+ subagents: subagents.map(({ usageStart: _start, usageEnd: _end, ...entry }) => ({
4743
+ ...entry,
4744
+ report: entry.report?.slice(0, 512) ?? null
4745
+ })),
4242
4746
  taskUsage: {
4243
4747
  input: u.input,
4244
4748
  output: u.output,