@themoltnet/agent-daemon 0.5.3 → 0.6.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.
Files changed (3) hide show
  1. package/README.md +19 -0
  2. package/dist/main.js +221 -64
  3. package/package.json +5 -5
package/README.md CHANGED
@@ -66,6 +66,25 @@ To use an alternate auth-file path: `PI_AUTH_PATH=/abs/path/to/auth.json`.
66
66
  | `MOLTNET_OTEL_ENDPOINT` | unset | OTLP traces endpoint. Empty = disabled. |
67
67
  | `LOG_LEVEL` | `info` | Pino log level override. |
68
68
 
69
+ ### Host command auto-approval
70
+
71
+ The daemon reads `sandbox.json` through `--sandbox` or by searching up from the
72
+ current directory. Configure host-side auto-approval there, not in task data:
73
+
74
+ ```json
75
+ {
76
+ "hostExec": {
77
+ "autoApprove": [
78
+ { "argsPrefix": ["push"], "executable": "git" },
79
+ { "argsPrefix": ["pr", "create"], "executable": "gh" }
80
+ ]
81
+ }
82
+ }
83
+ ```
84
+
85
+ Set `"autoApprove": true` only for isolated hosts where every built-in
86
+ host-exec command is safe to run without a dialog.
87
+
69
88
  ## Correlation anchors
70
89
 
71
90
  When a `fulfill_brief` task carries a non-null `correlationId`, the daemon
package/dist/main.js CHANGED
@@ -3,7 +3,7 @@ import crypto, { createHash } from "crypto";
3
3
  import { parseArgs, parseEnv, promisify } from "node:util";
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
5
5
  import { ROOT_CONTEXT, SpanStatusCode, context, metrics, propagation, trace } from "@opentelemetry/api";
6
- import { pino } from "pino";
6
+ import { pino, transport } from "pino";
7
7
  import { readFile } from "node:fs/promises";
8
8
  import { createHash as createHash$1 } from "node:crypto";
9
9
  import path, { dirname, isAbsolute, join, resolve } from "node:path";
@@ -12,6 +12,7 @@ import { execFile, execFileSync } from "node:child_process";
12
12
  import { DefaultResourceLoader, SessionManager, createAgentSession, createBashToolDefinition, createEditToolDefinition, createReadToolDefinition, createSyntheticSourceInfo, createWriteToolDefinition, defineTool, parseFrontmatter } from "@earendil-works/pi-coding-agent";
13
13
  import { Type, getModel } from "@earendil-works/pi-ai";
14
14
  import { MemoryProvider, RealFSProvider, ShadowProvider, VM, VmCheckpoint, createHttpHooks, createShadowPathPredicate, ensureImageSelector, loadGuestAssets } from "@earendil-works/gondolin";
15
+ import { once } from "node:events";
15
16
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
16
17
  import { resourceFromAttributes } from "@opentelemetry/resources";
17
18
  import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
@@ -5990,6 +5991,14 @@ var COMMON_OPTIONAL_FLAGS = `\
5990
5991
  --heartbeat-interval-ms <n> Reporter heartbeat cadence. Default: 60000.
5991
5992
  --max-batch-size <n> Reporter message batch size. Default: 50.
5992
5993
  --flush-interval-ms <n> Reporter flush window. Default: 200.
5994
+ --max-turns <n> Cap on tool-use turns per attempt; over this,
5995
+ the pi session aborts and the attempt fails
5996
+ with code 'max_turns_exceeded'. 0 = disabled.
5997
+ Default: 0. Recommended ~30 for fulfill_brief.
5998
+ --max-bash-timeouts <n> Cap on bash-tool timeouts per attempt; over
5999
+ this, the pi session aborts and the attempt
6000
+ fails with code 'max_bash_timeouts_exceeded'.
6001
+ 0 = disabled. Default: 3.
5993
6002
  --debug Verbose logging: also log successful list/claim
5994
6003
  outcomes (candidate counts, claim attempts).`;
5995
6004
  var REGISTERED_TASK_TYPES = Object.keys(BUILT_IN_TASK_TYPES).sort();
@@ -13945,6 +13954,7 @@ function renderPhase6Markdown(pack) {
13945
13954
  * These tools run on the host (not in the VM) via the MoltNet SDK,
13946
13955
  * so agent credentials never touch the VM filesystem.
13947
13956
  */
13957
+ var DIARY_TAG_MAX_LENGTH = 128;
13948
13958
  /**
13949
13959
  * Baseline env keys forwarded to host-exec child processes.
13950
13960
  * Callers can extend this set at sandbox startup via `MoltNetToolsConfig.hostExecBaseEnv`.
@@ -13973,6 +13983,19 @@ function ensureConnected(config) {
13973
13983
  teamId: config.getTeamId() ?? ""
13974
13984
  };
13975
13985
  }
13986
+ function hostExecMatchesAutoApproveRule(params, rule) {
13987
+ if (params.executable !== rule.executable) return false;
13988
+ if (rule.argsExcludes?.some((arg) => params.args.includes(arg))) return false;
13989
+ if (rule.argsPrefix && !rule.argsPrefix.every((arg, index) => params.args[index] === arg)) return false;
13990
+ if (rule.argsContains && !rule.argsContains.every((arg) => params.args.includes(arg))) return false;
13991
+ return true;
13992
+ }
13993
+ function shouldAutoApproveHostExec(params, config) {
13994
+ const policy = config.autoApproveHostExec === true ? true : config.hostExecAutoApprove ?? false;
13995
+ if (policy === true) return true;
13996
+ if (!Array.isArray(policy)) return false;
13997
+ return policy.some((rule) => hostExecMatchesAutoApproveRule(params, rule));
13998
+ }
13976
13999
  /**
13977
14000
  * Expand the `taskFilter` shorthand on the diary list/search tools into
13978
14001
  * the matching `task:*` provenance tags emitted by `moltnet_create_entry`
@@ -14191,14 +14214,14 @@ function createMoltNetTools(config) {
14191
14214
  limit: Type.Optional(Type.Number({ description: "Max entries to return (default 10)" })),
14192
14215
  tags: Type.Optional(Type.Array(Type.String({
14193
14216
  minLength: 1,
14194
- maxLength: 50
14217
+ maxLength: DIARY_TAG_MAX_LENGTH
14195
14218
  }), {
14196
14219
  description: "Tags filter — entry must have ALL listed tags (AND). Max 20.",
14197
14220
  maxItems: 20
14198
14221
  })),
14199
14222
  excludeTags: Type.Optional(Type.Array(Type.String({
14200
14223
  minLength: 1,
14201
- maxLength: 50
14224
+ maxLength: DIARY_TAG_MAX_LENGTH
14202
14225
  }), {
14203
14226
  description: "Tags to exclude — entry must have NONE of these. Max 20.",
14204
14227
  maxItems: 20
@@ -14291,14 +14314,14 @@ function createMoltNetTools(config) {
14291
14314
  limit: Type.Optional(Type.Number({ description: "Max results (default 5)" })),
14292
14315
  tags: Type.Optional(Type.Array(Type.String({
14293
14316
  minLength: 1,
14294
- maxLength: 50
14317
+ maxLength: DIARY_TAG_MAX_LENGTH
14295
14318
  }), {
14296
14319
  description: "Entry must have ALL listed tags (AND). Max 20.",
14297
14320
  maxItems: 20
14298
14321
  })),
14299
14322
  excludeTags: Type.Optional(Type.Array(Type.String({
14300
14323
  minLength: 1,
14301
- maxLength: 50
14324
+ maxLength: DIARY_TAG_MAX_LENGTH
14302
14325
  }), {
14303
14326
  description: "Entry must have NONE of these tags. Max 20.",
14304
14327
  maxItems: 20
@@ -14344,7 +14367,7 @@ function createMoltNetTools(config) {
14344
14367
  const createEntry = defineTool({
14345
14368
  name: "moltnet_create_entry",
14346
14369
  label: "Create MoltNet Diary Entry",
14347
- description: "Create a new diary entry to record decisions, findings, incidents, or reflections. During an active task, the entry is forced into the task diary and tagged with the task:* provenance namespace (task:id:<id>, task:type:<type>, task:attempt:<n>, plus task:correlation:<id> when set); an explicit diaryId mismatching the task diary is rejected.",
14370
+ description: "Create a new diary entry to record decisions, findings, incidents, or reflections. During an active task, the entry is forced into the task diary and tagged with the task:* provenance namespace (task:id:<id>, task:type:<type>, task:attempt:<n>, plus task:correlation:<id> when set); an explicit diaryId mismatching the task diary is rejected. Use this tool — NOT `moltnet entry create` / `moltnet entry create-signed` via bash. The CLI path bypasses task-tag auto-injection and leaves entries invisible to taskFilter queries.",
14348
14371
  parameters: Type.Object({
14349
14372
  title: Type.String({ description: "Entry title (concise, descriptive)" }),
14350
14373
  content: Type.String({ description: "Entry content (markdown)" }),
@@ -14484,7 +14507,7 @@ function createMoltNetTools(config) {
14484
14507
  }),
14485
14508
  async execute(_id, params, _signal, _onUpdate, ctx) {
14486
14509
  if (!HOST_EXEC_ALLOWED.has(params.executable)) throw new Error(`host_exec: '${params.executable}' is not in the allowed list (${[...HOST_EXEC_ALLOWED].join(", ")}). Extend HOST_EXEC_ALLOWED only after explicit security review.`);
14487
- if (ctx?.ui) {
14510
+ if (ctx?.ui && !shouldAutoApproveHostExec(params, config)) {
14488
14511
  const cmdDisplay = [params.executable, ...params.args].join(" ");
14489
14512
  if (!await ctx.ui.confirm("Allow host command?", `The agent wants to run on your machine:\n\n ${cmdDisplay}\n\nAllow?`)) throw new Error(`host_exec: user declined approval for: ${cmdDisplay}`);
14490
14513
  }
@@ -14924,38 +14947,48 @@ async function resumeVm(config) {
14924
14947
  [GUEST_TASK_SKILLS_MOUNT]: new MemoryProvider()
14925
14948
  } }
14926
14949
  });
14927
- await vm.exec(`sh -c '
14950
+ try {
14951
+ await vm.exec(`sh -c '
14928
14952
  cp /etc/gondolin/mitm/ca.crt /usr/local/share/ca-certificates/gondolin-mitm.crt
14929
14953
  update-ca-certificates 2>/dev/null
14930
14954
  cat /etc/gondolin/mitm/ca.crt >> /etc/ssl/certs/ca-certificates.crt
14931
14955
  '`);
14932
- await vmRun(vm, "DNS resolvers", `printf 'nameserver 8.8.8.8\\nnameserver 1.1.1.1\\n' > /etc/resolv.conf`);
14933
- await vmRun(vm, "git safe.directory", `git config --system --add safe.directory '*'`);
14934
- for (const [i, cmd] of (config.sandboxConfig?.resumeCommands ?? []).entries()) await vmRun(vm, `resumeCommands[${i}]`, cmd);
14935
- const vmSshDir = `${vmAgentDir}/ssh`;
14936
- await vm.exec(`mkdir -p ${vmAgentDir}/ssh /home/agent/.pi/agent`);
14937
- if (creds.piAuthJson !== null) await vm.fs.writeFile("/home/agent/.pi/agent/auth.json", creds.piAuthJson, { mode: 384 });
14938
- const vmMoltnetJson = rewriteMoltnetJsonPaths(creds.moltnetJson, vmAgentDir, vmSshDir, creds.githubAppPemFilename);
14939
- await vm.fs.writeFile(`${vmAgentDir}/moltnet.json`, vmMoltnetJson, { mode: 384 });
14940
- await vm.fs.writeFile(`${vmAgentDir}/env`, creds.agentEnvRaw, { mode: 384 });
14941
- if (creds.gitconfig) {
14942
- const vmSigningKey = `${vmSshDir}/id_ed25519`;
14943
- let vmGitconfig = creds.gitconfig.replace(/signingKey\s*=\s*.+/g, `signingKey = ${vmSigningKey}`);
14944
- vmGitconfig = ensureRelativeWorktreePaths(vmGitconfig);
14945
- await vm.fs.writeFile(`${vmAgentDir}/gitconfig`, vmGitconfig, { mode: 420 });
14946
- }
14947
- if (creds.sshPrivateKey) await vm.fs.writeFile(`${vmSshDir}/id_ed25519`, creds.sshPrivateKey, { mode: 384 });
14948
- if (creds.sshPublicKey) await vm.fs.writeFile(`${vmSshDir}/id_ed25519.pub`, creds.sshPublicKey, { mode: 420 });
14949
- if (creds.allowedSigners) await vm.fs.writeFile(`${vmSshDir}/allowed_signers`, creds.allowedSigners, { mode: 420 });
14950
- if (creds.githubAppPem && creds.githubAppPemFilename) await vm.fs.writeFile(`${vmAgentDir}/${creds.githubAppPemFilename}`, creds.githubAppPem, { mode: 384 });
14951
- await vm.exec("chown -R agent:agent /home/agent/.pi /home/agent/.moltnet");
14952
- return {
14953
- vm,
14954
- credentials: creds,
14955
- mountPath: config.mountPath,
14956
- guestWorkspace: GUEST_WORKSPACE$1,
14957
- agentDir
14958
- };
14956
+ await vmRun(vm, "DNS resolvers", `printf 'nameserver 8.8.8.8\\nnameserver 1.1.1.1\\n' > /etc/resolv.conf`);
14957
+ await vmRun(vm, "git safe.directory", `git config --system --add safe.directory '*'`);
14958
+ for (const [i, cmd] of (config.sandboxConfig?.resumeCommands ?? []).entries()) await vmRun(vm, `resumeCommands[${i}]`, cmd);
14959
+ const vmSshDir = `${vmAgentDir}/ssh`;
14960
+ await vm.exec(`mkdir -p ${vmAgentDir}/ssh /home/agent/.pi/agent`);
14961
+ if (creds.piAuthJson !== null) await vm.fs.writeFile("/home/agent/.pi/agent/auth.json", creds.piAuthJson, { mode: 384 });
14962
+ const vmMoltnetJson = rewriteMoltnetJsonPaths(creds.moltnetJson, vmAgentDir, vmSshDir, creds.githubAppPemFilename);
14963
+ await vm.fs.writeFile(`${vmAgentDir}/moltnet.json`, vmMoltnetJson, { mode: 384 });
14964
+ await vm.fs.writeFile(`${vmAgentDir}/env`, creds.agentEnvRaw, { mode: 384 });
14965
+ if (creds.gitconfig) {
14966
+ const vmSigningKey = `${vmSshDir}/id_ed25519`;
14967
+ let vmGitconfig = creds.gitconfig.replace(/signingKey\s*=\s*.+/g, `signingKey = ${vmSigningKey}`);
14968
+ vmGitconfig = ensureRelativeWorktreePaths(vmGitconfig);
14969
+ await vm.fs.writeFile(`${vmAgentDir}/gitconfig`, vmGitconfig, { mode: 420 });
14970
+ }
14971
+ if (creds.sshPrivateKey) await vm.fs.writeFile(`${vmSshDir}/id_ed25519`, creds.sshPrivateKey, { mode: 384 });
14972
+ if (creds.sshPublicKey) await vm.fs.writeFile(`${vmSshDir}/id_ed25519.pub`, creds.sshPublicKey, { mode: 420 });
14973
+ if (creds.allowedSigners) await vm.fs.writeFile(`${vmSshDir}/allowed_signers`, creds.allowedSigners, { mode: 420 });
14974
+ if (creds.githubAppPem && creds.githubAppPemFilename) await vm.fs.writeFile(`${vmAgentDir}/${creds.githubAppPemFilename}`, creds.githubAppPem, { mode: 384 });
14975
+ await vm.exec("chown -R agent:agent /home/agent/.pi /home/agent/.moltnet");
14976
+ return {
14977
+ vm,
14978
+ credentials: creds,
14979
+ mountPath: config.mountPath,
14980
+ guestWorkspace: GUEST_WORKSPACE$1,
14981
+ agentDir
14982
+ };
14983
+ } catch (err) {
14984
+ try {
14985
+ await vm.close();
14986
+ } catch (closeErr) {
14987
+ const m = closeErr instanceof Error ? closeErr.message : String(closeErr);
14988
+ process.stderr.write(`[vm] post-throw vm.close() failed: ${m}\n`);
14989
+ }
14990
+ throw err;
14991
+ }
14959
14992
  }
14960
14993
  /**
14961
14994
  * Rewrite host-absolute paths inside moltnet.json to VM-local equivalents.
@@ -15464,8 +15497,8 @@ function buildRuntimeInstructor(ctx) {
15464
15497
  "## Diary discipline",
15465
15498
  "",
15466
15499
  `- During this task, every diary entry MUST land in \`${ctx.diaryId}\``,
15467
- " (the task diary). The MCP `moltnet_create_entry` tool enforces this",
15468
- " and rejects mismatched explicit `diaryId` parameters.",
15500
+ " (the task diary). The `moltnet_create_entry` custom tool enforces",
15501
+ " this and rejects mismatched explicit `diaryId` parameters.",
15469
15502
  `- Provenance tags \`task:id:${ctx.taskId}\`, \`task:type:${ctx.taskType}\`,`,
15470
15503
  ` and \`task:attempt:${ctx.attemptN}\`${ctx.correlationId ? `, plus \`task:correlation:${ctx.correlationId}\`` : ""} are auto-injected on every entry.`,
15471
15504
  " These share the `task:` namespace so `moltnet_diary_tags` with",
@@ -15473,12 +15506,23 @@ function buildRuntimeInstructor(ctx) {
15473
15506
  " `taskFilter` shorthand on `moltnet_list_entries` /",
15474
15507
  " `moltnet_search_entries` expands into them. You may add additional",
15475
15508
  " tags but you cannot remove the auto-injected ones.",
15509
+ "- **DO NOT shell out to `moltnet entry create` / `moltnet entry",
15510
+ " create-signed` / any other `moltnet entry` subcommand via bash.**",
15511
+ " Those CLI paths hit the REST API directly and bypass the",
15512
+ " custom tool's task-tag auto-injection, leaving you with",
15513
+ " untagged entries that `moltnet_list_entries` with a",
15514
+ " `taskFilter: { taskId: ... }` cannot find. The legreffier skill",
15515
+ " recommends `moltnet entry *` for normal interactive sessions —",
15516
+ " inside a running task that advice does not apply. Use the",
15517
+ " `moltnet_create_entry` custom tool only.",
15476
15518
  "",
15477
15519
  "## Accountable commits",
15478
15520
  "",
15479
15521
  "- Every commit you make during this task MUST be paired with a signed",
15480
- " diary entry created via `moltnet_create_entry`. Embed the returned",
15481
- " entry id in the commit trailer `MoltNet-Diary: <id>`.",
15522
+ " diary entry created via the `moltnet_create_entry` custom tool",
15523
+ " (NOT via `moltnet entry create-signed` from bash — see Diary",
15524
+ " discipline above). Embed the returned entry id in the commit",
15525
+ " trailer `MoltNet-Diary: <id>`.",
15482
15526
  "- Commits must be signed with the agent credentials (gitconfig is",
15483
15527
  " pre-configured). Do not bypass signing.",
15484
15528
  "",
@@ -16103,6 +16147,7 @@ async function executePiTask(claimedTask, reporter, opts) {
16103
16147
  clearSessionErrors: () => {},
16104
16148
  getHostCwd: () => mountPath,
16105
16149
  hostExecBaseEnv: new Set([...HOST_EXEC_DEFAULT_BASE_ENV, ...Object.keys(managed.credentials.agentEnv)]),
16150
+ hostExecAutoApprove: opts.hostExecAutoApprove ?? opts.sandboxConfig?.hostExec?.autoApprove ?? false,
16106
16151
  getTaskContext: () => ({
16107
16152
  taskId: task.id,
16108
16153
  taskType: task.taskType,
@@ -16175,6 +16220,11 @@ async function executePiTask(claimedTask, reporter, opts) {
16175
16220
  let assistantText = "";
16176
16221
  let reporterError = null;
16177
16222
  const usage = finalUsage;
16223
+ let capAbort = null;
16224
+ let toolUseTurnCount = 0;
16225
+ let bashTimeoutCount = 0;
16226
+ const maxTurns = opts.maxTurns ?? 0;
16227
+ const maxBashTimeouts = opts.maxBashTimeouts ?? 3;
16178
16228
  cancelListener = wireSessionAbort(reporter.cancelSignal, session);
16179
16229
  const recordingPromise = [];
16180
16230
  const track = (p) => {
@@ -16189,6 +16239,23 @@ async function executePiTask(claimedTask, reporter, opts) {
16189
16239
  }
16190
16240
  }));
16191
16241
  };
16242
+ const liveSession = session;
16243
+ const triggerCapAbort = (code, message) => {
16244
+ if (capAbort) return;
16245
+ capAbort = {
16246
+ code,
16247
+ message
16248
+ };
16249
+ liveSession.abort().catch((err) => {
16250
+ const m = err instanceof Error ? err.message : String(err);
16251
+ process.stderr.write(`[cap] session.abort() failed: ${m}\n`);
16252
+ });
16253
+ track(emit("info", {
16254
+ event: "cap_abort",
16255
+ code,
16256
+ message
16257
+ }));
16258
+ };
16192
16259
  session.subscribe((event) => {
16193
16260
  if (event.type === "message_update") {
16194
16261
  const ae = event.assistantMessageEvent;
@@ -16197,12 +16264,17 @@ async function executePiTask(claimedTask, reporter, opts) {
16197
16264
  track(emit("text_delta", { delta: ae.delta }));
16198
16265
  }
16199
16266
  } else if (event.type === "tool_execution_start") track(emit("tool_call_start", { tool_name: event.toolName }));
16200
- else if (event.type === "tool_execution_end") track(emit("tool_call_end", {
16201
- tool_name: event.toolName,
16202
- is_error: event.isError,
16203
- result: event.isError ? truncateForWire(event.result) : void 0
16204
- }));
16205
- else if (event.type === "turn_end") {
16267
+ else if (event.type === "tool_execution_end") {
16268
+ track(emit("tool_call_end", {
16269
+ tool_name: event.toolName,
16270
+ is_error: event.isError,
16271
+ result: event.isError ? truncateForWire(event.result) : void 0
16272
+ }));
16273
+ if (maxBashTimeouts > 0 && event.toolName === "bash" && event.isError && isBashTimeoutResult(event.result)) {
16274
+ bashTimeoutCount += 1;
16275
+ if (bashTimeoutCount >= maxBashTimeouts) triggerCapAbort("max_bash_timeouts_exceeded", `Aborted after ${bashTimeoutCount} bash timeouts in this attempt (cap ${maxBashTimeouts}).`);
16276
+ }
16277
+ } else if (event.type === "turn_end") {
16206
16278
  const msg = event.message;
16207
16279
  if (msg?.role === "assistant" && msg.usage) {
16208
16280
  usage.inputTokens += Math.max(0, msg.usage.input ?? 0);
@@ -16212,7 +16284,12 @@ async function executePiTask(claimedTask, reporter, opts) {
16212
16284
  if (cr) usage.cacheReadTokens = (usage.cacheReadTokens ?? 0) + cr;
16213
16285
  if (cw) usage.cacheWriteTokens = (usage.cacheWriteTokens ?? 0) + cw;
16214
16286
  }
16215
- track(emit("turn_end", { stop_reason: msg?.stopReason ?? "end_turn" }));
16287
+ const stopReason = msg?.stopReason ?? "end_turn";
16288
+ track(emit("turn_end", { stop_reason: stopReason }));
16289
+ if (maxTurns > 0 && stopReason !== "end_turn" && stopReason !== "aborted" && stopReason !== "error") {
16290
+ toolUseTurnCount += 1;
16291
+ if (toolUseTurnCount >= maxTurns) triggerCapAbort("max_turns_exceeded", `Aborted after ${toolUseTurnCount} tool-use turns (cap ${maxTurns}).`);
16292
+ }
16216
16293
  llmAbort = msg?.stopReason === "error";
16217
16294
  if (msg?.stopReason === "error") llmErrorMessage = typeof msg.errorMessage === "string" && msg.errorMessage.length > 0 ? msg.errorMessage : null;
16218
16295
  else llmErrorMessage = null;
@@ -16241,7 +16318,7 @@ async function executePiTask(claimedTask, reporter, opts) {
16241
16318
  let parsedOutput = null;
16242
16319
  let parsedOutputCid = null;
16243
16320
  let parseError = null;
16244
- if (!runError && !llmAbort && !cancelled) {
16321
+ if (!runError && !llmAbort && !cancelled && !capAbort) {
16245
16322
  const captured = submitToolHandle?.getCaptured() ?? null;
16246
16323
  if (captured) try {
16247
16324
  parsedOutput = captured;
@@ -16294,6 +16371,21 @@ async function executePiTask(claimedTask, reporter, opts) {
16294
16371
  retryable: false
16295
16372
  }
16296
16373
  };
16374
+ const capAbortSnapshot = capAbort;
16375
+ if (capAbortSnapshot) return {
16376
+ taskId: task.id,
16377
+ attemptN,
16378
+ status: "failed",
16379
+ output: null,
16380
+ outputCid: null,
16381
+ usage,
16382
+ durationMs: Date.now() - startTime,
16383
+ error: {
16384
+ code: capAbortSnapshot.code,
16385
+ message: capAbortSnapshot.message,
16386
+ retryable: false
16387
+ }
16388
+ };
16297
16389
  const status = runError || llmAbort || parseError || reporterError ? "failed" : "completed";
16298
16390
  const errorCode = runError?.code ?? parseError?.code ?? reporterError?.code ?? (llmAbort ? "llm_api_error" : void 0);
16299
16391
  const errorMessage = runError?.message ?? parseError?.message ?? reporterError?.message ?? (llmAbort ? llmErrorMessage ?? "LLM API error during turn" : void 0);
@@ -16395,6 +16487,25 @@ function summarizePayloadForLog(kind, payload) {
16395
16487
  default: return payload;
16396
16488
  }
16397
16489
  }
16490
+ /**
16491
+ * Detect pi's bash-timeout error wrapper in a `tool_execution_end`
16492
+ * result. The bash tool surfaces a timeout as a structured tool result
16493
+ * `{ content: [{ type: 'text', text: '… Command timed out after N
16494
+ * seconds' }] }` (see `@earendil-works/pi-coding-agent`'s bash.js).
16495
+ * Substring-match against the stable wrapper string is the only
16496
+ * mechanism short of patching pi; the string is part of pi's external
16497
+ * tool-error API and changing it would break agents that read tool
16498
+ * errors.
16499
+ */
16500
+ function isBashTimeoutResult(result) {
16501
+ if (result === null || result === void 0) return false;
16502
+ if (typeof result === "string") return result.includes("Command timed out after");
16503
+ if (typeof result !== "object") return false;
16504
+ const content = result.content;
16505
+ if (!Array.isArray(content)) return false;
16506
+ for (const part of content) if (typeof part === "object" && part !== null && typeof part.text === "string" && part.text.includes("Command timed out after")) return true;
16507
+ return false;
16508
+ }
16398
16509
  var TRUNCATE_LIMIT = 4 * 1024;
16399
16510
  function truncateForWire(value) {
16400
16511
  if (value === null || value === void 0) return value;
@@ -16533,6 +16644,7 @@ async function finalizeTask(agent, output, ctx = {}) {
16533
16644
  message: "Task execution failed before producing a valid output.",
16534
16645
  retryable: false
16535
16646
  };
16647
+ if ((await agent.tasks.heartbeat(output.taskId, output.attemptN, {})).cancelled) return;
16536
16648
  await agent.tasks.fail(output.taskId, output.attemptN, { error });
16537
16649
  }
16538
16650
  async function maybeWriteAnchors(output, ctx) {
@@ -16552,12 +16664,53 @@ async function maybeWriteAnchors(output, ctx) {
16552
16664
  }
16553
16665
  }
16554
16666
  //#endregion
16667
+ //#region src/lib/logger.ts
16668
+ /**
16669
+ * Logger setup + teardown for the agent-daemon CLI commands.
16670
+ *
16671
+ * Why this helper exists: pino with `pino-pretty` transport spawns a
16672
+ * Node Worker thread for human-readable output. The Worker holds the
16673
+ * parent's TTYWrap handles ref'd; without an explicit `.end()` the
16674
+ * daemon never exits after the task completes — symptom: `once`
16675
+ * prints `[done] TaskOutput: …` then hangs until Ctrl-C
16676
+ * (see issue #1107). This module owns both creation and shutdown
16677
+ * so every CLI command gets the same cleanup contract without
16678
+ * duplicating the dance.
16679
+ *
16680
+ * Non-TTY mode (CI, file redirect) skips the transport entirely:
16681
+ * pino writes raw NDJSON directly to stderr, no worker thread,
16682
+ * nothing extra to clean up.
16683
+ */
16684
+ function createRootLogger(options) {
16685
+ const prettyTransport = process.stderr.isTTY ? transport({
16686
+ target: "pino-pretty",
16687
+ options: { colorize: true }
16688
+ }) : null;
16689
+ const logger = pino(options, prettyTransport ?? void 0);
16690
+ const shutdown = async () => {
16691
+ if (!prettyTransport) return;
16692
+ logger.flush();
16693
+ try {
16694
+ prettyTransport.end();
16695
+ await once(prettyTransport, "close");
16696
+ } catch (err) {
16697
+ process.stderr.write(`[pino] transport teardown failed: ` + (err instanceof Error ? err.message : String(err)) + "\n");
16698
+ }
16699
+ };
16700
+ return {
16701
+ logger,
16702
+ shutdown
16703
+ };
16704
+ }
16705
+ //#endregion
16555
16706
  //#region src/lib/options.ts
16556
16707
  var DEFAULTS = {
16557
16708
  leaseTtlSec: 300,
16558
16709
  heartbeatIntervalMs: 6e4,
16559
16710
  maxBatchSize: 50,
16560
- flushIntervalMs: 200
16711
+ flushIntervalMs: 200,
16712
+ maxTurns: 0,
16713
+ maxBashTimeouts: 3
16561
16714
  };
16562
16715
  var MissingRequiredOptionError = class extends Error {
16563
16716
  constructor(flag) {
@@ -16579,6 +16732,8 @@ function parseCommonOptions(args) {
16579
16732
  heartbeatIntervalMs: parseNonNegativeInt(args["heartbeat-interval-ms"], "heartbeat-interval-ms", DEFAULTS.heartbeatIntervalMs),
16580
16733
  maxBatchSize: parsePositiveInt(args["max-batch-size"], "max-batch-size", DEFAULTS.maxBatchSize),
16581
16734
  flushIntervalMs: parseNonNegativeInt(args["flush-interval-ms"], "flush-interval-ms", DEFAULTS.flushIntervalMs),
16735
+ maxTurns: parseNonNegativeInt(args["max-turns"], "max-turns", DEFAULTS.maxTurns),
16736
+ maxBashTimeouts: parseNonNegativeInt(args["max-bash-timeouts"], "max-bash-timeouts", DEFAULTS.maxBashTimeouts),
16582
16737
  debug: args.debug === true
16583
16738
  };
16584
16739
  }
@@ -16612,6 +16767,8 @@ function commonOptionDefs() {
16612
16767
  "heartbeat-interval-ms": { type: "string" },
16613
16768
  "max-batch-size": { type: "string" },
16614
16769
  "flush-interval-ms": { type: "string" },
16770
+ "max-turns": { type: "string" },
16771
+ "max-bash-timeouts": { type: "string" },
16615
16772
  debug: { type: "boolean" }
16616
16773
  };
16617
16774
  }
@@ -16778,14 +16935,11 @@ async function runPolling(opts) {
16778
16935
  "moltnet.llm.model": common.model
16779
16936
  }
16780
16937
  });
16781
- const rootLogger = pino({
16938
+ const { logger, shutdown: shutdownLogger } = createRootLogger({
16782
16939
  name: `agent-daemon.${opts.modeLabel}`,
16783
- level: cfg.logLevel || (common.debug ? "debug" : "info"),
16784
- ...process.stderr.isTTY ? { transport: {
16785
- target: "pino-pretty",
16786
- options: { colorize: true }
16787
- } } : {}
16788
- }).child({
16940
+ level: cfg.logLevel || (common.debug ? "debug" : "info")
16941
+ });
16942
+ const rootLogger = logger.child({
16789
16943
  mode: opts.modeLabel,
16790
16944
  agent: common.agent,
16791
16945
  teamId,
@@ -16818,7 +16972,9 @@ async function runPolling(opts) {
16818
16972
  provider: common.provider,
16819
16973
  model: common.model,
16820
16974
  sandboxConfig: sandbox.config,
16821
- makeOnTurnEvent: makeTurnEventHandlerFactory(rootLogger)
16975
+ makeOnTurnEvent: makeTurnEventHandlerFactory(rootLogger),
16976
+ maxTurns: common.maxTurns,
16977
+ maxBashTimeouts: common.maxBashTimeouts
16822
16978
  });
16823
16979
  runtime = new AgentRuntime({
16824
16980
  logger: rootLogger,
@@ -16897,6 +17053,7 @@ async function runPolling(opts) {
16897
17053
  return drained.some((o) => o.status !== "completed") ? 1 : 0;
16898
17054
  } finally {
16899
17055
  await otelShutdown();
17056
+ await shutdownLogger();
16900
17057
  }
16901
17058
  }
16902
17059
  function parseCsv(raw) {
@@ -16968,14 +17125,11 @@ async function runOnce(argv) {
16968
17125
  "moltnet.llm.model": opts.model
16969
17126
  }
16970
17127
  });
16971
- const rootLogger = pino({
17128
+ const { logger, shutdown: shutdownLogger } = createRootLogger({
16972
17129
  name: "agent-daemon.once",
16973
- level: cfg.logLevel || (opts.debug ? "debug" : "info"),
16974
- ...process.stderr.isTTY ? { transport: {
16975
- target: "pino-pretty",
16976
- options: { colorize: true }
16977
- } } : {}
16978
- }).child({
17130
+ level: cfg.logLevel || (opts.debug ? "debug" : "info")
17131
+ });
17132
+ const rootLogger = logger.child({
16979
17133
  mode: "once",
16980
17134
  agent: opts.agent,
16981
17135
  provider: opts.provider,
@@ -17012,7 +17166,9 @@ async function runOnce(argv) {
17012
17166
  provider: opts.provider,
17013
17167
  model: opts.model,
17014
17168
  sandboxConfig: sandbox.config,
17015
- onTurnEvent: makeTurnEventHandler(rootLogger, { taskId })
17169
+ onTurnEvent: makeTurnEventHandler(rootLogger, { taskId }),
17170
+ maxTurns: opts.maxTurns,
17171
+ maxBashTimeouts: opts.maxBashTimeouts
17016
17172
  });
17017
17173
  const writeCorrelationAnchors = makePrBodyAnchorWriter({
17018
17174
  gh: createGhCliClient(),
@@ -17049,6 +17205,7 @@ async function runOnce(argv) {
17049
17205
  return output.status === "completed" ? 0 : 1;
17050
17206
  } finally {
17051
17207
  await otelShutdown();
17208
+ await shutdownLogger();
17052
17209
  }
17053
17210
  }
17054
17211
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@themoltnet/agent-daemon",
3
- "version": "0.5.3",
3
+ "version": "0.6.1",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "description": "MoltNet agent daemon — claims and executes tasks (fulfill_brief, assess_brief) from the MoltNet task-service via Pi-headless. CLI: moltnet-agent.",
@@ -33,9 +33,9 @@
33
33
  "@opentelemetry/semantic-conventions": "^1.39.0",
34
34
  "pino": "^10.3.1",
35
35
  "pino-pretty": "^13.1.3",
36
- "@themoltnet/agent-runtime": "0.15.0",
37
36
  "@themoltnet/sdk": "0.102.0",
38
- "@themoltnet/pi-extension": "0.15.2"
37
+ "@themoltnet/pi-extension": "0.16.1",
38
+ "@themoltnet/agent-runtime": "0.15.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "tsx": "^4.7.0",
@@ -43,9 +43,9 @@
43
43
  "vite": "^8.0.0",
44
44
  "vitest": "^3.0.0",
45
45
  "@moltnet/crypto-service": "0.1.0",
46
+ "@moltnet/tasks": "0.1.0",
46
47
  "@moltnet/database": "0.1.0",
47
- "@moltnet/bootstrap": "0.1.0",
48
- "@moltnet/tasks": "0.1.0"
48
+ "@moltnet/bootstrap": "0.1.0"
49
49
  },
50
50
  "nx": {
51
51
  "tags": [