@stigmer/runner 3.12.1 → 3.12.3

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 (77) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/capture-flow.d.ts +5 -4
  3. package/dist/activities/execute-cursor/capture-flow.js +5 -4
  4. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  5. package/dist/activities/execute-cursor/cas-observations.d.ts +10 -4
  6. package/dist/activities/execute-cursor/cas-observations.js +10 -4
  7. package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
  8. package/dist/activities/execute-cursor/hook-script.js +39 -13
  9. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  10. package/dist/activities/execute-cursor/skill-resolver.d.ts +23 -0
  11. package/dist/activities/execute-cursor/skill-resolver.js +121 -32
  12. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  13. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.d.ts +9 -1
  14. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js +13 -2
  15. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js.map +1 -1
  16. package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +3 -1
  17. package/dist/activities/execute-deep-agent/cas-capture-backend.js +3 -1
  18. package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
  19. package/dist/activities/execute-deep-agent/setup.js +8 -0
  20. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  21. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +5 -1
  22. package/dist/activities/execute-deep-agent/subagent-wiring.js +25 -10
  23. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  24. package/dist/activities/generate-session-subject.d.ts +80 -0
  25. package/dist/activities/generate-session-subject.js +283 -0
  26. package/dist/activities/generate-session-subject.js.map +1 -0
  27. package/dist/client/stigmer-client.d.ts +17 -1
  28. package/dist/client/stigmer-client.js +20 -0
  29. package/dist/client/stigmer-client.js.map +1 -1
  30. package/dist/middleware/approval-gate.d.ts +25 -10
  31. package/dist/middleware/approval-gate.js +33 -14
  32. package/dist/middleware/approval-gate.js.map +1 -1
  33. package/dist/middleware/index.d.ts +6 -5
  34. package/dist/middleware/index.js +8 -5
  35. package/dist/middleware/index.js.map +1 -1
  36. package/dist/middleware/otel-spans.d.ts +2 -1
  37. package/dist/middleware/otel-spans.js +2 -1
  38. package/dist/middleware/otel-spans.js.map +1 -1
  39. package/dist/middleware/tool-intent.d.ts +57 -0
  40. package/dist/middleware/tool-intent.js +152 -0
  41. package/dist/middleware/tool-intent.js.map +1 -0
  42. package/dist/runner-manager.js +3 -1
  43. package/dist/runner-manager.js.map +1 -1
  44. package/dist/runner.js +3 -1
  45. package/dist/runner.js.map +1 -1
  46. package/dist/shared/attachment-vision.js +9 -0
  47. package/dist/shared/attachment-vision.js.map +1 -1
  48. package/dist/shared/mcp-manager.js +8 -0
  49. package/dist/shared/mcp-manager.js.map +1 -1
  50. package/package.json +2 -2
  51. package/src/activities/__tests__/generate-session-subject.test.ts +348 -0
  52. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +60 -9
  53. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +267 -8
  54. package/src/activities/execute-cursor/capture-flow.ts +5 -4
  55. package/src/activities/execute-cursor/cas-observations.ts +10 -4
  56. package/src/activities/execute-cursor/hook-script.ts +39 -13
  57. package/src/activities/execute-cursor/skill-resolver.ts +148 -37
  58. package/src/activities/execute-deep-agent/__test-utils__/scripted-model.ts +13 -2
  59. package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +21 -20
  60. package/src/activities/execute-deep-agent/cas-capture-backend.ts +3 -1
  61. package/src/activities/execute-deep-agent/setup.ts +8 -0
  62. package/src/activities/execute-deep-agent/subagent-wiring.ts +26 -10
  63. package/src/activities/generate-session-subject.ts +370 -0
  64. package/src/client/stigmer-client.ts +23 -1
  65. package/src/middleware/__tests__/approval-gate.test.ts +130 -1
  66. package/src/middleware/__tests__/tool-intent.test.ts +266 -0
  67. package/src/middleware/approval-gate.ts +58 -24
  68. package/src/middleware/index.ts +9 -5
  69. package/src/middleware/otel-spans.ts +2 -1
  70. package/src/middleware/tool-intent.ts +174 -0
  71. package/src/runner-manager.ts +3 -0
  72. package/src/runner.ts +3 -0
  73. package/src/shared/__tests__/attachment-vision.test.ts +4 -0
  74. package/src/shared/__tests__/mcp-manager.test.ts +7 -2
  75. package/src/shared/attachment-vision.ts +9 -0
  76. package/src/shared/filereview/__tests__/capture.test.ts +48 -0
  77. package/src/shared/mcp-manager.ts +8 -0
@@ -7,10 +7,19 @@
7
7
  * - Uses a platform-managed directory outside the workspace
8
8
  * - Ensures the workspace `.stigmer` symlink (see stigmer-link.ts)
9
9
  * - Returns metadata for prompt injection
10
+ *
11
+ * The mount is cached by the skill's content-addressed version hash
12
+ * (stigmer/stigmer#672): metadata is fetched on every execution (that keeps
13
+ * latest-version freshness), but the artifact download and file rewrite are
14
+ * skipped when the mounted content's hash already matches. The session's
15
+ * platform dir survives across executions, so on an active session every
16
+ * message after the first pays a metadata read instead of a full artifact
17
+ * transfer.
10
18
  */
11
19
 
12
- import { mkdir, writeFile, rm } from "node:fs/promises";
20
+ import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
13
21
  import { join, dirname } from "node:path";
22
+ import { ConnectError, Code } from "@connectrpc/connect";
14
23
  import type { StigmerClient } from "../../client/stigmer-client.js";
15
24
  import type { Skill } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
16
25
  import type { ApiResourceReference } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
@@ -21,6 +30,25 @@ import { ensureStigmerSymlink, STIGMER_LOCAL_STATE_DIR } from "../../shared/work
21
30
 
22
31
  const SKILLS_SUBDIR = "skills";
23
32
 
33
+ /**
34
+ * Marker recording what a skill's mount directory currently holds. Written
35
+ * LAST, after every file of the mount landed — a crash mid-write leaves no
36
+ * marker, so the next execution remounts instead of trusting a partial tree.
37
+ */
38
+ const MOUNT_MARKER_FILE = ".stigmer-mount.json";
39
+
40
+ interface MountMarker {
41
+ /** Content-addressed version hash (`Skill.status.version_hash`) of the mounted content. */
42
+ versionHash: string;
43
+ /**
44
+ * Whether the artifact's files are part of the mount. `false` when the
45
+ * skill has no artifact OR when the download failed and the mount fell
46
+ * back to SKILL.md only — the latter makes the next execution retry the
47
+ * download rather than cache the degraded mount.
48
+ */
49
+ artifactMounted: boolean;
50
+ }
51
+
24
52
  export interface SkillResolverOptions {
25
53
  sessionId: string;
26
54
  primaryWorkspaceDir: string;
@@ -61,29 +89,49 @@ export async function resolveSkills(
61
89
  for (const ref of skillRefs) {
62
90
  try {
63
91
  const skill = await client.getSkillByReference(ref);
92
+ const spec = skill.spec;
93
+ if (!spec?.skillMd) {
94
+ console.warn(`[resolveSkills] skill ${ref.org}/${ref.slug} fetched but had no skillMd content`);
95
+ continue;
96
+ }
97
+
98
+ const name = spec.name || skill.metadata?.slug || "unknown";
99
+ const skillDir = join(skillsDir, name);
100
+ const versionHash = skill.status?.versionHash ?? "";
101
+ const wantsArtifact = Boolean(skill.status?.artifactStorageKey);
102
+ const meta: SkillMetadata = {
103
+ name,
104
+ description: spec.description || `Skill: ${name}`,
105
+ path: join(STIGMER_LOCAL_STATE_DIR, SKILLS_SUBDIR, name, "SKILL.md"),
106
+ };
107
+
108
+ if (versionHash !== "" && (await mountIsFresh(skillDir, versionHash, wantsArtifact))) {
109
+ results.push(meta);
110
+ console.log(
111
+ `[resolveSkills] mount cache hit: ${name} (version ${versionHash.slice(0, 12)}) — skipping artifact transfer`,
112
+ );
113
+ continue;
114
+ }
64
115
 
65
116
  let artifactBytes: Uint8Array | undefined;
66
- if (skill.status?.artifactStorageKey) {
117
+ if (wantsArtifact) {
67
118
  try {
68
- const resp = await client.getSkillArtifact(skill.status.artifactStorageKey);
69
- if (resp.artifact && resp.artifact.length > 0) {
70
- artifactBytes = resp.artifact;
71
- }
119
+ artifactBytes = await downloadArtifact(client, skill.status!.artifactStorageKey);
72
120
  } catch (err) {
73
- console.warn(
74
- `[resolveSkills] artifact download failed for ${ref.slug}, ` +
75
- `falling back to SKILL.md only: ${err instanceof Error ? err.message : err}`,
121
+ // Deliberate degradation, but LOUD (#675): the session still gets
122
+ // SKILL.md (better than a dead run), yet a skill silently missing
123
+ // its scripts/references was exactly how oversized artifacts hid.
124
+ console.error(
125
+ `[resolveSkills] artifact download FAILED for ${ref.org || "(default)"}/${ref.slug} ` +
126
+ `(key=${skill.status!.artifactStorageKey}) — mounting SKILL.md WITHOUT the skill's ` +
127
+ `supporting files (scripts/references will be missing): ${err instanceof Error ? err.message : err}`,
76
128
  );
77
129
  }
78
130
  }
79
131
 
80
- const meta = await writeSkill(skill, skillsDir, options.primaryWorkspaceDir, artifactBytes);
81
- if (meta) {
82
- results.push(meta);
83
- console.log(`[resolveSkills] wrote skill: ${meta.name} -> ${meta.path}`);
84
- } else {
85
- console.warn(`[resolveSkills] skill ${ref.org}/${ref.slug} fetched but had no skillMd content`);
86
- }
132
+ await writeSkillMount(skill, skillDir, artifactBytes);
133
+ results.push(meta);
134
+ console.log(`[resolveSkills] wrote skill: ${name} -> ${meta.path}`);
87
135
  } catch (err) {
88
136
  console.warn(
89
137
  `[resolveSkills] failed to resolve skill ${ref.org}/${ref.slug}: ${err instanceof Error ? err.message : err}`,
@@ -98,24 +146,89 @@ export async function resolveSkills(
98
146
  return results;
99
147
  }
100
148
 
101
- async function writeSkill(
149
+ /**
150
+ * Whether the mount at `skillDir` already holds this version's content.
151
+ *
152
+ * Fresh means: the marker's hash matches AND the mount isn't a degraded
153
+ * SKILL.md-only fallback when the skill does carry an artifact. Any read or
154
+ * parse failure counts as stale — the remount is the safe default.
155
+ */
156
+ async function mountIsFresh(skillDir: string, versionHash: string, wantsArtifact: boolean): Promise<boolean> {
157
+ try {
158
+ const raw = await readFile(join(skillDir, MOUNT_MARKER_FILE), "utf-8");
159
+ const marker = JSON.parse(raw) as Partial<MountMarker>;
160
+ return marker.versionHash === versionHash && (marker.artifactMounted === true || !wantsArtifact);
161
+ } catch {
162
+ return false;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Download a skill artifact's ZIP bytes, transfer lane first (#675).
168
+ *
169
+ * The URL lane (getArtifactDownloadUrl → HTTP GET) carries any valid skill
170
+ * size; the unary getArtifact response is capped by the server's 10MB gRPC
171
+ * message limit. Servers that predate the lane (and cloud until its sibling
172
+ * lands) answer the mint with UNIMPLEMENTED — those fall back to the unary
173
+ * path, which behaves exactly as before for ≤10MB artifacts.
174
+ *
175
+ * Runs only on a mount-cache miss (#672's hash-keyed marker above) — a hit
176
+ * skips the transfer entirely, whichever lane would have carried it.
177
+ *
178
+ * Exported for tests.
179
+ */
180
+ export async function downloadArtifact(
181
+ client: StigmerClient,
182
+ artifactStorageKey: string,
183
+ ): Promise<Uint8Array | undefined> {
184
+ let minted;
185
+ try {
186
+ minted = await client.getSkillArtifactDownloadUrl(artifactStorageKey);
187
+ } catch (err) {
188
+ if (err instanceof ConnectError && err.code === Code.Unimplemented) {
189
+ const resp = await client.getSkillArtifact(artifactStorageKey);
190
+ return resp.artifact && resp.artifact.length > 0 ? resp.artifact : undefined;
191
+ }
192
+ throw err;
193
+ }
194
+
195
+ const resp = await fetch(minted.url);
196
+ if (!resp.ok) {
197
+ throw new Error(`artifact fetch failed: HTTP ${resp.status} from ${minted.url}`);
198
+ }
199
+ const bytes = new Uint8Array(await resp.arrayBuffer());
200
+ if (minted.sizeBytes > 0n && BigInt(bytes.length) !== minted.sizeBytes) {
201
+ throw new Error(
202
+ `artifact fetch truncated: got ${bytes.length} bytes, expected ${minted.sizeBytes}`,
203
+ );
204
+ }
205
+ return bytes.length > 0 ? bytes : undefined;
206
+ }
207
+
208
+ /**
209
+ * (Re)write a skill's mount directory from scratch.
210
+ *
211
+ * The directory is removed first so files deleted between versions don't
212
+ * linger in the mount, then SKILL.md and the artifact files are written, and
213
+ * the marker is stamped LAST (see MOUNT_MARKER_FILE for the crash-safety
214
+ * contract). SKILL.md always comes from `spec.skillMd` — the server's
215
+ * authoritative copy — never from the zip; both the zip's SKILL.md and any
216
+ * stray marker-named entry are excluded from extraction so the mount's
217
+ * ownership of those two files is unconditional.
218
+ */
219
+ async function writeSkillMount(
102
220
  skill: Skill,
103
- skillsDir: string,
104
- workspaceDir: string,
105
- artifactBytes?: Uint8Array,
106
- ): Promise<SkillMetadata | null> {
107
- const spec = skill.spec;
108
- if (!spec?.skillMd) return null;
109
-
110
- const name = spec.name || skill.metadata?.slug || "unknown";
111
- const skillDir = join(skillsDir, name);
221
+ skillDir: string,
222
+ artifactBytes: Uint8Array | undefined,
223
+ ): Promise<void> {
224
+ await rm(skillDir, { recursive: true, force: true });
112
225
  await mkdir(skillDir, { recursive: true });
113
226
 
114
- const skillMdPath = join(skillDir, "SKILL.md");
115
- await writeFile(skillMdPath, spec.skillMd, "utf-8");
227
+ await writeFile(join(skillDir, "SKILL.md"), skill.spec!.skillMd, "utf-8");
116
228
 
117
- if (artifactBytes && artifactBytes.length > 0) {
118
- const entries = await extractZipFileEntries(artifactBytes, { exclude: ["SKILL.md"] });
229
+ const artifactMounted = artifactBytes !== undefined && artifactBytes.length > 0;
230
+ if (artifactMounted) {
231
+ const entries = await extractZipFileEntries(artifactBytes, { exclude: ["SKILL.md", MOUNT_MARKER_FILE] });
119
232
  for (const entry of entries) {
120
233
  const filePath = join(skillDir, entry.path);
121
234
  await mkdir(dirname(filePath), { recursive: true });
@@ -123,13 +236,11 @@ async function writeSkill(
123
236
  }
124
237
  }
125
238
 
126
- const relativePath = join(STIGMER_LOCAL_STATE_DIR, SKILLS_SUBDIR, name, "SKILL.md");
127
-
128
- return {
129
- name,
130
- description: spec.description || `Skill: ${name}`,
131
- path: relativePath,
132
- };
239
+ const versionHash = skill.status?.versionHash ?? "";
240
+ if (versionHash !== "") {
241
+ const marker: MountMarker = { versionHash, artifactMounted };
242
+ await writeFile(join(skillDir, MOUNT_MARKER_FILE), JSON.stringify(marker), "utf-8");
243
+ }
133
244
  }
134
245
 
135
246
  /**
@@ -50,11 +50,20 @@ export type ScriptSelector = (boundToolNames: string[]) => ScriptStep;
50
50
  */
51
51
  export class ScriptedModel extends BaseChatModel {
52
52
  toolNames: string[] = [];
53
+ /**
54
+ * The tool objects from the most recent `bindTools` call, exactly as the
55
+ * agent bound them (post-middleware). Lets tests assert on the bound
56
+ * SCHEMAS — e.g. the tool-intent middleware's bind-time shell clone — not
57
+ * just the names. The array is shared across the clones `bindTools`
58
+ * returns, so the instance the test holds always sees the latest bind.
59
+ */
60
+ readonly boundTools: unknown[];
53
61
  private readonly select: ScriptSelector;
54
62
 
55
- constructor(select: ScriptSelector) {
63
+ constructor(select: ScriptSelector, boundTools: unknown[] = []) {
56
64
  super({});
57
65
  this.select = select;
66
+ this.boundTools = boundTools;
58
67
  }
59
68
 
60
69
  _llmType(): string {
@@ -62,8 +71,10 @@ export class ScriptedModel extends BaseChatModel {
62
71
  }
63
72
 
64
73
  bindTools(tools: unknown[]): this {
65
- const next = new ScriptedModel(this.select);
74
+ const next = new ScriptedModel(this.select, this.boundTools);
66
75
  next.toolNames = (tools as Array<{ name?: string }>).map((t) => t?.name ?? "");
76
+ this.boundTools.length = 0;
77
+ this.boundTools.push(...tools);
67
78
  return next as unknown as this;
68
79
  }
69
80
 
@@ -7,11 +7,12 @@ describe("buildSubAgentMiddleware", () => {
7
7
  it("returns the correct middleware order without cost cap", () => {
8
8
  const stack = buildSubAgentMiddleware();
9
9
 
10
- expect(stack).toHaveLength(4);
10
+ expect(stack).toHaveLength(5);
11
11
  expect(stack[0].name).toBe("LoopDetectionMiddleware");
12
12
  expect(stack[1].name).toBe("ExecutionBudgetMiddleware");
13
- expect(stack[2].name).toBe("ToolTruncationMiddleware");
14
- expect(stack[3].name).toBe("ErrorHintsMiddleware");
13
+ expect(stack[2].name).toBe("StigmerToolIntentMiddleware");
14
+ expect(stack[3].name).toBe("ToolTruncationMiddleware");
15
+ expect(stack[4].name).toBe("ErrorHintsMiddleware");
15
16
  });
16
17
 
17
18
  it("includes cost cap view when parent cost cap is provided", () => {
@@ -25,9 +26,9 @@ describe("buildSubAgentMiddleware", () => {
25
26
 
26
27
  const stack = buildSubAgentMiddleware({ costCap: parentCostCap });
27
28
 
28
- expect(stack).toHaveLength(5);
29
- expect(stack[3].name).toBe("CostCapSubAgentView");
30
- expect(stack[4].name).toBe("ErrorHintsMiddleware");
29
+ expect(stack).toHaveLength(6);
30
+ expect(stack[4].name).toBe("CostCapSubAgentView");
31
+ expect(stack[5].name).toBe("ErrorHintsMiddleware");
31
32
  });
32
33
 
33
34
  it("sub-agent cost cap view shares parent state", () => {
@@ -43,7 +44,7 @@ describe("buildSubAgentMiddleware", () => {
43
44
 
44
45
  expect(parentCostCap.runningCost).toBe(0);
45
46
 
46
- const subView = stack[3];
47
+ const subView = stack[4];
47
48
  expect(subView.afterModel).toBeDefined();
48
49
  expect(subView.wrapToolCall).toBeDefined();
49
50
  expect(subView.beforeAgent).toBeUndefined();
@@ -69,7 +70,7 @@ describe("buildSubAgentMiddleware", () => {
69
70
  toolTruncation: { maxChars: 5000 },
70
71
  });
71
72
 
72
- expect(stack[2].name).toBe("ToolTruncationMiddleware");
73
+ expect(stack[3].name).toBe("ToolTruncationMiddleware");
73
74
  });
74
75
 
75
76
  it("installs the approval gate when an approvalGate config is provided", () => {
@@ -77,16 +78,16 @@ describe("buildSubAgentMiddleware", () => {
77
78
  approvalGate: { policies: new Map(), toolServerMap: new Map() },
78
79
  });
79
80
 
80
- // loop, budget, truncation, approval gate, error hints
81
- expect(stack).toHaveLength(5);
82
- expect(stack[3].name).toBe("ApprovalGateMiddleware");
83
- expect(stack[3].wrapToolCall).toBeDefined();
81
+ // loop, budget, tool intent, truncation, approval gate, error hints
82
+ expect(stack).toHaveLength(6);
83
+ expect(stack[4].name).toBe("ApprovalGateMiddleware");
84
+ expect(stack[4].wrapToolCall).toBeDefined();
84
85
  });
85
86
 
86
87
  it("omits the approval gate when approvalGate is null (auto-approve-all parity)", () => {
87
88
  const stack = buildSubAgentMiddleware({ approvalGate: null });
88
89
 
89
- expect(stack).toHaveLength(4);
90
+ expect(stack).toHaveLength(5);
90
91
  expect(stack.some((m) => m.name === "ApprovalGateMiddleware")).toBe(false);
91
92
  });
92
93
 
@@ -104,13 +105,13 @@ describe("buildSubAgentMiddleware", () => {
104
105
  approvalGate: { policies: new Map(), toolServerMap: new Map() },
105
106
  });
106
107
 
107
- // loop, budget, truncation, approval gate, cost cap view, error hints.
108
- // Hints AFTER the gate matches the parent nesting: the gate's HITL
109
- // interrupt stays outside the hints' try/catch (issue #255).
110
- expect(stack).toHaveLength(6);
111
- expect(stack[3].name).toBe("ApprovalGateMiddleware");
112
- expect(stack[4].name).toBe("CostCapSubAgentView");
113
- expect(stack[5].name).toBe("ErrorHintsMiddleware");
108
+ // loop, budget, tool intent, truncation, approval gate, cost cap view,
109
+ // error hints. Hints AFTER the gate matches the parent nesting: the
110
+ // gate's HITL interrupt stays outside the hints' try/catch (issue #255).
111
+ expect(stack).toHaveLength(7);
112
+ expect(stack[4].name).toBe("ApprovalGateMiddleware");
113
+ expect(stack[5].name).toBe("CostCapSubAgentView");
114
+ expect(stack[6].name).toBe("ErrorHintsMiddleware");
114
115
  });
115
116
 
116
117
  // captureIgnored is the structural coupling that makes sub-agent gitignored
@@ -21,7 +21,9 @@
21
21
  *
22
22
  * deepagents mutates only via `write` (create/overwrite) and `edit` (modify);
23
23
  * there is no backend delete/rename, so this observes CREATE and MODIFY. A
24
- * gitignored delete can only arrive via shell, which stays on the approval gate.
24
+ * delete-category tool consequently cannot be observed here the approval gate
25
+ * records its before-bytes at authorization time instead (`captureDeleteBefore`,
26
+ * issue #303). A delete via shell (`rm`) stays on the approval gate as always.
25
27
  *
26
28
  * @since File-Change HITL Redesign (Phase 3 — CAS deep-agent wiring); sub-agent
27
29
  * gitignored capture parity (Session 26, DD-19); shell restore (issue #248)
@@ -676,6 +676,14 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
676
676
  isCapturablePath,
677
677
  captureIgnored: captureMode && !!artifactStorage,
678
678
  recordBlockedSecret: (rawPath: string) => casObserver.recordBlockedSecret(rawPath),
679
+ // Pre-delete byte capture (issue #303): deepagents has no backend
680
+ // delete method for the CAS backend to observe, so a flowing delete's
681
+ // before-bytes are recorded by the gate at authorization time, through
682
+ // the SAME shared observer — the turn boundary then reads after=null
683
+ // and authors a reviewable, restorable DELETE. recordBefore's own
684
+ // ownership predicate (gitignored-only in a git tree, everything in
685
+ // non-git) agrees with the gate's not-capturable condition.
686
+ captureDeleteBefore: (rawPath: string) => casObserver.recordBefore(rawPath),
679
687
  unattended,
680
688
  unattendedSkips,
681
689
  }
@@ -9,6 +9,9 @@
9
9
  * same `permissions` option it bakes into the graph, keeping the rules
10
10
  * and their normalization shim coupled.
11
11
  * - Fresh loop detection (independent cycle tracking)
12
+ * - Tool intent (issue #276) — the shell tool's bind-time schema gains the
13
+ * optional model-authored `description`, so sub-agent shell rows carry
14
+ * intent titles exactly like the parent's
12
15
  * - Fresh tool truncation (same limits as parent)
13
16
  * - Periodic execution budget (interval=30, max=4 advisories)
14
17
  * - Approval gate (so a mutating tool *inside* a sub-agent is gated, not
@@ -43,6 +46,7 @@ import { createPathNormalizationMiddleware } from "../../middleware/path-normali
43
46
  import { createLoopDetectionMiddleware } from "../../middleware/loop-detection.js";
44
47
  import { createToolTruncationMiddleware } from "../../middleware/tool-truncation.js";
45
48
  import { createExecutionBudgetMiddleware } from "../../middleware/execution-budget.js";
49
+ import { createToolIntentMiddleware } from "../../middleware/tool-intent.js";
46
50
  import {
47
51
  createApprovalGateMiddleware,
48
52
  type ApprovalGateConfig,
@@ -83,7 +87,8 @@ export interface SubAgentMiddlewareOptions {
83
87
  *
84
88
  * Returns an ordered array mirroring the parent composition:
85
89
  * [path normalization] → loop detection → execution budget (periodic) →
86
- * tool truncation → [approval gate] → cost cap view → error hints.
90
+ * tool intent → tool truncation → [approval gate] → cost cap view →
91
+ * error hints.
87
92
  * Normalization is outermost so everything downstream observes canonical
88
93
  * workspace-absolute paths (matching the parent). The gate sits before the
89
94
  * cost-cap view so an approval pause happens before budget accounting, and
@@ -107,22 +112,33 @@ export function buildSubAgentMiddleware(
107
112
  maxWarnings: SUB_AGENT_MAX_ADVISORIES,
108
113
  }));
109
114
 
115
+ // Sub-agent shell rows render in the same thread as the parent's and must
116
+ // carry the same model-authored intent titles (issue #276).
117
+ stack.push(createToolIntentMiddleware());
118
+
110
119
  stack.push(createToolTruncationMiddleware(options.toolTruncation));
111
120
 
112
121
  if (options.approvalGate) {
113
- // captureIgnored (DD-19): a sub-agent flows gitignored writes into CAS iff a
114
- // CAS observer backs its filesystem backend (compileSubagents passes this as
122
+ // captureIgnored (DD-19): a sub-agent flows gitignored writes and, since
123
+ // issue #303, non-secret CAS-owned deletes into CAS iff a CAS observer
124
+ // backs its filesystem backend (compileSubagents passes this as
115
125
  // `!!casObserver`). When true, inherit the parent gate verbatim so its
116
- // captureIgnored + recordBlockedSecret feed the SAME shared observer that
117
- // backs the sub-agent's writes. When false (default; non-capture mode, or no
118
- // observer), force CAS routing off and drop the blocked-secret sink so
119
- // gitignored paths stay on the interrupt gate — a flowed gitignored edit on an
120
- // unobserved backend would apply unobserved, unreviewable bytes. Sub-agent
121
- // git-tracked edits are always captured by the backend-agnostic boundary diff.
126
+ // captureIgnored + recordBlockedSecret + captureDeleteBefore feed the SAME
127
+ // shared observer that backs the sub-agent's writes. When false (default;
128
+ // non-capture mode, or no observer), force CAS routing off and drop both
129
+ // observer sinks so gitignored paths stay on the interrupt gate — a flowed
130
+ // gitignored edit on an unobserved backend would apply unobserved,
131
+ // unreviewable bytes. Sub-agent git-tracked edits are always captured by
132
+ // the backend-agnostic boundary diff.
122
133
  stack.push(createApprovalGateMiddleware(
123
134
  options.captureIgnored
124
135
  ? options.approvalGate
125
- : { ...options.approvalGate, captureIgnored: false, recordBlockedSecret: undefined },
136
+ : {
137
+ ...options.approvalGate,
138
+ captureIgnored: false,
139
+ recordBlockedSecret: undefined,
140
+ captureDeleteBefore: undefined,
141
+ },
126
142
  ));
127
143
  }
128
144