@sema-agent/core 2.13.0 → 3.0.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 (95) hide show
  1. package/dist/agents/cascade.d.ts +4 -0
  2. package/dist/agents/cascade.js +54 -9
  3. package/dist/agents/repair-loop.d.ts +2 -0
  4. package/dist/agents/repair-loop.js +101 -2
  5. package/dist/agents/retain-ledger.d.ts +18 -2
  6. package/dist/agents/retain-ledger.js +37 -8
  7. package/dist/agents/roster-store.d.ts +4 -0
  8. package/dist/agents/roster-store.js +22 -1
  9. package/dist/agents/teacher.js +4 -2
  10. package/dist/agents/team.js +7 -4
  11. package/dist/agents/verify.d.ts +15 -2
  12. package/dist/agents/verify.js +36 -1
  13. package/dist/bin/sema-tb.js +2 -2
  14. package/dist/brain/circuit-breaker.d.ts +1 -0
  15. package/dist/brain/circuit-breaker.js +26 -9
  16. package/dist/brain/degrading.js +13 -1
  17. package/dist/brain/failover.js +2 -0
  18. package/dist/brain/repetition.js +10 -1
  19. package/dist/brain/routing.js +5 -1
  20. package/dist/brain/status-sink.js +5 -1
  21. package/dist/brain/tool-call-repair.js +32 -14
  22. package/dist/core/auto-compaction.js +17 -7
  23. package/dist/core/checkpoint-store.d.ts +1 -0
  24. package/dist/core/checkpoint-store.js +28 -10
  25. package/dist/core/consolidate-scope.js +4 -3
  26. package/dist/core/context-edit.js +3 -0
  27. package/dist/core/context-guard.js +3 -0
  28. package/dist/core/git-worktree-env.d.ts +6 -1
  29. package/dist/core/git-worktree-env.js +12 -1
  30. package/dist/core/hooks.d.ts +1 -0
  31. package/dist/core/hooks.js +40 -2
  32. package/dist/core/lsp-diagnostics.js +13 -7
  33. package/dist/core/lsp-protocol.d.ts +1 -1
  34. package/dist/core/lsp-protocol.js +20 -8
  35. package/dist/core/lsp-session.d.ts +12 -2
  36. package/dist/core/lsp-session.js +114 -49
  37. package/dist/core/lsp.d.ts +13 -1
  38. package/dist/core/lsp.js +64 -9
  39. package/dist/core/memory-engine/data-plane.js +4 -0
  40. package/dist/core/memory-engine/engine.d.ts +2 -1
  41. package/dist/core/memory-engine/engine.js +82 -7
  42. package/dist/core/memory-engine/file-backend.d.ts +1 -0
  43. package/dist/core/memory-engine/file-backend.js +13 -3
  44. package/dist/core/memory-engine/layout.js +12 -1
  45. package/dist/core/memory.js +3 -0
  46. package/dist/core/permission-rules.d.ts +2 -1
  47. package/dist/core/permission-rules.js +24 -3
  48. package/dist/core/runner/assemble-result.d.ts +1 -1
  49. package/dist/core/runner/assemble-result.js +0 -1
  50. package/dist/core/runner/prepare-task.js +11 -0
  51. package/dist/core/runner/runtask.js +21 -22
  52. package/dist/core/runner/usage-accounting.d.ts +6 -0
  53. package/dist/core/runner/usage-accounting.js +9 -8
  54. package/dist/core/secret-env.js +5 -1
  55. package/dist/core/sensitive-path-policy.js +9 -3
  56. package/dist/core/store-contracts/session-repo-contract.js +40 -0
  57. package/dist/core/task-registry-monitor.js +7 -1
  58. package/dist/core/tool-policy.js +65 -17
  59. package/dist/core/trace.d.ts +1 -0
  60. package/dist/core/types.d.ts +1 -0
  61. package/dist/engine/compaction/compaction.d.ts +1 -0
  62. package/dist/engine/compaction/compaction.js +14 -5
  63. package/dist/engine/lsp/frame-decoder.js +6 -3
  64. package/dist/engine/lsp/node-lsp-manager.d.ts +2 -0
  65. package/dist/engine/lsp/node-lsp-manager.js +103 -26
  66. package/dist/engine/lsp/stdio-lsp-transport.js +16 -7
  67. package/dist/internal/harness.d.ts +1 -1
  68. package/dist/internal/harness.js +1 -1
  69. package/dist/orchestration/workflow-governance.js +43 -9
  70. package/dist/orchestration/workflow-sandbox-conformance.js +85 -51
  71. package/dist/orchestration/workflow-script-store.js +34 -4
  72. package/dist/orchestration/workflow.js +2 -2
  73. package/dist/prompt-assembly/artifact-store.d.ts +2 -0
  74. package/dist/prompt-assembly/artifact-store.js +39 -24
  75. package/dist/prompt-assembly/assemble.js +38 -7
  76. package/dist/prompt-assembly/epoch.js +19 -2
  77. package/dist/prompt-assembly/event-registry.js +3 -3
  78. package/dist/prompt-assembly/explain.js +1 -1
  79. package/dist/prompt-assembly/tool-catalog.js +16 -1
  80. package/dist/prompt-assembly/turn-snapshot.js +4 -1
  81. package/dist/stores/file/fs-atomic.d.ts +3 -0
  82. package/dist/stores/file/fs-atomic.js +53 -8
  83. package/dist/stores/file/index.d.ts +1 -0
  84. package/dist/stores/file/index.js +7 -0
  85. package/dist/stores/file/session-store.d.ts +2 -0
  86. package/dist/stores/file/session-store.js +28 -1
  87. package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -0
  88. package/dist/tools/fs/bash-readonly-classifier.js +19 -2
  89. package/dist/tools/fs/fs-bash.d.ts +1 -1
  90. package/dist/tools/fs/fs-bash.js +93 -5
  91. package/dist/tools/loop-tick.js +1 -1
  92. package/dist/tools/monitor.js +55 -9
  93. package/dist/tools/worktree.d.ts +2 -0
  94. package/dist/tools/worktree.js +233 -143
  95. package/package.json +5 -2
@@ -1,4 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
+ const PRESENTATION_KEYWORDS = new Set(["description", "title", "examples", "$id"]);
3
+ const SCHEMA_NAME_MAPS = new Set([
4
+ "properties",
5
+ "patternProperties",
6
+ "definitions",
7
+ "$defs",
8
+ "dependentSchemas",
9
+ ]);
2
10
  function stripPresentation(node) {
3
11
  if (Array.isArray(node))
4
12
  return node.map(stripPresentation);
@@ -6,8 +14,15 @@ function stripPresentation(node) {
6
14
  return node;
7
15
  const out = {};
8
16
  for (const [k, v] of Object.entries(node)) {
9
- if (k === "description" || k === "title" || k === "examples" || k === "$id")
17
+ if (PRESENTATION_KEYWORDS.has(k))
10
18
  continue;
19
+ if (SCHEMA_NAME_MAPS.has(k) && v !== null && typeof v === "object" && !Array.isArray(v)) {
20
+ const named = {};
21
+ for (const [name, sub] of Object.entries(v))
22
+ named[name] = stripPresentation(sub);
23
+ out[k] = named;
24
+ continue;
25
+ }
11
26
  out[k] = k === "required" && Array.isArray(v) ? [...v].sort() : stripPresentation(v);
12
27
  }
13
28
  return out;
@@ -61,7 +61,10 @@ export function buildTurnPromptSnapshot(raw, api, promptHasher) {
61
61
  loweringVersion: `${lowering.version}/${lowering.form}/${api}`,
62
62
  };
63
63
  const bump = () => {
64
- snapshot.cacheIdentity = identityOf(elements);
64
+ const next = identityOf(elements);
65
+ if (next === snapshot.cacheIdentity)
66
+ return;
67
+ snapshot.cacheIdentity = next;
65
68
  snapshot.onChange?.(snapshot);
66
69
  };
67
70
  const snapshot = {
@@ -10,7 +10,10 @@ export declare class AppendLog {
10
10
  private fd;
11
11
  private disposed;
12
12
  private readonly path;
13
+ private boundary;
13
14
  constructor(path: string);
15
+ private sizeOrUnknown;
16
+ private repairTornTail;
14
17
  append(record: unknown, fsync: boolean): void;
15
18
  close(): void;
16
19
  closeForSwap(): void;
@@ -1,4 +1,4 @@
1
- import { closeSync, constants as FS, existsSync, linkSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, truncateSync, unlinkSync, writeFileSync, writeSync, fsyncSync, } from "node:fs";
1
+ import { closeSync, constants as FS, existsSync, fstatSync, linkSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, truncateSync, unlinkSync, writeFileSync, writeSync, fsyncSync, } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, dirname, join, resolve as resolvePath } from "node:path";
4
4
  import { createHash, randomBytes } from "node:crypto";
@@ -148,30 +148,75 @@ export class AppendLog {
148
148
  fd;
149
149
  disposed = false;
150
150
  path;
151
+ boundary = -1;
151
152
  constructor(path) {
152
153
  this.path = path;
153
154
  ensureDir(dirnameOf(path));
154
155
  truncateTornTail(path);
155
- this.fd = openSync(path, "a", 0o600);
156
+ this.fd = openSync(path, "a+", 0o600);
157
+ this.boundary = this.sizeOrUnknown();
158
+ }
159
+ sizeOrUnknown() {
160
+ try {
161
+ return fstatSync(this.fd).size;
162
+ }
163
+ catch {
164
+ return -1;
165
+ }
166
+ }
167
+ repairTornTail() {
168
+ const size = this.sizeOrUnknown();
169
+ if (size < 0) {
170
+ this.boundary = -1;
171
+ return;
172
+ }
173
+ if (size === this.boundary)
174
+ return;
175
+ if (size === 0) {
176
+ this.boundary = 0;
177
+ return;
178
+ }
179
+ const tail = Buffer.allocUnsafe(1);
180
+ try {
181
+ if (readSync(this.fd, tail, 0, 1, size - 1) === 1 && tail[0] === 0x0a) {
182
+ this.boundary = size;
183
+ return;
184
+ }
185
+ }
186
+ catch {
187
+ return;
188
+ }
189
+ truncateTornTail(this.path);
190
+ this.boundary = this.sizeOrUnknown();
156
191
  }
157
192
  append(record, fsync) {
158
193
  if (this.fd < 0 && !this.disposed) {
159
194
  truncateTornTail(this.path);
160
- this.fd = openSync(this.path, "a", 0o600);
195
+ this.fd = openSync(this.path, "a+", 0o600);
196
+ this.boundary = this.sizeOrUnknown();
161
197
  }
162
198
  if (this.fd < 0) {
163
199
  const e = new Error("append: this log was closed (its session/box was deleted or replaced) — refusing to write through a released descriptor");
164
200
  e.code = "log_closed";
165
201
  throw e;
166
202
  }
203
+ this.repairTornTail();
167
204
  const buf = Buffer.from(`${JSON.stringify(record)}\n`, "utf8");
168
205
  let written = 0;
169
- while (written < buf.length) {
170
- const n = writeSync(this.fd, buf, written, buf.length - written);
171
- if (n <= 0)
172
- throw new Error(`append: write made no progress (${written}/${buf.length} bytes) — refusing to leave a torn record`);
173
- written += n;
206
+ try {
207
+ while (written < buf.length) {
208
+ const n = writeSync(this.fd, buf, written, buf.length - written);
209
+ if (n <= 0)
210
+ throw new Error(`append: write made no progress (${written}/${buf.length} bytes) — refusing to leave a torn record`);
211
+ written += n;
212
+ }
213
+ }
214
+ catch (err) {
215
+ this.boundary = -1;
216
+ throw err;
174
217
  }
218
+ if (this.boundary >= 0)
219
+ this.boundary += buf.length;
175
220
  if (fsync)
176
221
  fsyncSync(this.fd);
177
222
  }
@@ -38,6 +38,7 @@ export declare class FileStorageBackend {
38
38
  private readonly fileCheckpoints;
39
39
  private readonly fileMemory;
40
40
  private readonly ttl;
41
+ private readonly fileSessions;
41
42
  private readonly fileWorkflowJournal;
42
43
  constructor(opts?: FileStorageBackendOptions);
43
44
  dispose(): Promise<void>;
@@ -32,6 +32,7 @@ export class FileStorageBackend {
32
32
  fileCheckpoints;
33
33
  fileMemory;
34
34
  ttl;
35
+ fileSessions;
35
36
  fileWorkflowJournal;
36
37
  constructor(opts = {}) {
37
38
  this.root = resolveDataRoot(opts.root);
@@ -39,6 +40,7 @@ export class FileStorageBackend {
39
40
  this.lock.acquire();
40
41
  try {
41
42
  const repo = new FileSessionRepo(this.root);
43
+ this.fileSessions = repo;
42
44
  this.ttl = new TtlSessionStore({ repo, evict: opts.evict ?? "forget" });
43
45
  this.sessionStore = this.ttl;
44
46
  this.fileCheckpoints = new FileCheckpointStore(this.root, opts.checkpoint);
@@ -63,6 +65,11 @@ export class FileStorageBackend {
63
65
  }
64
66
  catch {
65
67
  }
68
+ try {
69
+ await this.fileSessions.dispose();
70
+ }
71
+ catch {
72
+ }
66
73
  this.fileCheckpoints.close();
67
74
  try {
68
75
  await this.fileMemory.flushEmbeds();
@@ -2,10 +2,12 @@ import type { Session, SessionMetadata, SessionRepo, SessionTreeEntry } from "..
2
2
  export declare class FileSessionRepo implements SessionRepo {
3
3
  private readonly dir;
4
4
  private readonly tmpDir;
5
+ private readonly joined;
5
6
  constructor(root: string);
6
7
  private pathFor;
7
8
  private read;
8
9
  private storage;
10
+ dispose(): Promise<void>;
9
11
  create(options?: {
10
12
  id?: string;
11
13
  }): Promise<Session>;
@@ -20,11 +20,19 @@ function evictSharedSessionStorage(canonical) {
20
20
  class FileSessionStorage extends BaseSessionStorage {
21
21
  log;
22
22
  canonicalPath;
23
+ holders = new Set();
23
24
  constructor(log, meta, entries, canonicalPath) {
24
25
  super(meta, entries);
25
26
  this.log = log;
26
27
  this.canonicalPath = canonicalPath;
27
28
  }
29
+ addHolder(holder) {
30
+ this.holders.add(holder);
31
+ }
32
+ removeHolder(holder) {
33
+ this.holders.delete(holder);
34
+ return this.holders.size === 0;
35
+ }
28
36
  closeLog() {
29
37
  this.log.close();
30
38
  }
@@ -46,6 +54,7 @@ class FileSessionStorage extends BaseSessionStorage {
46
54
  export class FileSessionRepo {
47
55
  dir;
48
56
  tmpDir;
57
+ joined = new Map();
49
58
  constructor(root) {
50
59
  this.dir = join(root, "sessions");
51
60
  this.tmpDir = join(root, "tmp");
@@ -77,19 +86,37 @@ export class FileSessionRepo {
77
86
  storage(id, createdAt, entries, forkedFrom) {
78
87
  const canonical = canonicalStoreKey(this.pathFor(id));
79
88
  const live = sharedSessionStorages.get(canonical)?.deref();
80
- if (live !== undefined)
89
+ if (live !== undefined) {
90
+ live.addHolder(this);
91
+ this.joined.set(canonical, new WeakRef(live));
81
92
  return live;
93
+ }
82
94
  const log = new AppendLog(this.pathFor(id));
83
95
  const created = new FileSessionStorage(log, { id, createdAt, ...(forkedFrom !== undefined ? { forkedFrom } : {}) }, entries, canonical);
84
96
  sharedSessionStorages.set(canonical, new WeakRef(created));
97
+ created.addHolder(this);
98
+ this.joined.set(canonical, new WeakRef(created));
85
99
  sessionStorageFinalizer.register(created, { canonical, log }, created);
86
100
  return created;
87
101
  }
102
+ async dispose() {
103
+ for (const [canonical, ref] of this.joined) {
104
+ const mine = ref.deref();
105
+ if (mine === undefined)
106
+ continue;
107
+ if (sharedSessionStorages.get(canonical)?.deref() !== mine)
108
+ continue;
109
+ if (mine.removeHolder(this))
110
+ evictSharedSessionStorage(canonical);
111
+ }
112
+ this.joined.clear();
113
+ }
88
114
  async create(options = {}) {
89
115
  const id = options.id ?? uuidv7();
90
116
  const createdAt = new Date().toISOString();
91
117
  const path = this.pathFor(id);
92
118
  if (!existsSync(path)) {
119
+ evictSharedSessionStorage(canonicalStoreKey(path));
93
120
  atomicWriteFile(this.tmpDir, path, `${JSON.stringify({ kind: "meta", id, createdAt })}\n`);
94
121
  return new StoredSession(this.storage(id, createdAt, []));
95
122
  }
@@ -14,6 +14,7 @@ export interface CompoundReadonlyVerdict {
14
14
  reason?: string;
15
15
  outOfRootRead?: true;
16
16
  outOfRootPaths?: readonly string[];
17
+ checkedPaths?: readonly string[];
17
18
  }
18
19
  export declare function formatOutOfRootReadApprovalOption(directory: string): string;
19
20
  export declare function classifyCompoundReadonlyDetailed(command: string, allow: ReadonlySet<string>, boundary?: BashReadonlyRootBoundary): CompoundReadonlyVerdict;
@@ -179,6 +179,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
179
179
  const args = tokens.folded.slice(1);
180
180
  const findings = [];
181
181
  const candidates = [];
182
+ const bareWordOperands = [];
182
183
  for (const rawArg of tokens.raw.slice(1)) {
183
184
  if (!hasUnquotedExpansionMetachar(rawArg))
184
185
  continue;
@@ -227,6 +228,8 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
227
228
  continue;
228
229
  if (isPathShapedToken(t))
229
230
  candidates.push(t);
231
+ else
232
+ bareWordOperands.push(t);
230
233
  }
231
234
  }
232
235
  for (const candidate of candidates) {
@@ -238,9 +241,16 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
238
241
  });
239
242
  continue;
240
243
  }
241
- if (!withinAnyRoot(boundary.roots, resolved))
244
+ if (withinAnyRoot(boundary.roots, resolved))
245
+ findings.push({ kind: "inside", path: resolved });
246
+ else
242
247
  findings.push({ kind: "outside", command: name, path: resolved });
243
248
  }
249
+ for (const operand of bareWordOperands) {
250
+ const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], operand, boundary.homeDir);
251
+ if (resolved !== undefined && withinAnyRoot(boundary.roots, resolved))
252
+ findings.push({ kind: "inside", path: resolved });
253
+ }
244
254
  return findings;
245
255
  }
246
256
  export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
@@ -383,22 +393,29 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
383
393
  }
384
394
  function evaluateReadBoundary(foldedSegments, boundary) {
385
395
  const outside = [];
396
+ const inside = [];
386
397
  for (const toks of foldedSegments) {
387
398
  for (const finding of collectSegmentBoundaryFindings(toks, boundary)) {
388
399
  if (finding.kind === "unresolvable")
389
400
  return { reason: finding.reason };
401
+ if (finding.kind === "inside") {
402
+ if (!inside.includes(finding.path))
403
+ inside.push(finding.path);
404
+ continue;
405
+ }
390
406
  if (!outside.some((o) => o.path === finding.path))
391
407
  outside.push(finding);
392
408
  }
393
409
  }
394
410
  if (outside.length === 0)
395
- return {};
411
+ return inside.length > 0 ? { checkedPaths: inside } : {};
396
412
  const paths = outside.map((o) => `"${o.path}"`).join(", ");
397
413
  const allowed = boundary.roots.length > 0 ? boundary.roots.join(", ") : "(none)";
398
414
  return {
399
415
  reason: `"${outside[0].command}" reads ${paths}, outside the allowed directories for this session: ${allowed} — not auto-allowed`,
400
416
  outOfRootRead: true,
401
417
  outOfRootPaths: outside.map((o) => o.path),
418
+ ...(inside.length > 0 ? { checkedPaths: inside } : {}),
402
419
  };
403
420
  }
404
421
  export function classifySimpleCommandReadBoundary(command, boundary) {
@@ -14,7 +14,7 @@ export interface ExecClampOption {
14
14
  }) => () => void;
15
15
  }
16
16
  export declare function bashExitOneInterpretation(command: string): string | undefined;
17
- export declare function canAutoBackground(command: string): boolean;
17
+ export declare function canAutoBackground(command: string, depth?: number): boolean;
18
18
  export declare function createBashTool(env: ExecutionEnv, rootCanonical: string, coAuthor?: string | false, cwdRef?: CwdRef, taskOpts?: {
19
19
  taskRegistry?: TaskRegistry;
20
20
  taskOwner?: string;
@@ -6,6 +6,7 @@ import { hasBackgroundShell } from "../../core/background-shell.js";
6
6
  import { delimitUntrusted } from "../../core/untrusted-text.js";
7
7
  import { MCP_IMAGE_MAX_BASE64 } from "../../core/mcp.js";
8
8
  import { imageMagicMatches, withinAnyRoot } from "./safety.js";
9
+ import { isRemoteExecutionEnv } from "../../core/remote-env.js";
9
10
  import { ghRateLimitHint } from "./gh-rate-limit.js";
10
11
  import { resolveBashTimeoutCaps, bashTimeoutCapsSec, bashMaxOutputChars, clipShellOutput, writeShellOverflowFile, createShellOverflowSpoolFence, shellRecoveryHint, CWD_SENTINEL, } from "./fs-shared.js";
11
12
  import { BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyCompoundReadonly, classifySimpleCommandReadBoundary, } from "./bash-readonly-classifier.js";
@@ -137,7 +138,26 @@ function commandBasename(token) {
137
138
  const idx = token.lastIndexOf("/");
138
139
  return idx === -1 ? token : token.slice(idx + 1);
139
140
  }
140
- export function canAutoBackground(command) {
141
+ const COMMAND_STRING_SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash"]);
142
+ function commandStringPayload(tokens) {
143
+ const word = commandBasename(tokens[0] ?? "");
144
+ if (word === "eval")
145
+ return tokens.slice(1).join(" ");
146
+ if (!COMMAND_STRING_SHELLS.has(word))
147
+ return undefined;
148
+ const flagIdx = tokens.findIndex((t, i) => i > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(t));
149
+ return flagIdx === -1 ? undefined : tokens.slice(flagIdx + 1).join(" ");
150
+ }
151
+ function unquotePayload(payload) {
152
+ const s = payload.trim();
153
+ const ansiC = /^\$'([\s\S]*)'$/.exec(s);
154
+ if (ansiC)
155
+ return (ansiC[1] ?? "").replace(/\\n/g, "\n").replace(/\\t/g, "\t");
156
+ const quoted = /^(['"])([\s\S]*)\1$/.exec(s);
157
+ return quoted ? (quoted[2] ?? "") : s;
158
+ }
159
+ const AUTO_BACKGROUND_MAX_WRAPPER_DEPTH = 3;
160
+ export function canAutoBackground(command, depth = 0) {
141
161
  const segments = command
142
162
  .split(/&&|\|\||;|\||\r?\n/)
143
163
  .map((s) => s.trim())
@@ -149,6 +169,9 @@ export function canAutoBackground(command) {
149
169
  const word = commandBasename(tokens[0] ?? "");
150
170
  if (word === "git")
151
171
  return false;
172
+ const payload = depth < AUTO_BACKGROUND_MAX_WRAPPER_DEPTH ? commandStringPayload(tokens) : undefined;
173
+ if (payload !== undefined && payload.trim() !== "" && !canAutoBackground(unquotePayload(payload), depth + 1))
174
+ return false;
152
175
  if (word === "xargs" && tokens.slice(1).some((t) => commandBasename(t) === "git"))
153
176
  return false;
154
177
  }
@@ -641,6 +664,60 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
641
664
  },
642
665
  });
643
666
  }
667
+ function createCanonicalReadBoundary(env, readRoots) {
668
+ const lexicalOnly = isRemoteExecutionEnv(env);
669
+ let rootsOnce;
670
+ const canonicalRoots = async (signal) => {
671
+ rootsOnce ??= (async () => {
672
+ const out = [];
673
+ for (const r of readRoots) {
674
+ const c = await env.canonicalPath(r, signal);
675
+ out.push(c.ok ? c.value : r);
676
+ }
677
+ return out;
678
+ })();
679
+ return await rootsOnce;
680
+ };
681
+ const resolve = async (p, signal) => {
682
+ const parts = p.split(/[\\/]/);
683
+ const tail = [];
684
+ while (parts.length > 0) {
685
+ const canon = await env.canonicalPath(parts.join("/") || "/", signal);
686
+ if (canon.ok) {
687
+ return [canon.value.replace(/[\\/]+$/, ""), ...[...tail].reverse()].join("/");
688
+ }
689
+ tail.push(parts.pop());
690
+ }
691
+ return undefined;
692
+ };
693
+ return {
694
+ escapesAfterResolution: async (paths, signal) => {
695
+ if (lexicalOnly || paths.length === 0)
696
+ return [];
697
+ const roots = await canonicalRoots(signal);
698
+ const escaping = [];
699
+ for (const p of paths) {
700
+ const real = await resolve(p, signal);
701
+ if (real === undefined)
702
+ continue;
703
+ if (!withinAnyRoot(roots, real) && !escaping.includes(real))
704
+ escaping.push(real);
705
+ }
706
+ return escaping;
707
+ },
708
+ allResolveInside: async (paths, signal) => {
709
+ if (lexicalOnly || paths.length === 0)
710
+ return false;
711
+ const roots = await canonicalRoots(signal);
712
+ for (const p of paths) {
713
+ const real = await resolve(p, signal);
714
+ if (real === undefined || !withinAnyRoot(roots, real))
715
+ return false;
716
+ }
717
+ return true;
718
+ },
719
+ };
720
+ }
644
721
  export function createBashReadonlyTool(env, rootCanonical, allow, execClamp, opts) {
645
722
  const timeoutCaps = resolveBashTimeoutCaps(opts);
646
723
  const timeoutCapsSecView = bashTimeoutCapsSec(timeoutCaps);
@@ -659,14 +736,16 @@ export function createBashReadonlyTool(env, rootCanonical, allow, execClamp, opt
659
736
  }
660
737
  return true;
661
738
  };
739
+ const canonicalBoundary = createCanonicalReadBoundary(env, readRoots);
662
740
  return defineTool({
663
741
  name: "Bash",
664
742
  contract: { contractId: "core.bash_readonly@1", implementationRevision: "1" },
665
743
  description: `Run a SINGLE read-only inspection command (e.g. ${sample}, …) and return its output. Shell ` +
666
744
  "operators (pipes, redirects, ;, &&, command substitution, subshells) are rejected and only " +
667
745
  "allowlisted commands run. Reads are confined to the workspace root(s): a path outside them is " +
668
- "refused, as is one that cannot be resolved statically (e.g. a `~` path). Still subject to the " +
669
- "deployment's approval policy.",
746
+ "refused, as is one that cannot be resolved statically (e.g. a `~` path). " +
747
+ (isRemoteExecutionEnv(env) ? "" : "A path that leads outside via a symlink counts as outside. ") +
748
+ "Still subject to the deployment's approval policy.",
670
749
  parameters: Type.Object({
671
750
  command: Type.String({ description: "A single allowlisted read-only command (no shell operators)." }),
672
751
  timeout: Type.Optional(Type.Number({ description: `Timeout in milliseconds (default ${timeoutCaps.defaultMs}, max ${timeoutCaps.maxMs}; requests above the max are capped to it).` })),
@@ -678,8 +757,17 @@ export function createBashReadonlyTool(env, rootCanonical, allow, execClamp, opt
678
757
  if (reason)
679
758
  return errorResult(`Error (Bash): ${reason}`);
680
759
  const boundary = classifySimpleCommandReadBoundary(command, { roots: readRoots, cwd: rootCanonical });
681
- if (boundary.reason !== undefined && !(await readsOnlyEngineOverflowSpool(boundary))) {
682
- return errorResult(`Error (Bash): ${boundary.reason}. bash_readonly is confined to the workspace roots; it has no approval path, so the call is refused rather than escalated.`, { code: "readonly_out_of_root", paths: boundary.outOfRootPaths ?? [] });
760
+ if (boundary.reason !== undefined) {
761
+ const rescued = boundary.outOfRootRead === true && (await canonicalBoundary.allResolveInside(boundary.outOfRootPaths ?? [], ctx.signal));
762
+ if (!rescued && !(await readsOnlyEngineOverflowSpool(boundary))) {
763
+ return errorResult(`Error (Bash): ${boundary.reason}. bash_readonly is confined to the workspace roots; it has no approval path, so the call is refused rather than escalated.`, { code: "readonly_out_of_root", paths: boundary.outOfRootPaths ?? [] });
764
+ }
765
+ }
766
+ const escaping = await canonicalBoundary.escapesAfterResolution(boundary.checkedPaths ?? [], ctx.signal);
767
+ if (escaping.length > 0) {
768
+ const quoted = escaping.map((p) => `"${p}"`).join(", ");
769
+ return errorResult(`Error (Bash): a path this command reads resolves through a symlink to ${quoted}, outside the allowed directories for this session: ${readRoots.join(", ")} — not auto-allowed. ` +
770
+ "bash_readonly is confined to the workspace roots; it has no approval path, so the call is refused rather than escalated.", { code: "readonly_out_of_root", paths: escaping });
683
771
  }
684
772
  return await runShell(env, rootCanonical, "Bash", command, msTimeoutToRequestedSec(timeout), timeoutCapsSecView, ctx.signal, undefined, undefined, execClamp, ctx.toolCallId, true);
685
773
  },
@@ -49,7 +49,7 @@ Read and analyze freely — understanding the state of things has no blast radiu
49
49
  `;
50
50
  const DYNAMIC_APPENDIX = `
51
51
 
52
- If a Monitor is armed (check TaskList), keep \`delaySeconds\` at 1200–1800s — the Monitor is the wake signal and this is only the fallback heartbeat. If you were woken by a \`<task-notification>\`, handle the event before deciding whether to re-arm. To stop the loop, call ${SCHEDULE_WAKEUP_TOOL_NAME} with \`stop: true\` and TaskStop the monitor (use TaskList to find its task ID if no longer in context).`;
52
+ If a Monitor is armed (its task_id was reported in the receipt when you started it), keep \`delaySeconds\` at 1200–1800s — the Monitor is the wake signal and this is only the fallback heartbeat. If you were woken by a \`<task-notification>\`, handle the event before deciding whether to re-arm. To stop the loop, call ${SCHEDULE_WAKEUP_TOOL_NAME} with \`stop: true\` and TaskStop the monitor (its task id is in that start receipt, and in the \`<task-id>\` of every notification the watch sends).`;
53
53
  function pushNotificationAppendix(persistentVariant) {
54
54
  const r = persistentVariant
55
55
  ? "newly blocked on a decision you won't make alone, you're ending the loop"
@@ -29,6 +29,16 @@ const MONITOR_DESCRIPTION = "Run a shell command in the background and watch its
29
29
  '- Wrong: `until grep -q "BUILD SUCCEEDED" build.log; do sleep 5; done` — if the build fails, this never exits and you wait until the watch times out.\n' +
30
30
  '- Right: `until grep -qE "BUILD (SUCCEEDED|FAILED)" build.log; do sleep 5; done` — every terminal state exits.\n' +
31
31
  "Before arming, ask: *if this process crashed right now, would my filter emit anything?* If not, widen it.";
32
+ function envBackgroundWallSec(caps, requestedSec) {
33
+ const positive = (v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined);
34
+ const cap = positive(caps.maxBgTimeoutSec);
35
+ const base = requestedSec ?? positive(caps.defaultBgTimeoutSec);
36
+ if (base === undefined)
37
+ return cap;
38
+ if (cap === undefined)
39
+ return base;
40
+ return Math.min(base, cap);
41
+ }
32
42
  export function createMonitorTool(env, opts) {
33
43
  return defineTool({
34
44
  name: "Monitor",
@@ -56,9 +66,10 @@ export function createMonitorTool(env, opts) {
56
66
  }
57
67
  const isPersistent = persistent === true;
58
68
  const timeoutMs = Math.min(MONITOR_MAX_TIMEOUT_MS, Math.max(1, Math.floor(timeout_ms ?? MONITOR_DEFAULT_TIMEOUT_MS)));
69
+ const spawnTimeoutSec = isPersistent ? undefined : Math.ceil(timeoutMs / 1000) + 5;
59
70
  const spawn = await env.spawnBackground(command, {
60
71
  ...(opts.cwdRef !== undefined ? { cwd: opts.cwdRef.current } : {}),
61
- ...(isPersistent ? {} : { timeout: Math.ceil(timeoutMs / 1000) + 5 }),
72
+ ...(spawnTimeoutSec !== undefined ? { timeout: spawnTimeoutSec } : {}),
62
73
  });
63
74
  if (!spawn.ok)
64
75
  return errorResult(`Error (Monitor): ${spawn.error.message}`);
@@ -87,18 +98,50 @@ export function createMonitorTool(env, opts) {
87
98
  });
88
99
  }
89
100
  catch (e) {
90
- await env.killBackground(spawn.value.shellId).catch(() => undefined);
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.`);
101
+ const why = e instanceof Error ? e.message : String(e);
102
+ let killDetail;
103
+ try {
104
+ const kill = await env.killBackground(spawn.value.shellId);
105
+ if (!kill.ok)
106
+ killDetail = `${kill.error.code}: ${kill.error.message}`;
107
+ }
108
+ catch (killErr) {
109
+ killDetail = killErr instanceof Error ? killErr.message : String(killErr);
110
+ }
111
+ return errorResult(killDetail === undefined
112
+ ? `Error (Monitor): the watch process was started but could not be registered (${why}); it has been terminated.`
113
+ : `Error (Monitor): the watch process was started but could not be registered (${why}), and it could NOT be terminated either (${killDetail}). ` +
114
+ `It may still be running as background shell ${spawn.value.shellId}; no task id was minted, so TaskOutput/TaskStop cannot reach it — ` +
115
+ "stop it out of band (the environment's own background time budget and its teardown sweep are the remaining backstops).");
92
116
  }
93
- const bgTimeout = env.backgroundCapabilities.defaultBgTimeoutSec;
117
+ const bgTimeout = envBackgroundWallSec(env.backgroundCapabilities, spawnTimeoutSec);
94
118
  const wasClamped = timeout_ms !== undefined && timeout_ms !== timeoutMs;
95
119
  const clampNote = !isPersistent && wasClamped ? ` (clamped to ${timeoutMs}ms from your requested ${timeout_ms}ms)` : "";
96
120
  const timeoutIgnoredNote = isPersistent && timeout_ms !== undefined ? ` (the timeout_ms you passed is ignored because persistent is true)` : "";
121
+ const envWallMs = bgTimeout !== undefined ? bgTimeout * 1000 : undefined;
122
+ const envWallWins = !isPersistent && envWallMs !== undefined && envWallMs < timeoutMs;
123
+ const persistentBudget = bgTimeout !== undefined
124
+ ? `the execution environment kills background processes after ${bgTimeout}s`
125
+ : `the execution environment's background time budget still applies`;
126
+ const retainedByEnv = env.backgroundCapabilities.retainBackgroundProcesses === true;
127
+ const persistentAnchor = sessionScoped
128
+ ? retainedByEnv
129
+ ? ". This environment declares retained background processes, so at the run teardown the watch is marked retained and " +
130
+ "survives the session's release: only your TaskStop, the command's own exit, or that budget end it."
131
+ : "; it runs until then, until you stop it, or until the session ends."
132
+ : retainedByEnv
133
+ ? ". This mount carries no session identity, so the watch is NOT session-resident despite persistent: true; and because this " +
134
+ "environment declares retained background processes it survives the session's release rather than being settled with the run — " +
135
+ "nothing but your TaskStop, the command's own exit, or that budget will end it, and no later turn can reach it " +
136
+ "(stop it explicitly with TaskStop before you finish)."
137
+ : ". This mount carries no session identity, so the watch is NOT session-resident despite persistent: true — " +
138
+ "it belongs to this task alone and the run teardown settles it, so do not count on it outliving this turn " +
139
+ "(stop it explicitly with TaskStop when you are done).";
97
140
  const lifetime = isPersistent
98
- ? typeof bgTimeout === "number"
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.`;
141
+ ? `It is persistent: no watch timeout of its own${timeoutIgnoredNote}, but ${persistentBudget}${persistentAnchor}`
142
+ : envWallWins
143
+ ? `It will be killed after ${envWallMs}ms if still running — the execution environment's background time budget (${bgTimeout}s) is shorter than the ${timeoutMs}ms watch timeout${clampNote}, and the shorter of the two wins.`
144
+ : `It will be killed after ${timeoutMs}ms${clampNote} if still running.`;
102
145
  const content = onNotify !== undefined
103
146
  ? `Monitoring in background; task_id=${taskId}. Each stdout line becomes a notification event (lines within ` +
104
147
  `~200ms are batched); you'll get a final notification with the exit code when it ends — do not poll. ` +
@@ -111,8 +154,11 @@ export function createMonitorTool(env, opts) {
111
154
  type: "monitor-start",
112
155
  taskId,
113
156
  persistent: isPersistent,
157
+ ...(bgTimeout !== undefined ? { envBudgetSec: bgTimeout } : {}),
158
+ ...(envWallWins && envWallMs !== undefined ? { effectiveTimeoutMs: envWallMs } : {}),
114
159
  ...(isPersistent
115
- ? { timeoutIgnored: timeout_ms !== undefined }
160
+ ?
161
+ { timeoutIgnored: timeout_ms !== undefined, sessionScoped, ...(retainedByEnv ? { retainedByEnv: true } : {}) }
116
162
  : { timeoutMs, ...(timeout_ms !== undefined ? { requestedTimeoutMs: timeout_ms } : {}), wasClamped }),
117
163
  },
118
164
  };
@@ -8,6 +8,8 @@ export interface ActiveWorktreeSession {
8
8
  }
9
9
  export interface WorktreeSessionRef {
10
10
  current?: ActiveWorktreeSession;
11
+ exiting?: boolean;
12
+ entering?: boolean;
11
13
  }
12
14
  export interface WorktreeToolsOptions {
13
15
  repoRoot: string;