neko-harness 0.3.0 → 0.3.1

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 {
@@ -640,6 +643,7 @@ function projectChatView(view, modelLabel, options = {}) {
640
643
  const defaultImagePrompt = entry.kind === NekoInput.kind ? savedDefaultImagePrompt(message.content, entry.data?.defaultImagePrompt) : undefined;
641
644
  items.push({
642
645
  type: "user",
646
+ ...entry.data?.subagentRoundId ? { subagentRoundId: entry.data.subagentRoundId } : {},
643
647
  key: `e${entry.id}`,
644
648
  id: entry.id,
645
649
  text: defaultImagePrompt && Array.isArray(message.content) ? userText(message.content.slice(1)) : userText(message.content),
@@ -840,7 +844,7 @@ import {
840
844
  Harness,
841
845
  LiveDoc as importLiveDoc,
842
846
  MemoryStorage,
843
- UsageDoc,
847
+ UsageDoc as UsageDoc2,
844
848
  watchEvents
845
849
  } from "@earendil-works/pi-durable";
846
850
  import { createNodePhotonImages } from "@earendil-works/pi-durable/images/node";
@@ -1220,9 +1224,58 @@ import {
1220
1224
  configure,
1221
1225
  defineDoc,
1222
1226
  defineExtension as defineExtension3,
1223
- defineTool as defineTool2
1227
+ defineTask,
1228
+ defineTool as defineTool2,
1229
+ LiveDoc,
1230
+ UsageDoc
1224
1231
  } from "@earendil-works/pi-durable";
1225
1232
  import { Type as Type2 } from "typebox";
1233
+ import { Value } from "typebox/value";
1234
+
1235
+ // packages/core/src/subagent-usage.ts
1236
+ var emptySubagentUsage = () => ({ models: {}, tools: {} });
1237
+ function subtractSubagentUsage(end, start) {
1238
+ const result = emptySubagentUsage();
1239
+ for (const bucket of ["models", "tools"]) {
1240
+ for (const [key, usage] of Object.entries(end[bucket])) {
1241
+ const before = start[bucket][key];
1242
+ const cost = usage.cost;
1243
+ const next = {
1244
+ input: usage.input - (before?.input ?? 0),
1245
+ output: usage.output - (before?.output ?? 0),
1246
+ cacheRead: usage.cacheRead - (before?.cacheRead ?? 0),
1247
+ cacheWrite: usage.cacheWrite - (before?.cacheWrite ?? 0),
1248
+ totalTokens: usage.totalTokens - (before?.totalTokens ?? 0),
1249
+ cost: {
1250
+ input: cost.input - (before?.cost.input ?? 0),
1251
+ output: cost.output - (before?.cost.output ?? 0),
1252
+ cacheRead: cost.cacheRead - (before?.cost.cacheRead ?? 0),
1253
+ cacheWrite: cost.cacheWrite - (before?.cost.cacheWrite ?? 0),
1254
+ total: cost.total - (before?.cost.total ?? 0)
1255
+ }
1256
+ };
1257
+ if (usage.reasoning !== undefined)
1258
+ next.reasoning = usage.reasoning - (before?.reasoning ?? 0);
1259
+ if (usage.cacheWrite1h !== undefined)
1260
+ next.cacheWrite1h = usage.cacheWrite1h - (before?.cacheWrite1h ?? 0);
1261
+ result[bucket][key] = next;
1262
+ }
1263
+ }
1264
+ return result;
1265
+ }
1266
+ function subagentUsageTotals(state) {
1267
+ const totals = totalUsage(state);
1268
+ return {
1269
+ input: totals.input,
1270
+ output: totals.output,
1271
+ cacheRead: totals.cacheRead,
1272
+ cacheWrite: totals.cacheWrite,
1273
+ cost: totals.cost.total
1274
+ };
1275
+ }
1276
+
1277
+ // packages/core/src/subagents.ts
1278
+ var agentIdFor = (childId) => `agent-${childId}`;
1226
1279
  var DelegationsDoc = defineDoc({
1227
1280
  kind: "app.neko.delegations",
1228
1281
  version: 1,
@@ -1231,6 +1284,14 @@ var DelegationsDoc = defineDoc({
1231
1284
  fork: "initial",
1232
1285
  initial: () => ({ items: [] })
1233
1286
  });
1287
+ var SubagentsDoc = defineDoc({
1288
+ kind: "app.neko.subagents",
1289
+ version: 1,
1290
+ scope: "conversation",
1291
+ history: "latest",
1292
+ fork: "initial",
1293
+ initial: () => ({ items: [] })
1294
+ });
1234
1295
  var SubagentDoc = defineDoc({
1235
1296
  kind: "app.neko.subagent",
1236
1297
  version: 1,
@@ -1250,157 +1311,401 @@ var ROLE_PROMPTS = {
1250
1311
  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
1312
  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
1313
  };
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.";
1314
+ 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
1315
  var READ_TOOLS = new Set(["read", "find_files", "search_text", "web_search", "web_extract"]);
1255
1316
  async function refreshDelegations(tx, id) {
1256
1317
  const doc = await tx.doc(DelegationsDoc, id);
1318
+ const identities = await tx.doc(SubagentsDoc, id);
1257
1319
  for (const item of doc.items) {
1320
+ item.agentId ??= agentIdFor(item.childId);
1321
+ item.round ??= doc.items.filter((d) => d.childId === item.childId && d.taskId <= item.taskId).length;
1258
1322
  if (item.historyThrough !== undefined)
1259
1323
  continue;
1260
- if (item.status === "completed" || item.status === "failed" || item.status === "cancelled")
1261
- continue;
1324
+ if (item.inputEntryId === undefined) {
1325
+ const submission = await tx.submissionByRequest(item.childId, `subagent:${item.taskId}`);
1326
+ if (submission?.type === "input" && submission.entry !== undefined)
1327
+ item.inputEntryId = submission.entry;
1328
+ }
1329
+ let identity = identities.items.find((a) => a.childId === item.childId);
1330
+ if (!identity) {
1331
+ const role = await tx.doc(SubagentDoc, item.childId);
1332
+ if (role.parentId !== id || role.role !== item.role)
1333
+ continue;
1334
+ identity = {
1335
+ agentId: item.agentId,
1336
+ parentId: id,
1337
+ childId: item.childId,
1338
+ role: item.role,
1339
+ cwd: "",
1340
+ title: item.title,
1341
+ createdAt: item.createdAt,
1342
+ activeRoundId: item.taskId
1343
+ };
1344
+ identities.items.push(identity);
1345
+ identity = identities.items.at(-1);
1346
+ }
1262
1347
  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();
1348
+ const runner = item.runnerId === undefined ? undefined : await tx.task(item.runnerId);
1349
+ const live = await tx.doc(LiveDoc, item.childId);
1350
+ const idle = live.run === undefined && !live.compactions?.length;
1351
+ if (task?.state.status === "terminal") {
1352
+ if (item.status === "queued" || item.status === "running") {
1353
+ item.status = task.state.outcome.status === "aborted" ? "cancelled" : "failed";
1354
+ item.endedAt ??= Date.now();
1355
+ }
1356
+ if (identity.activeRoundId === item.taskId && idle && (!runner || runner.state.status === "terminal"))
1357
+ identity.activeRoundId = null;
1358
+ }
1267
1359
  }
1268
1360
  return JSON.parse(JSON.stringify(doc.items));
1269
1361
  }
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
- }
1362
+ function summarizeSubagents(rounds, identities = []) {
1363
+ const groups = new Map;
1364
+ for (const round of rounds) {
1365
+ const id = round.agentId ?? agentIdFor(round.childId);
1366
+ groups.set(id, [...groups.get(id) ?? [], round]);
1367
+ }
1368
+ return [...groups].map(([agentId, entries]) => {
1369
+ const first = entries[0];
1370
+ const last = entries.at(-1);
1371
+ const identity = identities.find((a) => a.agentId === agentId);
1372
+ const active = entries.find((r) => identity ? r.taskId === identity.activeRoundId : r.status === "running" || r.status === "queued");
1373
+ return {
1374
+ agentId,
1375
+ parentId: first.parentId,
1376
+ childId: first.childId,
1377
+ role: first.role,
1378
+ title: identity?.title ?? first.title,
1379
+ status: active ? active.status === "queued" ? "queued" : "running" : "idle",
1380
+ activeRoundId: active?.taskId ?? null,
1381
+ lastRoundId: last.taskId,
1382
+ lastReport: last.report?.slice(0, 512) ?? null,
1383
+ roundCount: entries.length,
1384
+ taskTitle: (active ?? last).title,
1385
+ approvalCount: active?.approvalCount ?? 0,
1386
+ createdAt: first.createdAt,
1387
+ ...first.historyThrough !== undefined ? { historyThrough: first.historyThrough, historyUsage: first.historyUsage } : {}
1388
+ };
1389
+ });
1309
1390
  }
1391
+ async function acquireSlot(tx, round, cwd) {
1392
+ const slots = await tx.doc(SlotsDoc);
1393
+ const live = [];
1394
+ for (const slot of slots.items) {
1395
+ const task = await tx.task(slot.taskId);
1396
+ if (task && task.state.status !== "terminal")
1397
+ live.push(slot);
1398
+ }
1399
+ slots.items = live;
1400
+ if (live.some((slot) => slot.taskId === round.taskId))
1401
+ return true;
1402
+ if (live.filter((slot) => slot.parentId === round.parentId).length >= 3 || round.role === "coder" && live.some((slot) => slot.coder && slot.cwd === cwd))
1403
+ return false;
1404
+ slots.items.push({ taskId: round.taskId, parentId: round.parentId, cwd, coder: round.role === "coder" });
1405
+ return true;
1406
+ }
1407
+ var actionParameters = Type2.Union([
1408
+ Type2.Object({
1409
+ action: Type2.Optional(Type2.Literal("create")),
1410
+ type: Type2.Enum(["explore", "plan", "coder"]),
1411
+ title: Type2.String({ minLength: 1, maxLength: 80 }),
1412
+ task: Type2.String({ minLength: 1, maxLength: 32768 })
1413
+ }, { additionalProperties: false }),
1414
+ Type2.Object({
1415
+ action: Type2.Literal("continue"),
1416
+ agentId: Type2.String(),
1417
+ title: Type2.String({ minLength: 1, maxLength: 80 }),
1418
+ task: Type2.String({ minLength: 1, maxLength: 32768 })
1419
+ }, { additionalProperties: false }),
1420
+ Type2.Object({
1421
+ action: Type2.Literal("list"),
1422
+ cursor: Type2.Optional(Type2.Integer({ minimum: 0 })),
1423
+ limit: Type2.Optional(Type2.Integer({ minimum: 1, maximum: 100 }))
1424
+ }, { additionalProperties: false })
1425
+ ]);
1426
+ var parameters = Type2.Object({
1427
+ action: Type2.Optional(Type2.String({ enum: ["create", "continue", "list"], description: "Defaults to create." })),
1428
+ type: Type2.Optional(Type2.String({ enum: ["explore", "plan", "coder"], description: "Required for create only." })),
1429
+ agentId: Type2.Optional(Type2.String({ description: "Required for continue only; use an ID returned by create/list." })),
1430
+ title: Type2.Optional(Type2.String({ minLength: 1, maxLength: 80, description: "Required for create/continue." })),
1431
+ task: Type2.Optional(Type2.String({ minLength: 1, maxLength: 32768, description: "Required for create/continue." })),
1432
+ cursor: Type2.Optional(Type2.Integer({ minimum: 0, description: "List pagination cursor only." })),
1433
+ limit: Type2.Optional(Type2.Integer({ minimum: 1, maximum: 100, description: "List page size only." }))
1434
+ }, { additionalProperties: false });
1435
+ var structuredOutputSchema = Type2.Union([
1436
+ Type2.Object({
1437
+ agentId: Type2.String(),
1438
+ roundId: Type2.Integer(),
1439
+ status: Type2.Enum(["completed", "failed", "cancelled"]),
1440
+ report: Type2.String()
1441
+ }, { additionalProperties: false }),
1442
+ Type2.Object({
1443
+ items: Type2.Array(Type2.Object({
1444
+ agentId: Type2.String(),
1445
+ parentId: Type2.Integer(),
1446
+ childId: Type2.Integer(),
1447
+ role: Type2.Enum(["explore", "plan", "coder"]),
1448
+ title: Type2.String(),
1449
+ status: Type2.Enum(["idle", "queued", "running"]),
1450
+ activeRoundId: Type2.Union([Type2.Integer(), Type2.Null()]),
1451
+ lastRoundId: Type2.Integer(),
1452
+ lastReport: Type2.Optional(Type2.Union([Type2.String(), Type2.Null()])),
1453
+ roundCount: Type2.Integer(),
1454
+ taskTitle: Type2.String(),
1455
+ approvalCount: Type2.Integer(),
1456
+ createdAt: Type2.Number()
1457
+ }, { additionalProperties: false })),
1458
+ next: Type2.Union([Type2.Integer(), Type2.Null()])
1459
+ }, { additionalProperties: false })
1460
+ ]);
1310
1461
  function createSubagentExtension(prepare, discard) {
1462
+ const finish = async (tx, input, status, report) => {
1463
+ const round = (await tx.doc(DelegationsDoc, input.parentId)).items.find((r) => r.taskId === input.taskId);
1464
+ round.status = status;
1465
+ round.report = report;
1466
+ round.endedAt ??= Date.now();
1467
+ round.usageEnd = JSON.parse(JSON.stringify(await tx.doc(UsageDoc, input.childId)));
1468
+ round.roundUsage = round.usageStart ? subagentUsageTotals(subtractSubagentUsage(round.usageEnd, round.usageStart)) : round.startedAt === null ? subagentUsageTotals(emptySubagentUsage()) : null;
1469
+ };
1470
+ const RoundTask = defineTask({
1471
+ name: "app.neko.subagent-round",
1472
+ version: 1,
1473
+ initial: () => ({ phase: "admission" }),
1474
+ phases: {
1475
+ admission: async (task, runtime, ctx) => {
1476
+ const env = await runtime.env(ctx);
1477
+ const canonical = env && await env.canonicalPath(task.input.cwd, ctx);
1478
+ const slotCwd = canonical?.ok ? canonical.value : task.input.cwd;
1479
+ let admitted = false;
1480
+ while (!admitted) {
1481
+ await runtime.commit(async (tx) => {
1482
+ const round = (await tx.doc(DelegationsDoc, task.input.parentId)).items.find((r) => r.taskId === task.input.taskId);
1483
+ if (!await acquireSlot(tx, round, slotCwd))
1484
+ return;
1485
+ admitted = true;
1486
+ const duplicate = await tx.submissionByRequest(task.input.childId, `subagent:${round.taskId}`);
1487
+ if (!duplicate && !round.usageStart)
1488
+ round.usageStart = JSON.parse(JSON.stringify(await tx.doc(UsageDoc, task.input.childId)));
1489
+ round.status = "running";
1490
+ round.startedAt ??= Date.now();
1491
+ return { status: "running", checkpoint: { phase: "deliver" } };
1492
+ }, ctx);
1493
+ if (!admitted)
1494
+ await runtime.sleep(runtime.now() + 100, ctx);
1495
+ }
1496
+ },
1497
+ deliver: async (task, runtime, ctx) => {
1498
+ const input = task.input;
1499
+ const child = await runtime.conversation(input.childId, ctx);
1500
+ const requestId = `subagent:${input.taskId}`;
1501
+ try {
1502
+ let duplicate = false;
1503
+ await runtime.commit(async (tx) => {
1504
+ duplicate = !!await tx.submissionByRequest(input.childId, requestId);
1505
+ }, ctx);
1506
+ if (!duplicate)
1507
+ await prepare(input.childId, requestId, input.cwd);
1508
+ const submission = await child.submit({
1509
+ type: "input",
1510
+ content: input.task,
1511
+ requestId,
1512
+ whenBusy: "reject",
1513
+ entry: {
1514
+ kind: NekoInput.kind,
1515
+ data: { at: Date.now(), prompt: input.task, images: [], subagentRoundId: input.taskId }
1516
+ }
1517
+ }, ctx);
1518
+ const placed = await submission.status(ctx);
1519
+ await runtime.commit(async (tx) => {
1520
+ if (placed.type === "input" && placed.entry !== undefined)
1521
+ (await tx.doc(DelegationsDoc, input.parentId)).items.find((r) => r.taskId === input.taskId).inputEntryId = placed.entry;
1522
+ return { status: "running", checkpoint: { phase: "settle" } };
1523
+ }, ctx);
1524
+ } catch (error) {
1525
+ discard(input.childId, requestId);
1526
+ if (ctx.abortSignal?.aborted)
1527
+ throw error;
1528
+ await child.abort(ctx);
1529
+ const report = `Subagent failed: ${String(error)}`;
1530
+ await runtime.commit(async (tx) => {
1531
+ await finish(tx, input, "failed", report);
1532
+ return { status: "terminal", outcome: { status: "completed", result: { status: "failed", report } } };
1533
+ }, ctx);
1534
+ }
1535
+ },
1536
+ settle: async (task, runtime, ctx) => {
1537
+ const input = task.input;
1538
+ const child = await runtime.conversation(input.childId, ctx);
1539
+ const submission = await child.submit({ type: "input", content: input.task, requestId: `subagent:${input.taskId}` }, ctx);
1540
+ const settled = await submission.wait(ctx);
1541
+ await child.waitForIdle(ctx);
1542
+ await runtime.commit(async (tx) => {
1543
+ let report = "";
1544
+ let status = "failed";
1545
+ const round = (await tx.doc(DelegationsDoc, input.parentId)).items.find((r) => r.taskId === input.taskId);
1546
+ if (settled.type === "input" && settled.entry !== undefined)
1547
+ round.inputEntryId = settled.entry;
1548
+ if (settled.status === "done" && settled.type === "input") {
1549
+ round.answerEntryId = settled.answer;
1550
+ const message = (await tx.entry(AssistantEntry, settled.answer))?.model?.[0];
1551
+ report = message?.role === "assistant" ? message.content.flatMap((b) => b.type === "text" ? [b.text] : []).join("") : "";
1552
+ status = "completed";
1553
+ } else if (settled.status === "unanswered") {
1554
+ status = settled.reason === "aborted" ? "cancelled" : "failed";
1555
+ report = `Subagent ${status}: ${settled.reason}`;
1556
+ }
1557
+ await finish(tx, input, status, report);
1558
+ return { status: "terminal", outcome: { status: "completed", result: { status, report } } };
1559
+ }, ctx);
1560
+ }
1561
+ },
1562
+ abort: async (task, runtime, ctx) => {
1563
+ const input = task.input;
1564
+ let own = false;
1565
+ await runtime.commit(async (tx) => {
1566
+ own = (await tx.doc(SubagentsDoc, input.parentId)).items.some((a) => a.childId === input.childId && a.activeRoundId === input.taskId);
1567
+ }, ctx);
1568
+ if (own)
1569
+ await (await runtime.conversation(input.childId, ctx))?.abort(ctx);
1570
+ await runtime.commit(async (tx) => {
1571
+ await finish(tx, input, "cancelled", "Subagent cancelled");
1572
+ return { status: "terminal", outcome: { status: "aborted" } };
1573
+ }, ctx);
1574
+ }
1575
+ });
1311
1576
  const extension = defineExtension3({
1312
1577
  name: "neko-subagents",
1578
+ tasks: [RoundTask],
1313
1579
  tools: [
1314
1580
  defineTool2({
1315
1581
  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
- }),
1582
+ 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.",
1583
+ parameters,
1584
+ structuredOutputSchema,
1322
1585
  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.");
1586
+ execute: async (input, api, context) => {
1587
+ if (!Value.Check(actionParameters, input))
1588
+ 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.");
1589
+ const args = input;
1326
1590
  if ((await api.snapshot(SubagentDoc, api.conversationId, context))?.role)
1327
1591
  throw new Error("Subagents cannot delegate.");
1592
+ if (args.action === "list") {
1593
+ const list = await api.commit(async (tx) => {
1594
+ const rounds = await refreshDelegations(tx, api.conversationId);
1595
+ return summarizeSubagents(rounds, (await tx.doc(SubagentsDoc, api.conversationId)).items).filter((a) => a.historyThrough === undefined);
1596
+ }, context);
1597
+ const offset = args.cursor ?? 0;
1598
+ const items = list.slice(offset, offset + (args.limit ?? 50));
1599
+ const result = { items, next: offset + items.length < list.length ? offset + items.length : null };
1600
+ return { output: [{ type: "text", text: JSON.stringify(result) }], structuredOutput: result };
1601
+ }
1602
+ if (!args.task.trim() || !args.title.trim() || Buffer.byteLength(args.task) > 32768)
1603
+ throw new Error("Task/title must be nonempty; task limit is 32 KiB.");
1328
1604
  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({
1605
+ const round = await api.commit(async (tx) => {
1606
+ const rounds = await refreshDelegations(tx, api.conversationId);
1607
+ const receipt = rounds.find((r) => r.taskId === api.taskId);
1608
+ if (receipt?.runnerId !== undefined)
1609
+ return receipt;
1610
+ const identities = await tx.doc(SubagentsDoc, api.conversationId);
1611
+ let identity;
1612
+ if (args.action === "continue") {
1613
+ identity = identities.items.find((a) => a.agentId === args.agentId);
1614
+ if (!identity)
1615
+ throw new Error("Unknown subagent or it belongs to another conversation.");
1616
+ const info = await tx.doc(SubagentDoc, identity.childId);
1617
+ if (info.parentId !== api.conversationId || info.role !== identity.role)
1618
+ throw new Error("Subagent ownership mismatch.");
1619
+ if (identity.activeRoundId !== null)
1620
+ throw new Error("Subagent is busy; continue only when idle.");
1621
+ } else if (receipt) {
1622
+ identity = identities.items.find((a) => a.childId === receipt.childId);
1623
+ }
1624
+ const existing = identity ? undefined : (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
1625
+ const child = identity ? await tx.conversation(identity.childId) : existing;
1626
+ if (!identity) {
1627
+ const created = child ?? await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
1628
+ const role = args.action === "continue" ? "explore" : args.type;
1629
+ await configure(tx, created.id, {
1630
+ model: agent.model,
1631
+ thinkingLevel: agent.thinkingLevel,
1632
+ cwd: agent.cwd,
1633
+ extensions: agent.extensions.filter((e) => e.name !== extension.name),
1634
+ tools: agent.tools.filter((t) => t.name !== "subagent" && (role === "coder" || READ_TOOLS.has(t.name) || role === "explore" && t.name === "bash"))
1635
+ });
1636
+ Object.assign(await tx.doc(SubagentDoc, created.id), { role, parentId: api.conversationId });
1637
+ identity = {
1638
+ agentId: agentIdFor(created.id),
1639
+ parentId: api.conversationId,
1640
+ childId: created.id,
1641
+ role,
1642
+ cwd: agent.cwd ?? "",
1643
+ title: args.title,
1644
+ createdAt: Date.now(),
1645
+ activeRoundId: null
1646
+ };
1647
+ identities.items.push(identity);
1648
+ }
1649
+ const record = receipt ?? {
1343
1650
  parentId: api.conversationId,
1344
1651
  taskId: api.taskId,
1345
1652
  callId: api.callId,
1346
- childId: child.id,
1347
- role: args.type,
1653
+ childId: identity.childId,
1654
+ agentId: identity.agentId,
1655
+ round: rounds.filter((r) => r.childId === identity.childId).length + 1,
1656
+ role: identity.role,
1348
1657
  title: args.title,
1349
1658
  createdAt: Date.now(),
1350
1659
  startedAt: null,
1351
1660
  endedAt: null,
1352
1661
  status: "queued",
1353
1662
  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();
1663
+ };
1664
+ identities.items.find((a) => a.agentId === identity.agentId).activeRoundId = api.taskId;
1665
+ const runner = await tx.createTask(RoundTask, {
1666
+ parentId: api.conversationId,
1667
+ childId: identity.childId,
1668
+ taskId: api.taskId,
1669
+ task: args.task,
1670
+ cwd: identity.cwd || agent.cwd || ""
1671
+ }, { ownership: { kind: "task", taskId: api.taskId } });
1672
+ record.runnerId = runner;
1673
+ const doc = await tx.doc(DelegationsDoc, api.conversationId);
1674
+ if (receipt)
1675
+ Object.assign(doc.items.find((r) => r.taskId === receipt.taskId), record);
1676
+ else
1677
+ doc.items.push(record);
1678
+ return record;
1364
1679
  }, 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();
1680
+ await api.details({
1681
+ conversationId: round.childId,
1682
+ agentId: round.agentId,
1683
+ roundId: round.taskId,
1684
+ role: round.role,
1685
+ title: round.title
1399
1686
  }, context);
1687
+ const settled = await api.waitForTask(round.runnerId, context);
1688
+ const outcome = settled.state.outcome;
1689
+ const result = outcome.status === "completed" ? outcome.result : { status: outcome.status === "aborted" ? "cancelled" : "failed", report: `Subagent ${outcome.status}` };
1400
1690
  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 }
1691
+ output: [
1692
+ {
1693
+ type: "text",
1694
+ text: `Agent: ${round.agentId}
1695
+ Status: ${result.status}
1696
+ ${result.report || "Subagent completed without a text report."}`
1697
+ }
1698
+ ],
1699
+ isError: result.status !== "completed",
1700
+ structuredOutput: { agentId: round.agentId, roundId: round.taskId, ...result },
1701
+ details: {
1702
+ conversationId: round.childId,
1703
+ agentId: round.agentId,
1704
+ roundId: round.taskId,
1705
+ role: round.role,
1706
+ title: round.title,
1707
+ status: result.status
1708
+ }
1404
1709
  };
1405
1710
  }
1406
1711
  })
@@ -1409,15 +1714,70 @@ function createSubagentExtension(prepare, discard) {
1409
1714
  return extension;
1410
1715
  }
1411
1716
  async function listDelegations(harness, parent, context) {
1717
+ const rounds = await harness.commit((tx) => refreshDelegations(tx, parent), context);
1718
+ for (const round of rounds) {
1719
+ if (round.historyThrough !== undefined)
1720
+ continue;
1721
+ const task = await harness.getTask(round.taskId, context);
1722
+ if (task?.state.status !== "terminal")
1723
+ continue;
1724
+ const child = await harness.conversation(round.childId, context);
1725
+ const live = await harness.snapshot(LiveDoc, round.childId, context);
1726
+ const identity = (await harness.snapshot(SubagentsDoc, parent, context))?.items.find((a) => a.childId === round.childId);
1727
+ if (identity?.activeRoundId === round.taskId && live?.run !== undefined)
1728
+ await child?.abort(context);
1729
+ }
1412
1730
  return harness.commit((tx) => refreshDelegations(tx, parent), context);
1413
1731
  }
1732
+ async function installSubagentCoordinator(harness, parents, context) {
1733
+ const known = new Set(parents);
1734
+ let running = false;
1735
+ let dirty = false;
1736
+ const reconcile = async () => {
1737
+ if (running) {
1738
+ dirty = true;
1739
+ return;
1740
+ }
1741
+ running = true;
1742
+ try {
1743
+ do {
1744
+ dirty = false;
1745
+ for (const id of known)
1746
+ await listDelegations(harness, id, context);
1747
+ } while (dirty);
1748
+ } finally {
1749
+ running = false;
1750
+ }
1751
+ };
1752
+ for (const parent of parents) {
1753
+ await harness.commit((tx) => refreshDelegations(tx, parent), context);
1754
+ for (const identity of (await harness.snapshot(SubagentsDoc, parent, context))?.items ?? []) {
1755
+ if (identity.cwd)
1756
+ continue;
1757
+ const child = await harness.conversation(identity.childId, context);
1758
+ const cwd = (await child?.agent(context))?.cwd;
1759
+ if (cwd)
1760
+ await harness.commit(async (tx) => {
1761
+ (await tx.doc(SubagentsDoc, parent)).items.find((a) => a.agentId === identity.agentId).cwd = cwd;
1762
+ }, context);
1763
+ }
1764
+ }
1765
+ const stop = harness.subscribeCommits(({ changes }) => {
1766
+ for (const c of changes)
1767
+ if (c.type === "document" && c.record.kind === "app.neko.delegations" && c.conversationId !== undefined)
1768
+ known.add(c.conversationId);
1769
+ if (changes.some((c) => c.type === "task" && c.value.state.status === "terminal"))
1770
+ reconcile().catch(() => {});
1771
+ });
1772
+ harness.closed.finally(stop).catch(() => {});
1773
+ }
1414
1774
 
1415
1775
  // packages/core/src/prompt/prompt.ts
1416
1776
  var KEYS = ["preamble", "rules", "project_context", "skills", "cwd"];
1417
1777
  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
1778
  var TOOL_GUIDELINES = {
1419
1779
  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."
1780
+ "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
1781
  ],
1422
1782
  find_files: [
1423
1783
  "Use find_files to discover files; query is a literal substring. Narrow the directory when results are partial."
@@ -1627,9 +1987,9 @@ import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
1627
1987
  import {
1628
1988
  defineDoc as defineDoc2,
1629
1989
  defineExtension as defineExtension5,
1630
- defineTask,
1990
+ defineTask as defineTask2,
1631
1991
  InboxDoc,
1632
- LiveDoc
1992
+ LiveDoc as LiveDoc2
1633
1993
  } from "@earendil-works/pi-durable";
1634
1994
  var SessionMetadataDoc = defineDoc2({
1635
1995
  kind: "app.neko.session-metadata",
@@ -1694,7 +2054,7 @@ function mergeUsage(base, extra) {
1694
2054
  function namingTask() {
1695
2055
  let active = 0;
1696
2056
  const waits = new Set;
1697
- return defineTask({
2057
+ return defineTask2({
1698
2058
  name: "app.neko.name",
1699
2059
  version: 1,
1700
2060
  initial: () => ({ phase: "wait", until: Date.now() + 100 }),
@@ -1908,7 +2268,7 @@ class SessionMetadataManager {
1908
2268
  const answer = record.status === "done" ? (await this.storage.entry(record.answer, this.context))?.entry.model?.[0] : undefined;
1909
2269
  return { record, data, answer: answer?.role === "assistant" ? answer : undefined };
1910
2270
  }));
1911
- const live = await this.harness.snapshot(LiveDoc, id, this.context);
2271
+ const live = await this.harness.snapshot(LiveDoc2, id, this.context);
1912
2272
  await this.harness.commit(async (tx) => {
1913
2273
  const d = await tx.doc(SessionMetadataDoc, id);
1914
2274
  for (const { record, data, answer } of updates) {
@@ -2091,7 +2451,8 @@ function createApprovalExtension(service) {
2091
2451
  const child = await approvals.harness.conversation(api.conversationId, context);
2092
2452
  const agent = await child.agent(context);
2093
2453
  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);
2454
+ const identity = (await api.snapshot(SubagentsDoc, parent, context))?.items.find((a) => a.childId === api.conversationId);
2455
+ const delegation = (await api.snapshot(DelegationsDoc, parent, context))?.items.find((d) => d.childId === api.conversationId && d.historyThrough === undefined && d.taskId === identity?.activeRoundId);
2095
2456
  if (!delegation)
2096
2457
  return { block: "Missing delegation" };
2097
2458
  await approvals.ensure(parent, {
@@ -2804,6 +3165,7 @@ async function createNekoRuntime(options = {}) {
2804
3165
  approvals = new ToolApprovals(harness, context);
2805
3166
  const existing = await scanConversations();
2806
3167
  try {
3168
+ await installSubagentCoordinator(harness, existing.map((r) => r.id), context);
2807
3169
  await prepareApprovalConversations(harness, existing.map((r) => r.id), approvalExtension, context);
2808
3170
  for (const record of existing) {
2809
3171
  const target = await harness.conversation(record.id, context);
@@ -2988,7 +3350,7 @@ async function createNekoRuntime(options = {}) {
2988
3350
  return watchEvents(harness, target.id, context);
2989
3351
  },
2990
3352
  async usage() {
2991
- return mergeUsage(await harness.snapshot(UsageDoc, target.id, context) ?? { models: {}, tools: {} }, (await harness.snapshot(SessionMetadataDoc, target.id, context))?.namingUsage ?? { models: {}, tools: {} });
3353
+ return mergeUsage(await harness.snapshot(UsageDoc2, target.id, context) ?? { models: {}, tools: {} }, (await harness.snapshot(SessionMetadataDoc, target.id, context))?.namingUsage ?? { models: {}, tools: {} });
2992
3354
  },
2993
3355
  compactionRecord() {
2994
3356
  return readCompaction(harness, storage, target.id, context);
@@ -3037,7 +3399,7 @@ async function createNekoRuntime(options = {}) {
3037
3399
  async globalUsage() {
3038
3400
  let total = { models: {}, tools: {} };
3039
3401
  for (const record of await scanConversations()) {
3040
- total = mergeUsage(total, await harness.snapshot(UsageDoc, record.id, context) ?? { models: {}, tools: {} });
3402
+ total = mergeUsage(total, await harness.snapshot(UsageDoc2, record.id, context) ?? { models: {}, tools: {} });
3041
3403
  total = mergeUsage(total, (await harness.snapshot(SessionMetadataDoc, record.id, context))?.namingUsage ?? { models: {}, tools: {} });
3042
3404
  }
3043
3405
  return total;
@@ -3072,14 +3434,24 @@ async function createNekoRuntime(options = {}) {
3072
3434
  }
3073
3435
  cursor = page.next;
3074
3436
  } while (cursor);
3437
+ const frozen = new Map;
3075
3438
  for (const reference of references) {
3076
3439
  if (!calls.has(reference.callId))
3077
3440
  continue;
3078
- const latest = await tx.scanEntries({ conversationId: reference.childId }, 1);
3079
- const usage = totalUsage(await tx.doc(UsageDoc, reference.childId));
3441
+ let snapshot = frozen.get(reference.childId);
3442
+ if (!snapshot) {
3443
+ const latest = await tx.scanEntries({ conversationId: reference.childId }, 1);
3444
+ snapshot = {
3445
+ through: latest.items[0]?.id ?? 0,
3446
+ state: JSON.parse(JSON.stringify(await tx.doc(UsageDoc2, reference.childId)))
3447
+ };
3448
+ frozen.set(reference.childId, snapshot);
3449
+ }
3450
+ const usage = totalUsage(snapshot.state);
3080
3451
  inherited.push({
3081
3452
  ...reference,
3082
- historyThrough: reference.historyThrough ?? latest.items[0]?.id ?? 0,
3453
+ historyThrough: reference.historyThrough ?? snapshot.through,
3454
+ roundUsage: reference.roundUsage ?? (reference.usageStart ? subagentUsageTotals(subtractSubagentUsage(snapshot.state, reference.usageStart)) : null),
3083
3455
  historyUsage: reference.historyUsage ?? {
3084
3456
  input: usage.input,
3085
3457
  output: usage.output,
@@ -3315,8 +3687,8 @@ import { dirname as dirname5, join as join7 } from "path";
3315
3687
  import { fileURLToPath as fileURLToPath2 } from "url";
3316
3688
 
3317
3689
  // packages/core/src/application-version.ts
3318
- var RUNNING_VERSION = "0.3.0";
3319
- var APPLICATION_PROTOCOL = 3;
3690
+ var RUNNING_VERSION = "0.3.1";
3691
+ var APPLICATION_PROTOCOL = 4;
3320
3692
  function stableVersion(value) {
3321
3693
  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
3694
  }
@@ -3513,6 +3885,12 @@ var call = Type4.Object({
3513
3885
  });
3514
3886
  var DelegationSchema = Type4.Object({
3515
3887
  parentId: integer,
3888
+ agentId: Type4.Optional(str),
3889
+ round: Type4.Optional(integer),
3890
+ runnerId: Type4.Optional(integer),
3891
+ inputEntryId: Type4.Optional(integer),
3892
+ answerEntryId: Type4.Optional(integer),
3893
+ roundUsage: Type4.Optional(Type4.Union([usage, nil])),
3516
3894
  taskId: integer,
3517
3895
  callId: str,
3518
3896
  childId: integer,
@@ -3527,8 +3905,27 @@ var DelegationSchema = Type4.Object({
3527
3905
  historyThrough: Type4.Optional(integer),
3528
3906
  historyUsage: Type4.Optional(usage)
3529
3907
  });
3908
+ var SubagentSummarySchema = Type4.Object({
3909
+ agentId: str,
3910
+ parentId: integer,
3911
+ childId: integer,
3912
+ role: Type4.Enum(["explore", "plan", "coder"]),
3913
+ title: str,
3914
+ status: Type4.Enum(["idle", "queued", "running"]),
3915
+ activeRoundId: Type4.Union([integer, nil]),
3916
+ lastRoundId: integer,
3917
+ lastReport: Type4.Optional(nullableString),
3918
+ roundCount: integer,
3919
+ taskTitle: str,
3920
+ approvalCount: integer,
3921
+ createdAt: num,
3922
+ historyThrough: Type4.Optional(integer),
3923
+ historyUsage: Type4.Optional(usage)
3924
+ });
3530
3925
  var ChatViewSchema = Type4.Object({
3531
3926
  historyBefore: Type4.Optional(Type4.Union([integer, nil])),
3927
+ agents: Type4.Optional(Type4.Array(SubagentSummarySchema)),
3928
+ rounds: Type4.Optional(Type4.Array(DelegationSchema)),
3532
3929
  subagents: Type4.Optional(Type4.Array(DelegationSchema)),
3533
3930
  taskUsage: Type4.Optional(usage),
3534
3931
  model: str,
@@ -3684,7 +4081,12 @@ var NekoModels = contract("neko.models", {
3684
4081
  var imageChunk = Type4.Object({ data: str, total: integer, mimeType: str });
3685
4082
  var NekoSubagent = contract("neko.subagent", {
3686
4083
  version: 1,
3687
- input: Type4.Object({ sessionId: str, attachmentId: str, taskId: integer }),
4084
+ input: Type4.Object({
4085
+ sessionId: str,
4086
+ attachmentId: str,
4087
+ taskId: Type4.Optional(integer),
4088
+ agentId: Type4.Optional(str)
4089
+ }),
3688
4090
  variants: {
3689
4091
  ready: {
3690
4092
  state: { revision: integer },
@@ -3697,7 +4099,7 @@ var NekoSubagent = contract("neko.subagent", {
3697
4099
  }), Type4.Object({ token: integer, data: str, next: Type4.Union([integer, nil]) })),
3698
4100
  getImage: rpc(Type4.Object({ entryId: integer, contentIndex: integer, offset: integer }), imageChunk),
3699
4101
  decideApproval: rpc(Type4.Object({ id: integer, version: str, decision: Type4.Enum(["allowed", "denied"]) }), Type4.Enum(["pending", "allowed", "denied", "cancelled"])),
3700
- stop: rpc(nil, nil)
4102
+ stop: rpc(Type4.Union([nil, Type4.Object({ taskId: integer })]), nil)
3701
4103
  }
3702
4104
  }
3703
4105
  }
@@ -3839,7 +4241,7 @@ import {
3839
4241
  resource as resource2
3840
4242
  } from "@earendil-works/chord";
3841
4243
  import { withAbortSignal as withAbortSignal3 } from "@earendil-works/chord/context";
3842
- import { UsageDoc as UsageDoc2 } from "@earendil-works/pi-durable";
4244
+ import { UsageDoc as UsageDoc4 } from "@earendil-works/pi-durable";
3843
4245
  import { Type as Type6 } from "typebox";
3844
4246
 
3845
4247
  // packages/gateway/src/image-uploads.ts
@@ -3946,6 +4348,7 @@ import {
3946
4348
  opaque,
3947
4349
  resource
3948
4350
  } from "@earendil-works/chord";
4351
+ import { UsageDoc as UsageDoc3 } from "@earendil-works/pi-durable";
3949
4352
  import { Type as Type5 } from "typebox";
3950
4353
  async function openReader(runtime, childId, parentId, context, through, historyUsage) {
3951
4354
  const child = await runtime.harness.conversation(childId, context);
@@ -4008,7 +4411,7 @@ async function openReader(runtime, childId, parentId, context, through, historyU
4008
4411
  return () => {};
4009
4412
  const stop = native.subscribe(() => fn(null));
4010
4413
  const stopApprovals = runtime.harness.subscribeCommits(({ changes }) => {
4011
- if (changes.some((c) => c.type === "document" && c.record.kind === "app.neko.approvals" && c.conversationId === parentId))
4414
+ if (changes.some((c) => c.type === "document" && ["app.neko.approvals", "app.neko.delegations", "app.neko.subagents"].includes(c.record.kind) && c.conversationId === parentId))
4012
4415
  fn(null);
4013
4416
  });
4014
4417
  return () => {
@@ -4019,7 +4422,12 @@ async function openReader(runtime, childId, parentId, context, through, historyU
4019
4422
  async refresh() {
4020
4423
  const page = await pageAt();
4021
4424
  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 });
4425
+ const currentUsage = await runtime.harness.snapshot(UsageDoc3, childId, context);
4426
+ const rounds = (await listDelegations(runtime.harness, parentId, context)).filter((r) => r.childId === childId).map(({ usageStart, usageEnd: _end, ...round }) => ({
4427
+ ...round,
4428
+ roundUsage: round.roundUsage ?? (through === undefined && usageStart && currentUsage ? subagentUsageTotals(subtractSubagentUsage(currentUsage, usageStart)) : null)
4429
+ }));
4430
+ revision = remember({ ...project(page.entries), rounds, approvals, historyBefore: page.next });
4023
4431
  return revision;
4024
4432
  },
4025
4433
  async chunk(input) {
@@ -4066,7 +4474,7 @@ async function openReader(runtime, childId, parentId, context, through, historyU
4066
4474
  }
4067
4475
  var ChildProjection = local("neko.subagent-projection", {
4068
4476
  version: 1,
4069
- input: Type5.Object({ parentId: Type5.Integer(), taskId: Type5.Integer() }),
4477
+ input: Type5.Object({ parentId: Type5.Integer(), agentId: Type5.String() }),
4070
4478
  members: {
4071
4479
  revision: Type5.Integer(),
4072
4480
  reader: opaque("neko.subagent-reader@1")
@@ -4079,7 +4487,7 @@ function installSubagentHost(host, runtime, context) {
4079
4487
  reader: resource({
4080
4488
  rev: 1,
4081
4489
  async open() {
4082
- const entry = (await listDelegations(runtime.harness, input.parentId, context)).find((d) => d.taskId === input.taskId);
4490
+ 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
4491
  if (!entry)
4084
4492
  throw new ChordError("gone", "Subagent unavailable");
4085
4493
  const r = await openReader(runtime, entry.childId, input.parentId, context, entry.historyThrough, entry.historyUsage);
@@ -4109,13 +4517,13 @@ function installSubagentHost(host, runtime, context) {
4109
4517
  throw new ChordError("forbidden", "Attachment expired");
4110
4518
  if (!await runtime.getConversation(Number(input.sessionId)))
4111
4519
  throw new ChordError("gone", "Session unavailable");
4112
- const entry = (await listDelegations(runtime.harness, Number(input.sessionId), context)).find((d) => d.taskId === input.taskId);
4520
+ 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
4521
  if (!entry)
4114
4522
  throw new ChordError("forbidden", "Subagent does not belong to this session");
4115
4523
  return entry;
4116
4524
  };
4117
4525
  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 });
4526
+ const shared = self.get(ChildProjection, { parentId: Number(input.sessionId), agentId: input.agentId ?? `round-${input.taskId}` }, { principal: hostPrincipal });
4119
4527
  const { authorizedRevision } = self.state({ authorizedRevision: cell(Type5.Integer(), 0) });
4120
4528
  self.listen({
4121
4529
  authorizedView: listener(shared.revision, async (value, ctx) => {
@@ -4144,14 +4552,26 @@ function installSubagentHost(host, runtime, context) {
4144
4552
  const entry = await check();
4145
4553
  if (entry.historyThrough !== undefined)
4146
4554
  throw new ChordError("forbidden", "Fork history is read-only");
4147
- return runtime.approvals.decide(Number(input.sessionId), entry.taskId, id, version, decision, context);
4555
+ const approval = (await runtime.approvals.list(Number(input.sessionId), context)).find((a) => a.id === id && a.childId === entry.childId);
4556
+ if (!approval)
4557
+ throw new ChordError("forbidden", "Approval does not belong to this agent");
4558
+ return runtime.approvals.decide(Number(input.sessionId), approval.delegationTaskId, id, version, decision, context);
4148
4559
  },
4149
- stop: async () => {
4560
+ stop: async (request) => {
4150
4561
  const entry = await check();
4151
4562
  if (entry.historyThrough !== undefined)
4152
4563
  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);
4564
+ const taskId = request?.taskId ?? input.taskId;
4565
+ if (taskId === undefined)
4566
+ throw new ChordError("invalid", "Select the exact active round to stop");
4567
+ const valid = await runtime.harness.commit(async (tx) => {
4568
+ const identity = (await tx.doc(SubagentsDoc, Number(input.sessionId))).items.find((a) => a.childId === entry.childId);
4569
+ return identity?.activeRoundId === taskId;
4570
+ }, context);
4571
+ if (!valid)
4572
+ return null;
4573
+ await runtime.harness.abortTask(taskId, context);
4574
+ await runtime.harness.waitForTask(taskId, context);
4155
4575
  return null;
4156
4576
  }
4157
4577
  };
@@ -4198,7 +4618,7 @@ async function openProjection(runtime, session, context) {
4198
4618
  const stopMetadata = runtime.onDirectoryChange(() => changed?.(null));
4199
4619
  let childIds = new Set;
4200
4620
  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))))
4621
+ 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
4622
  changed?.(null);
4203
4623
  });
4204
4624
  return {
@@ -4230,15 +4650,19 @@ async function openProjection(runtime, session, context) {
4230
4650
  entry.approvalCount = entry.historyThrough === undefined ? approvals.filter((a) => a.delegationTaskId === entry.taskId && a.status === "pending").length : 0;
4231
4651
  childIds = new Set(subagents.filter((entry) => entry.historyThrough === undefined).map((entry) => entry.childId));
4232
4652
  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) ?? {
4653
+ for (const child of new Map(subagents.filter((entry) => entry.historyThrough === undefined).map((r) => [r.childId, r])).values())
4654
+ aggregate = mergeUsage(aggregate, await runtime.harness.snapshot(UsageDoc4, child.childId, context) ?? {
4235
4655
  models: {},
4236
4656
  tools: {}
4237
4657
  });
4238
4658
  const u = totalUsage(aggregate);
4239
4659
  return {
4240
4660
  ...projected,
4241
- subagents: subagents.map((entry) => ({ ...entry, report: entry.report?.slice(0, 512) ?? null })),
4661
+ agents: summarizeSubagents(subagents, (await runtime.harness.snapshot(SubagentsDoc, session.id, context))?.items),
4662
+ subagents: subagents.map(({ usageStart: _start, usageEnd: _end, ...entry }) => ({
4663
+ ...entry,
4664
+ report: entry.report?.slice(0, 512) ?? null
4665
+ })),
4242
4666
  taskUsage: {
4243
4667
  input: u.input,
4244
4668
  output: u.output,