@sema-agent/core 2.3.0 → 2.5.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 (83) hide show
  1. package/dist/agents/send-message-tool.d.ts +4 -0
  2. package/dist/agents/send-message-tool.js +37 -24
  3. package/dist/agents/subagent.js +279 -128
  4. package/dist/brain/errors.d.ts +1 -0
  5. package/dist/brain/errors.js +14 -0
  6. package/dist/brain/stream-engine.js +3 -3
  7. package/dist/core/auto-compaction.d.ts +2 -0
  8. package/dist/core/auto-compaction.js +2 -1
  9. package/dist/core/context-edit.js +2 -1
  10. package/dist/core/mcp.js +4 -1
  11. package/dist/core/runner/prepare-task.d.ts +5 -0
  12. package/dist/core/runner/prepare-task.js +41 -2
  13. package/dist/core/runner/runtask.js +39 -9
  14. package/dist/core/runner/tool-disclosure.d.ts +1 -0
  15. package/dist/core/runner/tool-disclosure.js +16 -5
  16. package/dist/core/runner/tool-output-projection.js +2 -1
  17. package/dist/core/session-reconcile.d.ts +1 -0
  18. package/dist/core/session-reconcile.js +40 -20
  19. package/dist/core/store-contracts/checkpoint-store-contract.d.ts +37 -0
  20. package/dist/core/store-contracts/checkpoint-store-contract.js +195 -0
  21. package/dist/core/store-contracts/contract-harness.d.ts +6 -0
  22. package/dist/core/store-contracts/contract-harness.js +16 -0
  23. package/dist/core/store-contracts/contract-kit-version.d.ts +1 -0
  24. package/dist/core/store-contracts/contract-kit-version.js +2 -0
  25. package/dist/core/store-contracts/file-snapshot-store-contract.d.ts +3 -0
  26. package/dist/core/store-contracts/file-snapshot-store-contract.js +126 -0
  27. package/dist/core/store-contracts/mailbox-store-contract.d.ts +6 -0
  28. package/dist/core/store-contracts/mailbox-store-contract.js +193 -0
  29. package/dist/core/store-contracts/session-repo-contract.d.ts +3 -0
  30. package/dist/core/store-contracts/session-repo-contract.js +36 -0
  31. package/dist/core/store-contracts/tool-result-store-contract.d.ts +3 -0
  32. package/dist/core/store-contracts/tool-result-store-contract.js +35 -0
  33. package/dist/core/task-notification.d.ts +2 -0
  34. package/dist/core/task-registry-agent.d.ts +6 -0
  35. package/dist/core/task-registry-agent.js +24 -1
  36. package/dist/core/task-registry-monitor.js +6 -6
  37. package/dist/core/task-registry-shared.d.ts +9 -2
  38. package/dist/core/task-registry-shared.js +1 -1
  39. package/dist/core/task-registry.d.ts +8 -0
  40. package/dist/core/task-registry.js +59 -4
  41. package/dist/core/tool-result-store.d.ts +3 -2
  42. package/dist/core/tool-result-store.js +12 -4
  43. package/dist/core/trace.d.ts +7 -0
  44. package/dist/core/types.d.ts +10 -3
  45. package/dist/engine/compaction/compaction.d.ts +5 -0
  46. package/dist/engine/compaction/compaction.js +68 -2
  47. package/dist/engine/compaction/utils.d.ts +6 -0
  48. package/dist/engine/compaction/utils.js +53 -3
  49. package/dist/engine/harness/messages.d.ts +1 -1
  50. package/dist/engine/harness/messages.js +11 -3
  51. package/dist/engine/loop/types.d.ts +2 -0
  52. package/dist/engine/lsp/node-lsp-manager.d.ts +2 -0
  53. package/dist/engine/lsp/node-lsp-manager.js +16 -0
  54. package/dist/engine/session/import-validate.js +30 -1
  55. package/dist/engine/session/session.js +7 -5
  56. package/dist/index.d.ts +8 -0
  57. package/dist/index.js +8 -0
  58. package/dist/internal/harness.d.ts +1 -1
  59. package/dist/internal/harness.js +1 -1
  60. package/dist/orchestration/builtin-workflows.d.ts +1 -1
  61. package/dist/orchestration/builtin-workflows.js +11 -2
  62. package/dist/orchestration/workflow-governance.d.ts +6 -1
  63. package/dist/orchestration/workflow-governance.js +24 -4
  64. package/dist/orchestration/workflow-primitives.js +7 -1
  65. package/dist/orchestration/workflow-types.d.ts +1 -0
  66. package/dist/orchestration/workflow.d.ts +1 -0
  67. package/dist/orchestration/workflow.js +41 -3
  68. package/dist/tools/fs/fs-bash.d.ts +7 -1
  69. package/dist/tools/fs/fs-bash.js +59 -22
  70. package/dist/tools/fs/fs-read.js +22 -11
  71. package/dist/tools/fs/fs-search-tools.js +3 -3
  72. package/dist/tools/fs/fs-shared.d.ts +20 -7
  73. package/dist/tools/fs/fs-shared.js +17 -3
  74. package/dist/tools/fs/fs-write.js +4 -4
  75. package/dist/tools/fs/index.d.ts +2 -0
  76. package/dist/tools/fs/index.js +7 -1
  77. package/dist/tools/fs/repo-map.js +2 -2
  78. package/dist/tools/fs/safety.d.ts +10 -0
  79. package/dist/tools/fs/safety.js +15 -1
  80. package/dist/tools/monitor.js +18 -4
  81. package/dist/tools/web.js +6 -2
  82. package/dist/tools/worktree.js +46 -25
  83. package/package.json +1 -1
@@ -35,7 +35,10 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
35
35
  tools.push(createGrepTool(env, rootCanonical, additionalRoots), createGlobTool(env, rootCanonical, additionalRoots), createRepoMapTool(env, rootCanonical, additionalRoots));
36
36
  if (includeShell) {
37
37
  tools.push(readOnly
38
- ? createBashReadonlyTool(env, rootCanonical, new Set(bashReadonlyAllow ?? BASH_READONLY_DEFAULT_ALLOW), opts.execClamp)
38
+ ? createBashReadonlyTool(env, rootCanonical, new Set(bashReadonlyAllow ?? BASH_READONLY_DEFAULT_ALLOW), opts.execClamp, {
39
+ ...(opts.bashDefaultTimeoutMs !== undefined ? { bashDefaultTimeoutMs: opts.bashDefaultTimeoutMs } : {}),
40
+ ...(opts.bashMaxTimeoutMs !== undefined ? { bashMaxTimeoutMs: opts.bashMaxTimeoutMs } : {}),
41
+ })
39
42
  : createBashTool(env, rootCanonical, commitCoAuthor, cwdRef, {
40
43
  taskRegistry: opts.taskRegistry,
41
44
  taskOwner: opts.taskOwner,
@@ -46,6 +49,9 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
46
49
  execClamp: opts.execClamp,
47
50
  ...(opts.autoBackgroundOnTimeout !== undefined ? { autoBackgroundOnTimeout: opts.autoBackgroundOnTimeout } : {}),
48
51
  ...(opts.oneShot !== undefined ? { oneShot: opts.oneShot } : {}),
52
+ ...(additionalRoots !== undefined ? { additionalRoots } : {}),
53
+ ...(opts.bashDefaultTimeoutMs !== undefined ? { bashDefaultTimeoutMs: opts.bashDefaultTimeoutMs } : {}),
54
+ ...(opts.bashMaxTimeoutMs !== undefined ? { bashMaxTimeoutMs: opts.bashMaxTimeoutMs } : {}),
49
55
  }));
50
56
  if (!readOnly && mountBackgroundTaskTools && hasBackgroundShell(env)) {
51
57
  const sessionAxis = opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {};
@@ -1,6 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { defineTool, errorResult } from "../../core/tools.js";
3
- import { resolveKey, violationText } from "./safety.js";
3
+ import { resolveKey, violationText, violationDetails } from "./safety.js";
4
4
  import { buildIgnore, walk, walkIsPartial } from "./search.js";
5
5
  const DEFAULT_MAX_CHARS = 16_000;
6
6
  const DEFAULT_MAX_FILES = 400;
@@ -101,7 +101,7 @@ export function createRepoMapTool(env, rootCanonical, additionalRoots) {
101
101
  if (a.path !== undefined) {
102
102
  const r = await resolveKey(env, rootCanonical, a.path, signal, rootCanonical, additionalRoots);
103
103
  if (!r.ok)
104
- return errorResult(violationText("RepoMap", r.violation));
104
+ return errorResult(violationText("RepoMap", r.violation), violationDetails(r.violation));
105
105
  start = r.key;
106
106
  }
107
107
  const maxChars = Math.max(1, Math.floor(a.max_chars ?? DEFAULT_MAX_CHARS));
@@ -18,6 +18,8 @@ export declare function sha256(content: string): string;
18
18
  export interface FsViolation {
19
19
  code: "path_not_in_root" | "not_read" | "stale" | "ambiguous_edit" | "invalid";
20
20
  partialView?: true;
21
+ target?: string;
22
+ roots?: readonly string[];
21
23
  message: string;
22
24
  }
23
25
  export declare function isBlockedDevicePath(key: string): boolean;
@@ -43,6 +45,14 @@ export declare function canonicalizeTarget(env: ExecutionEnv, path: string, sign
43
45
  unresolvedSymlink?: true;
44
46
  }>;
45
47
  export declare function violationText(toolName: string, v: FsViolation): string;
48
+ export declare function violationDetails(v: FsViolation): {
49
+ type: "path_not_in_root";
50
+ code: "path_not_in_root";
51
+ target: string;
52
+ roots: readonly string[];
53
+ } | undefined;
54
+ export declare function withinAnyRoot(rootsCanonical: readonly string[], p: string): boolean;
55
+ export declare const PATH_NOT_IN_ROOT_ESCAPE_HINT = "(This boundary applies to the structured file tools. If you genuinely need content outside the allowed root(s) and the deployment permits it, use the Bash tool \u2014 it is not confined by this fence, though every call remains subject to the deployment's approval policy. Or ask for the directory to be added to additionalDirectories.)";
46
56
  export declare function requireRead(state: ReadFileState, key: string): FsViolation | undefined;
47
57
  export declare const OVERSIZE_READ_ESCAPE_HINT = "(This file is over the Read tool's whole-file byte cap, so a default Read is refused \u2014 read it in slices with explicit offset/limit to satisfy the read-first rule, or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
48
58
  export declare const PARTIAL_VIEW_READ_ESCAPE_HINT = "(Your last Read of this file returned only a PARTIAL view \u2014 the output token cap paginated it, so a default Read will keep returning the same page. Re-read it with explicit offset/limit (start from the page marker's next-page hint) until you have seen the part you are about to change; an explicit slice that fits satisfies the read-first rule. Or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
@@ -175,7 +175,12 @@ export async function resolveKey(env, rootCanonical, path, signal, baseCwd, addi
175
175
  const roots = [rootCanonical, ...(additionalRootsCanonical ?? [])];
176
176
  return {
177
177
  ok: false,
178
- violation: { code: "path_not_in_root", message: `path "${path}" resolves outside the allowed root${roots.length > 1 ? "s" : ""} (${roots.join(", ")}); access denied` },
178
+ violation: {
179
+ code: "path_not_in_root",
180
+ message: `path "${path}" ${key !== path ? `(canonical target: "${key}") ` : ""}resolves outside the allowed root${roots.length > 1 ? "s" : ""} (${roots.join(", ")}); access denied. ${PATH_NOT_IN_ROOT_ESCAPE_HINT}`,
181
+ target: key,
182
+ roots,
183
+ },
179
184
  };
180
185
  }
181
186
  return { ok: true, key };
@@ -241,6 +246,15 @@ async function canonicalizeNewPath(env, abs, signal) {
241
246
  export function violationText(toolName, v) {
242
247
  return `Error (${toolName}): ${v.message}`;
243
248
  }
249
+ export function violationDetails(v) {
250
+ return v.code === "path_not_in_root" && v.target !== undefined
251
+ ? { type: "path_not_in_root", code: "path_not_in_root", target: v.target, roots: v.roots ?? [] }
252
+ : undefined;
253
+ }
254
+ export function withinAnyRoot(rootsCanonical, p) {
255
+ return rootsCanonical.some((r) => within(r, p));
256
+ }
257
+ export const PATH_NOT_IN_ROOT_ESCAPE_HINT = "(This boundary applies to the structured file tools. If you genuinely need content outside the allowed root(s) and the deployment permits it, use the Bash tool — it is not confined by this fence, though every call remains subject to the deployment's approval policy. Or ask for the directory to be added to additionalDirectories.)";
244
258
  export function requireRead(state, key) {
245
259
  const entry = state.get(key);
246
260
  if (entry === undefined || entry.isPartialView) {
@@ -91,17 +91,31 @@ export function createMonitorTool(env, opts) {
91
91
  return errorResult(`Error (Monitor): the watch process was started but could not be registered (${e instanceof Error ? e.message : String(e)}); it has been terminated.`);
92
92
  }
93
93
  const bgTimeout = env.backgroundCapabilities.defaultBgTimeoutSec;
94
+ const wasClamped = timeout_ms !== undefined && timeout_ms !== timeoutMs;
95
+ const clampNote = !isPersistent && wasClamped ? ` (clamped to ${timeoutMs}ms from your requested ${timeout_ms}ms)` : "";
96
+ const timeoutIgnoredNote = isPersistent && timeout_ms !== undefined ? ` (the timeout_ms you passed is ignored because persistent is true)` : "";
94
97
  const lifetime = isPersistent
95
98
  ? typeof bgTimeout === "number"
96
- ? `It is persistent: no watch timeout of its own, but the execution environment kills background processes after ${bgTimeout}s; it runs until then, until you stop it, or until the session ends.`
97
- : "It is persistent: no watch timeout of its own, but the execution environment's background time budget still applies; it runs until then, until you stop it, or until the session ends."
98
- : `It will be killed after ${timeoutMs}ms if still running.`;
99
- return onNotify !== undefined
99
+ ? `It is persistent: no watch timeout of its own${timeoutIgnoredNote}, but the execution environment kills background processes after ${bgTimeout}s; it runs until then, until you stop it, or until the session ends.`
100
+ : `It is persistent: no watch timeout of its own${timeoutIgnoredNote}, but the execution environment's background time budget still applies; it runs until then, until you stop it, or until the session ends.`
101
+ : `It will be killed after ${timeoutMs}ms${clampNote} if still running.`;
102
+ const content = onNotify !== undefined
100
103
  ? `Monitoring in background; task_id=${taskId}. Each stdout line becomes a notification event (lines within ` +
101
104
  `~200ms are batched); you'll get a final notification with the exit code when it ends — do not poll. ` +
102
105
  `${lifetime} TaskOutput("${taskId}") reads the full spool (stdout+stderr); TaskStop("${taskId}") stops the watch.`
103
106
  : `Monitoring in background; task_id=${taskId}. No notification channel is wired in this mount — read output with ` +
104
107
  `TaskOutput("${taskId}") (re-readable spool, stdout+stderr) and stop with TaskStop("${taskId}"). ${lifetime}`;
108
+ return {
109
+ content,
110
+ details: {
111
+ type: "monitor-start",
112
+ taskId,
113
+ persistent: isPersistent,
114
+ ...(isPersistent
115
+ ? { timeoutIgnored: timeout_ms !== undefined }
116
+ : { timeoutMs, ...(timeout_ms !== undefined ? { requestedTimeoutMs: timeout_ms } : {}), wasClamped }),
117
+ },
118
+ };
105
119
  },
106
120
  });
107
121
  }
package/dist/tools/web.js CHANGED
@@ -594,7 +594,7 @@ function classifySearchFailure(message) {
594
594
  if (code === 408 || /\btimed? ?out\b|\betimedout\b/.test(m)) {
595
595
  return { retryable: true, hint: "This looks like a timeout — a retry may succeed; a narrower query tends to return faster." };
596
596
  }
597
- if (/\becconnrefused\b|\benotfound\b|\beai_again\b|\becconnreset\b|\bepipe\b|\bfetch failed\b|socket hang up|\bnetwork\b|\bdns\b/.test(m)) {
597
+ if (/\beconnrefused\b|\benotfound\b|\beai_again\b|\beconnreset\b|\bepipe\b|\bfetch failed\b|socket hang up|\bnetwork\b|\bdns\b/.test(m)) {
598
598
  return { retryable: true, hint: "This is a network/transport fault, not a rejection of the query — one retry is reasonable; if it repeats, treat the search backend as unavailable and continue without it." };
599
599
  }
600
600
  if (code !== undefined && code >= 500 && code <= 599) {
@@ -751,6 +751,7 @@ export function createWebSearchTool(config) {
751
751
  const domainFiltered = usable.filter((r) => webSearchResultAllowed(r.url, allowed_domains, blocked_domains));
752
752
  const droppedByDomain = usable.length - domainFiltered.length;
753
753
  const kept = domainFiltered.slice(0, max);
754
+ const droppedByMax = domainFiltered.length - kept.length;
754
755
  const schemeAssumed = kept.filter((r) => r.schemeAssumed).length;
755
756
  let clipped = false;
756
757
  const shown = kept.map((r) => {
@@ -771,6 +772,9 @@ export function createWebSearchTool(config) {
771
772
  const domainNote = droppedByDomain > 0
772
773
  ? `\n\n[WebSearch: ${droppedByDomain} result(s) were dropped by the ${allowed_domains?.length ? "allowed_domains" : "blocked_domains"} filter passed with this call — the backend returned them, this tool removed them. Relax or drop the filter to see them.]`
773
774
  : "";
775
+ const maxResultsNote = droppedByMax > 0
776
+ ? `\n\n[WebSearch: showing ${kept.length} of ${domainFiltered.length} results — the rest were cut off by this deployment's maxResults limit (${max}).]`
777
+ : "";
774
778
  const clipNote = clipped
775
779
  ? "\n\n[WebSearch: one or more result fields exceeded the display limits and were truncated — treat the text above as an excerpt, not the backend's full result.]"
776
780
  : "";
@@ -780,7 +784,7 @@ export function createWebSearchTool(config) {
780
784
  const tailReminder = shown.length > 0
781
785
  ? "REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks."
782
786
  : "REMINDER: this search returned ZERO usable sources — there is nothing above to cite. Do not fabricate sources, URLs or citations; tell the user the search returned no usable results (the notes above say why, when there is a why) and answer from what you already know, or try a different query.";
783
- const modelText = `${fenced}${dropNote}${domainNote}${schemeNote}${clipNote}\n\n${tailReminder}`;
787
+ const modelText = `${fenced}${dropNote}${domainNote}${maxResultsNote}${schemeNote}${clipNote}\n\n${tailReminder}`;
784
788
  return {
785
789
  content: modelText,
786
790
  details: {
@@ -164,11 +164,14 @@ export function createWorktreeTools(env, opts) {
164
164
  const originalCwd = previous ? previous.originalCwd : cwdRef.current;
165
165
  session.current = { worktreeDir: target, originalCwd, baseSha: head.value.stdout.trim(), entered: true };
166
166
  cwdRef.current = target;
167
- return (`Switched into the existing worktree at ${target}${previous ? ` (the previous worktree at ${previous.worktreeDir} was left on disk, untouched)` : ""}. ` +
168
- "The session's working directory is now this worktree subsequent commands and relative paths " +
169
- "operate there. Note this is a cwd switch, not a sandbox: file containment stays anchored at the " +
170
- "task root. ExitWorktree will restore the original directory but will NOT remove a worktree " +
171
- "entered via `path`.");
167
+ return {
168
+ content: `Switched into the existing worktree at ${target}${previous ? ` (the previous worktree at ${previous.worktreeDir} was left on disk, untouched)` : ""}. ` +
169
+ "The session's working directory is now this worktree subsequent commands and relative paths " +
170
+ "operate there. Note this is a cwd switch, not a sandbox: file containment stays anchored at the " +
171
+ "task root. ExitWorktree will restore the original directory but will NOT remove a worktree " +
172
+ "entered via `path`.",
173
+ details: { type: "worktree", action: "enter", worktreeDir: target },
174
+ };
172
175
  }
173
176
  const base = await resolveBaseCommit(env, repoRoot, baseRef ?? "fresh");
174
177
  if ("error" in base)
@@ -195,11 +198,14 @@ export function createWorktreeTools(env, opts) {
195
198
  baseSha: base.sha,
196
199
  };
197
200
  cwdRef.current = createdDir;
198
- return (`Created worktree at ${createdDir} (base: ${base.label}${base.note ?? ""}). ` +
199
- "The session's working directory is now the worktree subsequent commands and relative paths " +
200
- "operate there. Note this is a cwd switch, not a sandbox: file containment stays anchored at the " +
201
- "task root, so the rest of the repository remains reachable. " +
202
- "Use ExitWorktree to leave (an unchanged worktree is removed; a changed one is kept).");
201
+ return {
202
+ content: `Created worktree at ${createdDir} (base: ${base.label}${base.note ?? ""}). ` +
203
+ "The session's working directory is now the worktree subsequent commands and relative paths " +
204
+ "operate there. Note this is a cwd switch, not a sandbox: file containment stays anchored at the " +
205
+ "task root, so the rest of the repository remains reachable. " +
206
+ "Use ExitWorktree to leave (an unchanged worktree is removed; a changed one is kept).",
207
+ details: { type: "worktree", action: "create", worktreeDir: createdDir, base: base.label },
208
+ };
203
209
  },
204
210
  });
205
211
  const exit = defineTool({
@@ -245,16 +251,22 @@ export function createWorktreeTools(env, opts) {
245
251
  cwdRef.current = restoredTo;
246
252
  if (active.entered) {
247
253
  const discardNote = discardChanges === true ? " (`discardChanges` does not apply to a worktree this session did not create)" : "";
248
- return (`Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` +
249
- `The worktree at ${active.worktreeDir} was entered via \`path\` (not created by this session) and ` +
250
- `was left on disk, untouched${discardNote}.`);
254
+ return {
255
+ content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` +
256
+ `The worktree at ${active.worktreeDir} was entered via \`path\` (not created by this session) and ` +
257
+ `was left on disk, untouched${discardNote}.`,
258
+ details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, kept: true },
259
+ };
251
260
  }
252
261
  const wtThere = await env.exists(active.worktreeDir);
253
262
  if (wtThere.ok && !wtThere.value) {
254
263
  await pruneWorktrees(env, repoRoot).catch(() => undefined);
255
- return (`Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` +
256
- `The worktree at ${active.worktreeDir} was already gone from disk (removed externally, not by this ` +
257
- "session any uncommitted work in it is lost); stale git worktree metadata was pruned.");
264
+ return {
265
+ content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` +
266
+ `The worktree at ${active.worktreeDir} was already gone from disk (removed externally, not by this ` +
267
+ "session — any uncommitted work in it is lost); stale git worktree metadata was pruned.",
268
+ details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, kept: false },
269
+ };
258
270
  }
259
271
  let changed = true;
260
272
  let changeDetail = "could not verify the worktree state — keeping it to be safe";
@@ -278,10 +290,13 @@ export function createWorktreeTools(env, opts) {
278
290
  const removeHint = wantsRemove && keep !== true
279
291
  ? " To remove it anyway and permanently discard these changes, re-invoke with discard_changes: true."
280
292
  : "";
281
- return (`Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` +
282
- `The worktree was KEPT at ${active.worktreeDir} (${why}) — inspect or merge it there, and remove it ` +
283
- "later with `git worktree remove` if no longer needed." +
284
- removeHint);
293
+ return {
294
+ content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` +
295
+ `The worktree was KEPT at ${active.worktreeDir} (${why}) — inspect or merge it there, and remove it ` +
296
+ "later with `git worktree remove` if no longer needed." +
297
+ removeHint,
298
+ details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, kept: true },
299
+ };
285
300
  }
286
301
  await env.exec(`git worktree remove --force ${shq(active.worktreeDir)}`, { cwd: repoRoot }).catch(() => undefined);
287
302
  await pruneWorktrees(env, repoRoot).catch(() => undefined);
@@ -291,12 +306,18 @@ export function createWorktreeTools(env, opts) {
291
306
  : changeDetail;
292
307
  const stateClause = changed ? `had ${detailForDiscard} (discarded — discardChanges was set)` : "had no changes";
293
308
  if (stillThere.ok && stillThere.value) {
294
- return (`Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` +
295
- `The worktree at ${active.worktreeDir} ${changed ? "was marked for discard" : "had no changes"} but could NOT be removed — it is still on disk; ` +
296
- "remove it manually with `git worktree remove --force` if no longer needed.");
309
+ return {
310
+ content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` +
311
+ `The worktree at ${active.worktreeDir} ${changed ? "was marked for discard" : "had no changes"} but could NOT be removed — it is still on disk; ` +
312
+ "remove it manually with `git worktree remove --force` if no longer needed.",
313
+ details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, kept: true },
314
+ };
297
315
  }
298
- return (`Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` +
299
- `The worktree at ${active.worktreeDir} ${stateClause} and was removed.`);
316
+ return {
317
+ content: `Exited the worktree session; working directory restored to ${restoredTo}${restoreNote}. ` +
318
+ `The worktree at ${active.worktreeDir} ${stateClause} and was removed.`,
319
+ details: { type: "worktree", action: "exit", worktreeDir: active.worktreeDir, kept: false },
320
+ };
300
321
  },
301
322
  });
302
323
  return [enter, exit];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",