@sema-agent/core 5.13.0 → 5.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +296 -0
  2. package/dist/agents/send-message-tool.js +1 -0
  3. package/dist/agents/subagent.d.ts +4 -0
  4. package/dist/agents/subagent.js +133 -41
  5. package/dist/brain/anthropic.js +33 -10
  6. package/dist/brain/context-overflow.d.ts +20 -0
  7. package/dist/brain/context-overflow.js +58 -0
  8. package/dist/brain/open-responses.js +24 -10
  9. package/dist/brain/openai.js +29 -11
  10. package/dist/brain/request-params.d.ts +2 -0
  11. package/dist/brain/request-params.js +16 -0
  12. package/dist/brain/stream-engine.d.ts +9 -1
  13. package/dist/brain/stream-engine.js +256 -27
  14. package/dist/brain/timeout.d.ts +1 -0
  15. package/dist/brain/timeout.js +1 -0
  16. package/dist/core/a2a.d.ts +2 -2
  17. package/dist/core/a2a.js +3 -3
  18. package/dist/core/ask-question.d.ts +47 -2
  19. package/dist/core/ask-question.js +209 -28
  20. package/dist/core/background-agent-store.d.ts +2 -0
  21. package/dist/core/checkpoint-store.d.ts +41 -17
  22. package/dist/core/checkpoint-store.js +114 -3
  23. package/dist/core/hooks.d.ts +24 -2
  24. package/dist/core/hooks.js +97 -10
  25. package/dist/core/human-input-projection.d.ts +12 -0
  26. package/dist/core/human-input-projection.js +27 -0
  27. package/dist/core/mcp.d.ts +7 -2
  28. package/dist/core/mcp.js +7 -7
  29. package/dist/core/memory-admission.d.ts +4 -0
  30. package/dist/core/memory-admission.js +3 -0
  31. package/dist/core/runner/assemble-result.d.ts +1 -0
  32. package/dist/core/runner/assemble-result.js +1 -1
  33. package/dist/core/runner/prepare-task.d.ts +16 -6
  34. package/dist/core/runner/prepare-task.js +301 -24
  35. package/dist/core/runner/runtask.d.ts +3 -6
  36. package/dist/core/runner/runtask.js +186 -36
  37. package/dist/core/runner/tool-output-projection.js +1 -0
  38. package/dist/core/session-store.d.ts +3 -0
  39. package/dist/core/session-store.js +4 -0
  40. package/dist/core/session.d.ts +1 -0
  41. package/dist/core/store-contracts/background-agent-store-contract.js +19 -0
  42. package/dist/core/store-contracts/checkpoint-store-contract.js +62 -3
  43. package/dist/core/task-notification.d.ts +2 -0
  44. package/dist/core/task-notification.js +5 -3
  45. package/dist/core/task-registry-agent.d.ts +1 -0
  46. package/dist/core/task-registry-agent.js +6 -0
  47. package/dist/core/task-registry.d.ts +1 -0
  48. package/dist/core/task-registry.js +4 -1
  49. package/dist/core/tool-policy.d.ts +5 -0
  50. package/dist/core/tool-policy.js +2 -1
  51. package/dist/core/types.d.ts +32 -1
  52. package/dist/core/wiring-manifest.d.ts +97 -0
  53. package/dist/core/wiring-manifest.js +186 -0
  54. package/dist/engine/compaction/compaction.js +2 -2
  55. package/dist/engine/harness/agent-harness.d.ts +2 -1
  56. package/dist/engine/harness/agent-harness.js +8 -1
  57. package/dist/engine/harness/types.d.ts +3 -1
  58. package/dist/engine/llm/types.d.ts +7 -0
  59. package/dist/engine/llm/types.js +8 -1
  60. package/dist/engine/session/import-validate.d.ts +6 -1
  61. package/dist/engine/session/import-validate.js +29 -6
  62. package/dist/engine/session/memory-repo.d.ts +3 -1
  63. package/dist/engine/session/memory-repo.js +2 -2
  64. package/dist/index.d.ts +7 -4
  65. package/dist/index.js +7 -4
  66. package/dist/internal/harness-types.d.ts +1 -1
  67. package/dist/internal/llm.d.ts +2 -2
  68. package/dist/internal/llm.js +1 -1
  69. package/dist/orchestration/run-workflow-tool.d.ts +4 -0
  70. package/dist/orchestration/run-workflow-tool.js +3 -0
  71. package/dist/orchestration/workflow-types.d.ts +8 -0
  72. package/dist/orchestration/workflow-types.js +14 -0
  73. package/dist/orchestration/workflow.d.ts +4 -0
  74. package/dist/orchestration/workflow.js +134 -5
  75. package/dist/prompts/default.js +1 -1
  76. package/dist/stores/file/checkpoint-store.d.ts +3 -5
  77. package/dist/stores/file/checkpoint-store.js +31 -2
  78. package/dist/stores/file/index.js +1 -1
  79. package/dist/stores/file/session-store.d.ts +3 -1
  80. package/dist/stores/file/session-store.js +2 -2
  81. package/dist/stores/file/shared-ledger.js +8 -1
  82. package/dist/tools/fs/bash-readonly-classifier.d.ts +3 -0
  83. package/dist/tools/fs/bash-readonly-classifier.js +94 -0
  84. package/dist/tools/fs/fs-bash.js +31 -12
  85. package/dist/tools/fs/safety.js +34 -10
  86. package/package.json +1 -1
@@ -45,7 +45,7 @@ export class FileStorageBackend {
45
45
  const corruptRead = opts.onCorruptRead !== undefined ? { onCorruptRead: opts.onCorruptRead } : undefined;
46
46
  const repo = new FileSessionRepo(this.root, corruptRead);
47
47
  this.fileSessions = repo;
48
- this.ttl = new TtlSessionStore({ repo, evict: opts.evict ?? "forget" });
48
+ this.ttl = new TtlSessionStore({ repo, evict: opts.evict ?? "forget", durability: "durable" });
49
49
  this.sessionStore = this.ttl;
50
50
  this.fileCheckpoints = new FileCheckpointStore(this.root, opts.checkpoint);
51
51
  this.checkpointStore = this.fileCheckpoints;
@@ -24,5 +24,7 @@ export declare class FileSessionRepo implements SessionRepo {
24
24
  delete(metadata: SessionMetadata): Promise<void>;
25
25
  fork(sourceMetadata: SessionMetadata, options?: SessionForkOptions): Promise<Session>;
26
26
  exportEntries(sessionId: string): Promise<SessionTreeEntry[]>;
27
- importEntries(sessionId: string, owner: string | undefined, entries: SessionTreeEntry[]): Promise<void>;
27
+ importEntries(sessionId: string, owner: string | undefined, entries: SessionTreeEntry[], options?: {
28
+ preserveActorAssertions?: boolean;
29
+ }): Promise<void>;
28
30
  }
@@ -191,9 +191,9 @@ export class FileSessionRepo {
191
191
  async exportEntries(sessionId) {
192
192
  return this.read(sessionId).entries;
193
193
  }
194
- async importEntries(sessionId, owner, entries) {
194
+ async importEntries(sessionId, owner, entries, options) {
195
195
  void owner;
196
- const validated = validateEntriesForImport(entries);
196
+ const validated = validateEntriesForImport(entries, options);
197
197
  evictSharedSessionStorage(canonicalStoreKey(this.pathFor(sessionId)));
198
198
  const createdAt = new Date().toISOString();
199
199
  const lines = [JSON.stringify({ kind: "meta", id: sessionId, createdAt })];
@@ -88,7 +88,14 @@ export class SharedLedgerTable {
88
88
  }
89
89
  const core = new LedgerCore(key, paths, this.model, this.live);
90
90
  this.live.set(key, core);
91
- core.bootstrap();
91
+ try {
92
+ core.bootstrap();
93
+ }
94
+ catch (e) {
95
+ if (this.live.get(key) === core)
96
+ this.live.delete(key);
97
+ throw e;
98
+ }
92
99
  return core;
93
100
  }
94
101
  }
@@ -21,3 +21,6 @@ export declare function formatOutOfRootReadApprovalOption(directory: string): st
21
21
  export declare function classifyCompoundReadonlyDetailed(command: string, allow: ReadonlySet<string>, boundary?: BashReadonlyRootBoundary): CompoundReadonlyVerdict;
22
22
  export declare function classifySimpleCommandReadBoundary(command: string, boundary: BashReadonlyRootBoundary): CompoundReadonlyVerdict;
23
23
  export declare function classifyCompoundReadonly(command: string, allow: ReadonlySet<string>, boundary?: BashReadonlyRootBoundary): string | undefined;
24
+ export declare const POLL_LOOP_MAX_BEATS = 120;
25
+ export declare const POLL_LOOP_MAX_SLEEP_SECONDS = 600;
26
+ export declare function classifyBoundedReadonlyPollLoop(command: string, allow: ReadonlySet<string>, boundary?: BashReadonlyRootBoundary): string | undefined;
@@ -462,3 +462,97 @@ export function classifySimpleCommandReadBoundary(command, boundary) {
462
462
  export function classifyCompoundReadonly(command, allow, boundary) {
463
463
  return classifyCompoundReadonlyDetailed(command, allow, boundary).reason;
464
464
  }
465
+ export const POLL_LOOP_MAX_BEATS = 120;
466
+ export const POLL_LOOP_MAX_SLEEP_SECONDS = 600;
467
+ const POLL_BODY_HARD_REJECT = /[<>$()`|&{}\n\r\\]/;
468
+ const NON_ASCII_WHITESPACE = /[^\S \t]/;
469
+ const POLL_LOOP_SHAPE = /^for[ \t]+([A-Za-z_][A-Za-z0-9_]*)[ \t]+in[ \t]+(.+?)[ \t]*;[ \t]*do[ \t]+(.+?)[ \t]*;[ \t]*done[ \t]*;?$/;
470
+ function pollLoopQuotesBalanced(s) {
471
+ let open;
472
+ for (const ch of s) {
473
+ if (open === undefined && (ch === '"' || ch === "'"))
474
+ open = ch;
475
+ else if (open === ch)
476
+ open = undefined;
477
+ }
478
+ return open === undefined;
479
+ }
480
+ function pollLoopBeatsOf(iterable) {
481
+ const seq = /^\$\(seq[ \t]+(\d{1,7})[ \t]+(\d{1,7})\)$/.exec(iterable);
482
+ const brace = seq === null ? /^\{(\d{1,7})\.\.(\d{1,7})\}$/.exec(iterable) : null;
483
+ const range = seq ?? brace;
484
+ if (range !== null) {
485
+ const lo = Number(range[1]);
486
+ const hi = Number(range[2]);
487
+ return hi >= lo ? hi - lo + 1 : undefined;
488
+ }
489
+ const items = iterable.split(/[ \t]+/);
490
+ return items.every((t) => /^[A-Za-z0-9_.-]{1,64}$/.test(t)) ? items.length : undefined;
491
+ }
492
+ function pollLoopSleepReason(segment) {
493
+ const toks = segment.split(/[ \t]+/);
494
+ if (toks.length !== 2)
495
+ return "`sleep` in a poll loop must take exactly one literal numeric argument";
496
+ const v = toks[1];
497
+ if (!/^\d+(\.\d+)?$/.test(v) || v.length > 8 || Number(v) > POLL_LOOP_MAX_SLEEP_SECONDS) {
498
+ return `\`sleep ${v}\` is not a literal duration within the ${POLL_LOOP_MAX_SLEEP_SECONDS}s per-beat cap — not auto-allowed`;
499
+ }
500
+ return undefined;
501
+ }
502
+ export function classifyBoundedReadonlyPollLoop(command, allow, boundary) {
503
+ if (NON_ASCII_WHITESPACE.test(command)) {
504
+ return "the command contains a non-ASCII or control whitespace character (only space and tab are allowed) — its word boundaries cannot be read the way bash would split them, so it is not auto-allowed";
505
+ }
506
+ const trimmed = command.trim();
507
+ if (!trimmed)
508
+ return "empty command";
509
+ if (!pollLoopQuotesBalanced(trimmed)) {
510
+ return "unbalanced quote — the command's structure cannot be read reliably, so it is not auto-allowed";
511
+ }
512
+ const m = POLL_LOOP_SHAPE.exec(trimmed);
513
+ if (m === null) {
514
+ return "not a bounded read-only poll loop (`for <v> in <literal bound>; do <read commands>; done` is the only accepted control structure)";
515
+ }
516
+ const varName = m[1];
517
+ const iterable = m[2];
518
+ const body = m[3];
519
+ if (!/^[a-z]$/.test(varName)) {
520
+ return `the loop variable "${varName}" must be a single lowercase letter — a name like PATH/IFS/LD_PRELOAD would change how the body's commands resolve`;
521
+ }
522
+ const beats = pollLoopBeatsOf(iterable);
523
+ if (beats === undefined) {
524
+ return `the loop bound "${iterable}" is not a recognized literal — only \`$(seq <int> <int>)\`, \`{<int>..<int>}\` (ascending) or a literal word list is accepted`;
525
+ }
526
+ if (beats > POLL_LOOP_MAX_BEATS) {
527
+ return `the loop runs ${beats} iterations, above the ${POLL_LOOP_MAX_BEATS}-iteration cap for auto-allow`;
528
+ }
529
+ if (POLL_BODY_HARD_REJECT.test(body)) {
530
+ return "the loop body may not contain redirection, pipes, backgrounding, substitution, subshells, braces, escapes, or line breaks";
531
+ }
532
+ const readSegments = [];
533
+ for (const rawSegment of body.split(";")) {
534
+ const segment = rawSegment.trim();
535
+ if (segment.length === 0)
536
+ return "empty command in the loop body";
537
+ const parsed = parseLeadingCommandName(segment);
538
+ if ("reject" in parsed)
539
+ return parsed.reject;
540
+ if (parsed.name === "sleep") {
541
+ const sleepReason = pollLoopSleepReason(segment);
542
+ if (sleepReason !== undefined)
543
+ return sleepReason;
544
+ continue;
545
+ }
546
+ readSegments.push(segment);
547
+ }
548
+ if (readSegments.length === 0) {
549
+ return "the loop body has no read command — a sleep-only loop observes nothing and is not auto-allowed";
550
+ }
551
+ const verdict = classifyCompoundReadonlyDetailed(readSegments.join("; "), allow, boundary);
552
+ if (verdict.reason !== undefined)
553
+ return verdict.reason;
554
+ if (verdict.undecidedPaths !== undefined) {
555
+ return `the loop body carries an unexpanded glob (${verdict.undecidedPaths.join(", ")}) — what a REPEATED read touches is decided at run time, so it is not auto-allowed`;
556
+ }
557
+ return undefined;
558
+ }
@@ -7,10 +7,10 @@ import { hasBackgroundShell } from "../../core/background-shell.js";
7
7
  import { delimitUntrusted } from "../../core/untrusted-text.js";
8
8
  import { MCP_IMAGE_MAX_BASE64 } from "../../core/mcp.js";
9
9
  import { imageMagicMatches, withinAnyRoot } from "./safety.js";
10
- import { isRemoteExecutionEnv } from "../../core/remote-env.js";
10
+ import { isRemoteExecutionEnv, hasDestroy, isIsolated } from "../../core/remote-env.js";
11
11
  import { ghRateLimitHint } from "./gh-rate-limit.js";
12
12
  import { resolveBashTimeoutCaps, bashTimeoutCapsSec, bashMaxOutputChars, clipShellOutput, writeShellOverflowFile, createShellOverflowSpoolFence, shellRecoveryHint, CWD_SENTINEL, } from "./fs-shared.js";
13
- import { BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyCompoundReadonly, classifySimpleCommandReadBoundary, } from "./bash-readonly-classifier.js";
13
+ import { BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyBoundedReadonlyPollLoop, classifyCompoundReadonly, classifySimpleCommandReadBoundary, } from "./bash-readonly-classifier.js";
14
14
  export function bashReversibilityProbe(allow, boundary) {
15
15
  const allowSet = new Set(allow ?? BASH_READONLY_DEFAULT_ALLOW);
16
16
  return (args) => {
@@ -18,7 +18,9 @@ export function bashReversibilityProbe(allow, boundary) {
18
18
  if (typeof command !== "string")
19
19
  return { reversible: false };
20
20
  const resolved = typeof boundary === "function" ? boundary() : boundary;
21
- return { reversible: classifyCompoundReadonly(command, allowSet, resolved) === undefined };
21
+ if (classifyCompoundReadonly(command, allowSet, resolved) === undefined)
22
+ return { reversible: true };
23
+ return { reversible: classifyBoundedReadonlyPollLoop(command, allowSet, resolved) === undefined };
22
24
  };
23
25
  }
24
26
  const EXIT1_INTERPRETATION = {
@@ -399,7 +401,22 @@ async function runShell(env, cwd, toolName, command, timeoutSec, caps, signal, c
399
401
  },
400
402
  };
401
403
  }
402
- function bashDescription(coAuthor, caps, bgNotifies = false, bgRetained = false) {
404
+ function bgReapClause(sessionScoped, envIsolatedOwned) {
405
+ if (!sessionScoped)
406
+ return "when this task ends";
407
+ return envIsolatedOwned
408
+ ? "at session end, not when this task ends — UNLESS this task's own isolated sandbox is torn down, which reaps it right then instead"
409
+ : "at session end, not when this task ends: it keeps running across tasks in the same session, and a later task can still read its output or stop it";
410
+ }
411
+ function bgNohupCaveat(envIsolatedOwned) {
412
+ return envIsolatedOwned
413
+ ? " — on a per-task isolated sandbox this only helps if the sandbox itself survives; if the deployment tears it down at task end, host the deliverable outside the sandbox instead"
414
+ : "";
415
+ }
416
+ function bgRetainedIsolatedNote() {
417
+ return " This environment declares retention for background processes — normally one keeps running past this task and the session. The one exception: a per-task isolated sandbox that gets torn down still reaps it right then despite the retention declaration; if the deliverable must survive that too, host it outside the sandbox instead.";
418
+ }
419
+ function bashDescription(coAuthor, caps, bgNotifies = false, bgRetained = false, bgSessionScoped = false, bgEnvIsolatedOwned = false) {
403
420
  const coAuthorLines = coAuthor === false
404
421
  ? "- Follow the deployment's commit-message conventions."
405
422
  : `- End git commit messages with:\nCo-Authored-By: ${coAuthor}`;
@@ -409,7 +426,7 @@ function bashDescription(coAuthor, caps, bgNotifies = false, bgRetained = false)
409
426
  - IMPORTANT: Avoid using this tool to run \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user.
410
427
  - File search by name: use the Glob tool (NOT \`find\` or \`ls\`); content search: use the Grep tool (NOT \`grep\`/\`rg\` in the shell).
411
428
  - \`timeout\` is in milliseconds: default ${caps.defaultMs}, max ${caps.maxMs}.
412
- - \`run_in_background\` runs the command detached: it keeps running across turns and ${bgNotifies ? "re-invokes you when it exits" : "you read its output later with TaskOutput(task_id)"}. No \`&\` needed.${bgRetained ? "" : " Background processes do NOT survive the session — they are reaped when the task ends. A deliverable that must stay alive afterwards (a server, a daemon) needs a self-detaching FOREGROUND start instead: run `nohup cmd >log 2>&1 &` as a normal foreground command (portable; setsid does not exist on macOS), or use a service manager."}
429
+ - \`run_in_background\` runs the command detached: it keeps running across turns and ${bgNotifies ? "re-invokes you when it exits" : "you read its output later with TaskOutput(task_id)"}. No \`&\` needed.${bgRetained ? (bgEnvIsolatedOwned ? bgRetainedIsolatedNote() : "") : ` Background processes do NOT survive the session — they are reaped ${bgReapClause(bgSessionScoped, bgEnvIsolatedOwned)}. A deliverable that must stay alive afterwards (a server, a daemon) needs a self-detaching FOREGROUND start instead: run \`nohup cmd >log 2>&1 &\` as a normal foreground command (portable; setsid does not exist on macOS), or use a service manager${bgNohupCaveat(bgEnvIsolatedOwned)}.`}
413
430
 
414
431
  # Git
415
432
  - Interactive flags (\`-i\`, e.g. \`git rebase -i\`, \`git add -i\`) are not supported in this environment.
@@ -463,20 +480,22 @@ async function adoptBackgroundShell(req) {
463
480
  return { ok: false, reason, killed: false, killMsg: kill !== undefined && !kill.ok ? kill.error.message : "killBackground threw" };
464
481
  }
465
482
  }
466
- function backgroundLifetimeNote(caps) {
467
- return caps.retainBackgroundProcesses === true
468
- ? ""
469
- : ` NOTE: background processes do NOT survive the session (reaped at task end). If this is a deliverable service that must stay alive afterwards, host it with a FOREGROUND command instead: \`nohup cmd >log 2>&1 &\` (self-detaching — survives the session).`;
483
+ function backgroundLifetimeNote(caps, sessionScoped, envIsolatedOwned) {
484
+ if (caps.retainBackgroundProcesses === true)
485
+ return envIsolatedOwned ? bgRetainedIsolatedNote() : "";
486
+ return ` NOTE: background processes do NOT survive the session (reaped ${bgReapClause(sessionScoped, envIsolatedOwned)}). If this is a deliverable service that must stay alive afterwards, host it with a FOREGROUND command instead: \`nohup cmd >log 2>&1 &\` (self-detaching — survives the session)${bgNohupCaveat(envIsolatedOwned)}.`;
470
487
  }
471
488
  export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = { current: rootCanonical }, taskOpts = {}) {
472
489
  const timeoutCaps = resolveBashTimeoutCaps(taskOpts);
473
490
  const timeoutCapsSecView = bashTimeoutCapsSec(timeoutCaps);
474
491
  const bgNotifies = taskOpts.taskRegistry !== undefined && taskOpts.onTaskNotification !== undefined;
475
492
  const bgRetained = hasBackgroundShell(env) && env.backgroundCapabilities.retainBackgroundProcesses === true;
493
+ const bgSessionScoped = taskOpts.sessionId !== undefined && taskOpts.taskRegistry !== undefined;
494
+ const bgEnvIsolatedOwned = hasDestroy(env) && isIsolated(env);
476
495
  return defineTool({
477
496
  name: "Bash",
478
497
  contract: { contractId: "core.bash@1", implementationRevision: "1" },
479
- description: bashDescription(coAuthor, timeoutCaps, bgNotifies, bgRetained),
498
+ description: bashDescription(coAuthor, timeoutCaps, bgNotifies, bgRetained, bgSessionScoped, bgEnvIsolatedOwned),
480
499
  parameters: Type.Object({
481
500
  command: Type.String({ description: "The command to execute" }),
482
501
  timeout: Type.Optional(Type.Number({ description: `Optional timeout in milliseconds (max ${timeoutCaps.maxMs}; requests above the max are capped to it)` })),
@@ -535,7 +554,7 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
535
554
  ? ` Time budget: requested ${requestedTimeoutSec}s, capped at ${bgCaps.maxBgTimeoutSec}s (env ceiling: requests above ${bgCaps.maxBgTimeoutSec}s are reduced to it) — auto-terminates if still running after ${appliedTimeoutSec}s.`
536
555
  : ` Time budget: auto-terminates if still running after ${appliedTimeoutSec}s (hard cap ${bgCaps.maxBgTimeoutSec}s).`
537
556
  : "";
538
- const lifetimeNote = backgroundLifetimeNote(bgCaps);
557
+ const lifetimeNote = backgroundLifetimeNote(bgCaps, bgSessionScoped, bgEnvIsolatedOwned);
539
558
  const registry = taskOpts.taskRegistry;
540
559
  if (registry !== undefined) {
541
560
  const onNotify = taskOpts.onTaskNotification;
@@ -649,7 +668,7 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
649
668
  ? `You will be notified when it completes — do not poll.`
650
669
  : `Poll TaskOutput until its status is no longer "running".`) +
651
670
  adoptBudgetNote +
652
- backgroundLifetimeNote(adoptCaps) +
671
+ backgroundLifetimeNote(adoptCaps, bgSessionScoped, bgEnvIsolatedOwned) +
653
672
  (tail ? `\n--- output so far (tail) ---\n${tail}` : ""),
654
673
  details: { type: "bash", detached: true, task_id: taskId, ...(description !== undefined ? { description } : {}), ...(outputFile !== undefined ? { output_file: outputFile } : {}), ...(cause === "timeout" ? { autoBackgrounded: true } : {}) },
655
674
  };
@@ -309,15 +309,29 @@ export async function canonicalizeTarget(env, path, signal, baseCwd) {
309
309
  let abs = absR.value;
310
310
  for (let hop = 0; hop < 16; hop++) {
311
311
  const exists = await env.exists(abs, signal);
312
- if (!(exists.ok && exists.value)) {
313
- return { ok: true, key: await canonicalizeNewPath(env, abs, signal) };
312
+ if (!exists.ok) {
313
+ return { ok: false, message: `cannot determine whether "${path}" exists: ${exists.error.message}`, unresolvedSymlink: true };
314
+ }
315
+ if (!exists.value) {
316
+ const newPath = await canonicalizeNewPath(env, abs, signal);
317
+ if (!newPath.ok) {
318
+ return { ok: false, message: newPath.message, unresolvedSymlink: true };
319
+ }
320
+ return { ok: true, key: newPath.key };
314
321
  }
315
322
  const canon = await env.canonicalPath(abs, signal);
316
323
  if (canon.ok)
317
324
  return { ok: true, key: canon.value };
318
325
  const info = await env.fileInfo(abs, signal);
319
- if (!(info.ok && info.value.kind === "symlink")) {
320
- return { ok: true, key: abs };
326
+ if (!info.ok) {
327
+ return { ok: false, message: `cannot determine whether "${path}" is a symlink: ${info.error.message}`, unresolvedSymlink: true };
328
+ }
329
+ if (info.value.kind !== "symlink") {
330
+ const viaAncestors = await canonicalizeNewPath(env, abs, signal, { skipLeaf: true });
331
+ if (!viaAncestors.ok) {
332
+ return { ok: false, message: viaAncestors.message, unresolvedSymlink: true };
333
+ }
334
+ return { ok: true, key: viaAncestors.key };
321
335
  }
322
336
  if (!env.readLink) {
323
337
  return { ok: false, message: `cannot resolve symlink "${path}" (env has no readLink)`, unresolvedSymlink: true };
@@ -328,7 +342,10 @@ export async function canonicalizeTarget(env, path, signal, baseCwd) {
328
342
  }
329
343
  const linkTarget = isAbsolutePathForm(link.value) ? link.value : `${parentDir(abs)}${nativeSepOf(abs)}${link.value}`;
330
344
  const reabs = await env.absolutePath(linkTarget, signal);
331
- abs = reabs.ok ? reabs.value : linkTarget;
345
+ if (!reabs.ok) {
346
+ return { ok: false, message: `cannot resolve symlink target "${linkTarget}" for "${path}": ${reabs.error.message}`, unresolvedSymlink: true };
347
+ }
348
+ abs = reabs.value;
332
349
  }
333
350
  return { ok: false, message: `symlink chain for "${path}" exceeded the hop cap; target unresolved`, unresolvedSymlink: true };
334
351
  }
@@ -339,21 +356,28 @@ function parentDir(abs) {
339
356
  const head = abs.slice(0, i);
340
357
  return /^[A-Za-z]:$/.test(head) ? head + abs[i] : head;
341
358
  }
342
- async function canonicalizeNewPath(env, abs, signal) {
359
+ async function canonicalizeNewPath(env, abs, signal, opts) {
343
360
  const sep = nativeSepOf(abs);
344
361
  const parts = isWinFormPath(abs) ? abs.split(/[\\/]/) : abs.split("/");
345
362
  const tail = [];
363
+ if (opts?.skipLeaf === true && parts.length > 1)
364
+ tail.push(parts.pop());
346
365
  while (parts.length > 1) {
347
366
  const candidate = parts.join(sep) || sep;
348
367
  const ex = await env.exists(candidate, signal);
349
- if (ex.ok && ex.value) {
368
+ if (!ex.ok) {
369
+ return { ok: false, message: `cannot determine whether ancestor "${candidate}" exists: ${ex.error.message}` };
370
+ }
371
+ if (ex.value) {
350
372
  const canon = await env.canonicalPath(candidate, signal);
351
- const baseCanon = canon.ok ? canon.value : candidate;
352
- return [baseCanon, ...tail.reverse()].join(nativeSepOf(baseCanon));
373
+ if (!canon.ok) {
374
+ return { ok: false, message: `cannot canonicalize existing ancestor "${candidate}": ${canon.error.message}` };
375
+ }
376
+ return { ok: true, key: [canon.value, ...tail.reverse()].join(nativeSepOf(canon.value)) };
353
377
  }
354
378
  tail.push(parts.pop());
355
379
  }
356
- return abs;
380
+ return { ok: true, key: abs };
357
381
  }
358
382
  export function violationText(toolName, v) {
359
383
  return `Error (${toolName}): ${v.message}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.13.0",
3
+ "version": "5.14.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",