@mrclrchtr/supi-consulting 11.1.0 → 11.3.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.
@@ -0,0 +1,53 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ // The generated name is deliberately excluded: it prevents workspace-name collisions,
6
+ // but is not part of the saved conversation's behavior or compatibility contract.
7
+ const DEFINITION = `description: Bounded read-only consultations with observed evidence.
8
+ mainAgent: true
9
+ subagent: false
10
+ tools:
11
+ - search_web
12
+ - read_url_content
13
+ - view_file
14
+ - list_dir
15
+ - find_by_name
16
+ - grep_search
17
+ - finish
18
+ ---
19
+ Provide bounded, read-only consultations using the available inspection tools.
20
+ Do not run commands, modify files, automate browsers, or delegate work.
21
+ For web research, use search_web to discover sources and read_url_content to verify relevant pages before citing them. Report limitations instead of claiming unperformed reads.
22
+ Return workspace evidence as workspace-relative paths; omit references outside the selected workspace.
23
+ Complete each request with finish using the current output schema.
24
+ `;
25
+
26
+ /** Compatibility marker for the static profile, not an attestation from Antigravity. */
27
+ export const INSPECTION_AGENT_FINGERPRINT = createHash("sha256").update(DEFINITION).digest("hex");
28
+
29
+ /**
30
+ * Create an exclusively owned profile for a new conversation. Antigravity retains
31
+ * its definition in conversation state, so callers remove it after the process ends.
32
+ * Keep this out of general home preparation: discovery and follow-ups need no profile.
33
+ */
34
+ export async function createInspectionAgent(
35
+ homeDir: string,
36
+ ): Promise<{ name: string; dispose: () => Promise<void> }> {
37
+ const parent = join(homeDir, ".gemini", "config", "agents");
38
+ await mkdir(parent, { recursive: true, mode: 0o700 });
39
+ const name = `supi-consulting-${randomUUID()}`;
40
+ const directory = join(parent, name);
41
+ await mkdir(directory, { mode: 0o700 });
42
+ const dispose = () => rm(directory, { recursive: true, force: true });
43
+ try {
44
+ await writeFile(join(directory, "agent.md"), `---\nname: ${name}\n${DEFINITION}`, {
45
+ mode: 0o600,
46
+ flag: "wx",
47
+ });
48
+ } catch (error) {
49
+ await dispose();
50
+ throw error;
51
+ }
52
+ return { name, dispose };
53
+ }
@@ -67,13 +67,7 @@ export const INSPECTION_PERMISSION_SET = Object.freeze({
67
67
  allowNonWorkspaceAccess: false,
68
68
  permissions: Object.freeze({
69
69
  allow: Object.freeze(["read_url(*)"]),
70
- deny: Object.freeze([
71
- "write_file(*)",
72
- "command(*)",
73
- "unsandboxed(*)",
74
- "mcp(*)",
75
- "execute_url(*)",
76
- ]),
70
+ deny: Object.freeze(["write_file(*)", "command(*)", "mcp(*)", "execute_url(*)"]),
77
71
  }),
78
72
  });
79
73
 
@@ -171,24 +171,42 @@ export function extractUrl(value: unknown): string | undefined {
171
171
  );
172
172
  }
173
173
 
174
- export function extractWorkspacePath(value: unknown): string | undefined {
175
- return readBoundedField(value, [
176
- "path",
177
- "file",
178
- "file_path",
179
- "relative_path",
180
- "directory_path",
181
- "search_path",
182
- "search_directory",
183
- "absolute_path",
184
- "directory",
185
- ]);
174
+ const WORKSPACE_PATH_FIELDS = [
175
+ "path",
176
+ "file",
177
+ "file_path",
178
+ "relative_path",
179
+ "directory_path",
180
+ "search_path",
181
+ "search_directory",
182
+ "absolute_path",
183
+ "directory",
184
+ ] as const;
185
+
186
+ /** Distinguish an omitted path parameter from an explicitly supplied but invalid target. */
187
+ export function structuredWorkspacePath(value: unknown): { value: unknown } | undefined {
188
+ if (!isRecord(value)) return undefined;
189
+ for (const name of WORKSPACE_PATH_FIELDS) {
190
+ for (const [key, field] of Object.entries(value)) {
191
+ if (normalizeFieldName(key) === normalizeFieldName(name)) return { value: field };
192
+ }
193
+ }
194
+ return undefined;
195
+ }
196
+
197
+ /** Read a structured path; requested-target diagnostics preserve significant filename whitespace. */
198
+ export function extractWorkspacePath(
199
+ value: unknown,
200
+ options: { preserveWhitespace?: boolean } = {},
201
+ ): string | undefined {
202
+ return readBoundedField(value, WORKSPACE_PATH_FIELDS, () => true, options.preserveWhitespace);
186
203
  }
187
204
 
188
205
  function readBoundedField(
189
206
  value: unknown,
190
207
  names: readonly string[],
191
208
  predicate: (candidate: string) => boolean = () => true,
209
+ preserveWhitespace = false,
192
210
  ): string | undefined {
193
211
  if (!isRecord(value)) return undefined;
194
212
  const fields = Object.entries(value);
@@ -196,13 +214,20 @@ function readBoundedField(
196
214
  const normalizedName = normalizeFieldName(name);
197
215
  for (const [key, field] of fields) {
198
216
  if (normalizeFieldName(key) !== normalizedName) continue;
199
- const candidate = safeString(field, 4_096);
217
+ const candidate = boundedFieldText(field, preserveWhitespace);
200
218
  if (candidate && predicate(candidate)) return candidate;
201
219
  }
202
220
  }
203
221
  return undefined;
204
222
  }
205
223
 
224
+ function boundedFieldText(value: unknown, preserveWhitespace: boolean): string | undefined {
225
+ if (preserveWhitespace && typeof value === "string") {
226
+ return value.length > 0 && value.length <= 4_096 ? value : undefined;
227
+ }
228
+ return safeString(value, 4_096);
229
+ }
230
+
206
231
  function normalizeFieldName(value: string): string {
207
232
  return value.toLowerCase().replace(/[-_]/g, "");
208
233
  }
@@ -246,6 +271,7 @@ function selectAnswerFields(value: Record<string, unknown>): Record<string, unkn
246
271
  answer: value.answer,
247
272
  sources: value.sources,
248
273
  workspaceEvidence: value.workspaceEvidence,
274
+ ...(Object.hasOwn(value, "responseToken") ? { responseToken: value.responseToken } : {}),
249
275
  };
250
276
  }
251
277
 
@@ -1,6 +1,7 @@
1
1
  import { isAbsolute, normalize as normalizePath, relative } from "node:path";
2
2
  import { hashEvidence } from "../../../evidence-hash.ts";
3
3
  import type { ConsultationUsage } from "../../../types.ts";
4
+ import type { ConsultingDeniedOperation, ConsultingFailureDiagnostics } from "../../errors.ts";
4
5
  import { isWebToolName, isWorkspaceToolName } from "../activity.ts";
5
6
  import { validateAntigravityAnswer } from "../structured-output.ts";
6
7
  import type { AntigravityExecutionFacts } from "../types.ts";
@@ -13,20 +14,34 @@ import {
13
14
  extractUrl,
14
15
  extractWorkspacePath,
15
16
  isErrorStatus,
16
- isRecord,
17
- isSuccessStatus,
18
17
  isTerminalEvent,
19
18
  isToolResultEvent,
20
19
  isToolStartEvent,
21
- safeString,
22
20
  toolInput,
23
21
  toolName,
24
22
  } from "./event-values.ts";
25
- import { normalizeProtocolEvent } from "./protocol.ts";
23
+ import {
24
+ classifyDeniedOperation,
25
+ classifyToolResult,
26
+ type DiagnosticReadScope,
27
+ FileResources,
28
+ type FileTargetObservation,
29
+ fileTargetUpdate,
30
+ isAgentInternalFileActivity,
31
+ isPermissionDenial,
32
+ terminalDeniedOperations,
33
+ } from "./permission-diagnostics.ts";
34
+ import {
35
+ hasExplicitSuccess,
36
+ isNonToolStepUpdate,
37
+ isSuccessfulActivity,
38
+ nestedToolEvents,
39
+ normalizeProtocolEvent,
40
+ } from "./protocol.ts";
26
41
  import { AntigravityResponseError } from "./response-error.ts";
27
42
  import { mergeUsage, readUsage } from "./usage.ts";
28
43
 
29
- interface PendingTool {
44
+ interface PendingTool extends FileTargetObservation {
30
45
  name: string;
31
46
  urlHash?: string;
32
47
  pathHash?: string;
@@ -34,19 +49,16 @@ interface PendingTool {
34
49
  denied?: boolean;
35
50
  }
36
51
 
37
- interface ToolResultClassification {
38
- kind: "pending" | "failure" | "success";
39
- denied: boolean;
40
- }
41
-
42
52
  const MAX_COMPLETED_TOOL_IDS = 4_096;
43
53
 
44
54
  /**
45
55
  * Reduces parsed events to bounded execution facts. Raw events and tool payloads
46
- * are not retained after each event is processed.
56
+ * are not retained; only bounded structured requested file targets survive pending calls.
47
57
  */
48
58
  export class AntigravityEventAccumulator {
49
59
  readonly #workspaceDirectory: string | undefined;
60
+ readonly #readScope: DiagnosticReadScope;
61
+ readonly #expectedResponseToken: string | undefined;
50
62
  #pendingTools = new Map<string, PendingTool>();
51
63
  #completedToolIds = new Set<string>();
52
64
  #conversationId: string | undefined;
@@ -58,12 +70,17 @@ export class AntigravityEventAccumulator {
58
70
  #toolCounts = new Map<string, number>();
59
71
  #successfulToolNames = new Set<string>();
60
72
  #permissionDenials = 0;
73
+ #permissionDenialsByOperation: Partial<Record<ConsultingDeniedOperation, number>> = {};
74
+ #deniedFileAccessOutsideReadRoots = 0;
75
+ #deniedFileResources = new FileResources();
61
76
  #unattributedPermissionDenialSeen = false;
62
77
  #sourceHashes = new Set<string>();
63
78
  #workspacePathHashes = new Set<string>();
64
79
 
65
- constructor(options: { workspaceDirectory?: string } = {}) {
80
+ constructor(options: DiagnosticReadScope & { expectedResponseToken?: string } = {}) {
66
81
  this.#workspaceDirectory = options.workspaceDirectory;
82
+ this.#readScope = options;
83
+ this.#expectedResponseToken = options.expectedResponseToken;
67
84
  }
68
85
 
69
86
  /** Consume one already-parsed event. */
@@ -74,8 +91,9 @@ export class AntigravityEventAccumulator {
74
91
  conversationId(event) ?? conversationId(normalizedEvent) ?? this.#conversationId;
75
92
  this.#usage = mergeUsage(this.#usage, mergeUsage(readUsage(event), readUsage(normalizedEvent)));
76
93
  const terminal = isTerminalEvent(normalizedEvent);
77
- const nestedTools = consumeNestedToolBlocks(this, normalizedEvent);
78
- if (nestedTools && !terminal) return;
94
+ const nestedTools = nestedToolEvents(normalizedEvent);
95
+ for (const toolEvent of nestedTools) this.consume(toolEvent);
96
+ if (nestedTools.length && !terminal) return;
79
97
 
80
98
  if (isToolStartEvent(normalizedEvent)) {
81
99
  this.#rememberToolStart(normalizedEvent);
@@ -90,27 +108,28 @@ export class AntigravityEventAccumulator {
90
108
  return;
91
109
  }
92
110
 
93
- const directName = toolName(normalizedEvent);
94
- if (directName && isSuccessfulActivity(normalizedEvent)) {
95
- this.#recordSuccessfulTool(directName, normalizedEvent);
96
- }
97
- const denied = isPermissionDenial(normalizedEvent);
98
- if (denied && !isNonToolStepUpdate(normalizedEvent)) {
99
- const counted = directName
100
- ? this.#recordPermissionDenial(directName, normalizedEvent)
101
- : this.#recordUnattributedPermissionDenial();
102
- if (counted) this.#permissionDenials += 1;
103
- }
111
+ this.#consumeActivity(normalizedEvent);
104
112
  }
105
113
 
106
114
  /** Return safe execution facts even when the response is incomplete. */
107
- get diagnostics(): {
108
- permissionDenials: number;
109
- terminalSeen: boolean;
110
- terminalSucceeded: boolean;
111
- } {
115
+ get diagnostics(): ConsultingFailureDiagnostics {
116
+ const denied = this.#deniedFileResources.snapshot;
117
+ const unfinished = new FileResources();
118
+ for (const pending of this.#pendingTools.values()) {
119
+ if (!pending.denied)
120
+ unfinished.record(classifyDeniedOperation(pending.name), pending.requestedResource);
121
+ }
122
+ const pending = unfinished.snapshot;
112
123
  return {
113
124
  permissionDenials: this.#permissionDenials,
125
+ permissionDenialsByOperation: { ...this.#permissionDenialsByOperation },
126
+ deniedFileAccessOutsideReadRoots: this.#deniedFileAccessOutsideReadRoots,
127
+ deniedResources: denied.resources,
128
+ deniedResourcesOmitted: denied.omitted,
129
+ deniedResourcePathsUnavailable: denied.unavailable,
130
+ unfinishedResources: pending.resources,
131
+ unfinishedResourcesOmitted: pending.omitted,
132
+ unfinishedResourcePathsUnavailable: pending.unavailable,
114
133
  terminalSeen: this.#terminalSeen,
115
134
  terminalSucceeded: this.#terminalSucceeded,
116
135
  };
@@ -122,8 +141,16 @@ export class AntigravityEventAccumulator {
122
141
  if (!this.#terminalSucceeded) throw new AntigravityResponseError("terminal-failed");
123
142
  if (!this.#candidate) throw new AntigravityResponseError("missing-answer");
124
143
  if (!this.#conversationId) throw new AntigravityResponseError("missing-conversation");
144
+ const { responseToken, ...candidate } = this.#candidate;
145
+ const answer = validateAntigravityAnswer(candidate);
146
+ if (
147
+ this.#expectedResponseToken !== undefined &&
148
+ responseToken !== this.#expectedResponseToken
149
+ ) {
150
+ throw new AntigravityResponseError("unbound-answer");
151
+ }
125
152
  return {
126
- answer: validateAntigravityAnswer(this.#candidate),
153
+ answer,
127
154
  conversationId: this.#conversationId,
128
155
  ...(this.#usage ? { usage: this.#usage } : {}),
129
156
  observedToolNames: [...this.#toolNames],
@@ -135,6 +162,44 @@ export class AntigravityEventAccumulator {
135
162
  };
136
163
  }
137
164
 
165
+ #consumeActivity(event: Record<string, unknown>): void {
166
+ if (isNonToolStepUpdate(event)) return;
167
+ const id = eventId(event);
168
+ if (id && this.#completedToolIds.has(id)) return;
169
+ let pending = this.#pendingTools.get(id ?? "");
170
+ const name = toolName(event) ?? pending?.name;
171
+ if (name && eventType(event) === "step_update") {
172
+ pending = this.#recordPendingToolResult(id, name, event, pending);
173
+ }
174
+ if (isPermissionDenial(event)) {
175
+ this.#observeDeniedActivity(name, event, pending);
176
+ } else if (name) {
177
+ this.#observeExplicitOutcome(name, event, pending);
178
+ }
179
+ }
180
+
181
+ #observeDeniedActivity(
182
+ name: string | undefined,
183
+ event: Record<string, unknown>,
184
+ pending?: PendingTool,
185
+ ): void {
186
+ const counted = name
187
+ ? this.#recordPermissionDenial(name, event, pending)
188
+ : this.#recordUnattributedPermissionDenial();
189
+ if (counted) this.#countPermissionDenial(name, event, pending);
190
+ if (isErrorStatus(eventStatus(event))) this.#completeToolResult(eventId(event));
191
+ }
192
+
193
+ #observeExplicitOutcome(
194
+ name: string,
195
+ event: Record<string, unknown>,
196
+ pending?: PendingTool,
197
+ ): void {
198
+ const successful = isSuccessfulActivity(event);
199
+ if (successful) this.#recordSuccessfulTool(name, event, pending);
200
+ if (successful || isErrorStatus(eventStatus(event))) this.#completeToolResult(eventId(event));
201
+ }
202
+
138
203
  #rememberToolStart(event: Record<string, unknown>): void {
139
204
  const name = toolName(event);
140
205
  if (!name) return;
@@ -152,14 +217,15 @@ export class AntigravityEventAccumulator {
152
217
  observed: true,
153
218
  ...(urlHash ? { urlHash } : {}),
154
219
  ...(pathHash ? { pathHash } : {}),
220
+ ...fileTargetUpdate(name, event, this.#readScope, previous),
155
221
  ...(denied ? { denied: true } : {}),
156
222
  },
157
223
  previous,
158
224
  );
159
225
  if (!previous) this.#recordObservedTool(name);
160
226
  if (id) this.#pendingTools.set(id, pending);
161
- if (isSuccessfulActivity(event)) this.#recordSuccessfulTool(name, event, pending);
162
- if (denied && !previous?.denied) this.#permissionDenials += 1;
227
+ if (denied && !previous?.denied) this.#countPermissionDenial(name, event, pending);
228
+ if (!denied) this.#observeExplicitOutcome(name, event, pending);
163
229
  }
164
230
 
165
231
  #consumeToolResult(event: Record<string, unknown>): void {
@@ -171,12 +237,14 @@ export class AntigravityEventAccumulator {
171
237
  if (!name) return;
172
238
  const classification = classifyToolResult(event);
173
239
  if (classification.kind === "pending") {
174
- this.#recordPendingToolResult(id, name, pending);
240
+ this.#recordPendingToolResult(id, name, event, pending);
175
241
  return;
176
242
  }
177
243
  if (id) this.#completeToolResult(id);
178
244
  if (classification.kind === "failure") {
179
- this.#recordFailedToolResult(name, pending, classification.denied);
245
+ if (classification.denied && !pending?.denied)
246
+ this.#countPermissionDenial(name, event, pending);
247
+ if (!pending?.observed) this.#recordObservedTool(name);
180
248
  return;
181
249
  }
182
250
  this.#recordSuccessfulTool(name, event, pending);
@@ -185,20 +253,41 @@ export class AntigravityEventAccumulator {
185
253
  #recordPendingToolResult(
186
254
  id: string | undefined,
187
255
  name: string,
256
+ event: Record<string, unknown>,
188
257
  pending: PendingTool | undefined,
189
- ): void {
258
+ ): PendingTool {
190
259
  if (!pending?.observed) this.#recordObservedTool(name);
191
- if (id && !pending) this.#pendingTools.set(id, { name, observed: true });
260
+ const updated = {
261
+ ...pending,
262
+ name,
263
+ observed: true,
264
+ ...fileTargetUpdate(name, event, this.#readScope, pending),
265
+ };
266
+ if (id) this.#pendingTools.set(id, updated);
267
+ return updated;
192
268
  }
193
269
 
194
- #completeToolResult(id: string): void {
270
+ #completeToolResult(id: string | undefined): void {
271
+ if (!id) return;
195
272
  this.#pendingTools.delete(id);
196
273
  this.#rememberCompletedToolId(id);
197
274
  }
198
275
 
199
- #recordFailedToolResult(name: string, pending: PendingTool | undefined, denied: boolean): void {
200
- if (denied && !pending?.denied) this.#permissionDenials += 1;
201
- if (!pending?.observed) this.#recordObservedTool(name);
276
+ #countPermissionDenial(
277
+ name: string | undefined,
278
+ event: Record<string, unknown>,
279
+ pending?: PendingTool,
280
+ ): void {
281
+ this.#permissionDenials += 1;
282
+ const operation = classifyDeniedOperation(name);
283
+ this.#permissionDenialsByOperation[operation] =
284
+ (this.#permissionDenialsByOperation[operation] ?? 0) + 1;
285
+ const started = pending ?? this.#pendingTools.get(eventId(event) ?? "");
286
+ const target = fileTargetUpdate(name, event, this.#readScope, started);
287
+ this.#deniedFileResources.record(operation, target.requestedResource);
288
+ if (target.outsideReadRoots) {
289
+ this.#deniedFileAccessOutsideReadRoots += 1;
290
+ }
202
291
  }
203
292
 
204
293
  #rememberCompletedToolId(id: string): void {
@@ -219,10 +308,14 @@ export class AntigravityEventAccumulator {
219
308
  return true;
220
309
  }
221
310
 
222
- #recordPermissionDenial(name: string, event: Record<string, unknown>): boolean {
311
+ #recordPermissionDenial(
312
+ name: string,
313
+ event: Record<string, unknown>,
314
+ observation?: PendingTool,
315
+ ): boolean {
223
316
  const id = eventId(event);
224
317
  if (id && this.#completedToolIds.has(id)) return false;
225
- const pending = id ? this.#pendingTools.get(id) : undefined;
318
+ const pending = observation ?? (id ? this.#pendingTools.get(id) : undefined);
226
319
  if (pending?.denied) return false;
227
320
  if (!pending?.observed) this.#recordObservedTool(name);
228
321
  if (id) {
@@ -234,6 +327,7 @@ export class AntigravityEventAccumulator {
234
327
 
235
328
  #recordSuccessfulTool(name: string, event: Record<string, unknown>, pending?: PendingTool): void {
236
329
  if (!pending?.observed) this.#recordObservedTool(name);
330
+ if (isAgentInternalFileActivity(name, event, this.#readScope, pending)) return;
237
331
  this.#successfulToolNames.add(name);
238
332
  const input = toolInput(event) ?? event;
239
333
  const sourceHash = pending?.urlHash ?? hashValue(extractUrl(input));
@@ -261,8 +355,27 @@ export class AntigravityEventAccumulator {
261
355
  return normalizeWorkspacePath(normalized);
262
356
  }
263
357
 
358
+ /** Terminal summaries prove an operation was denied, but supply no call IDs or targets. */
359
+ #consumeTerminalDenials(event: Record<string, unknown>): void {
360
+ const operations = terminalDeniedOperations(event);
361
+ // The generic notice may describe the same denial as the terminal summary.
362
+ // Keep a conservative lower bound without attributing it to a particular call.
363
+ if (operations.size && this.#unattributedPermissionDenialSeen) {
364
+ this.#permissionDenials -= 1;
365
+ const other = (this.#permissionDenialsByOperation.other ?? 1) - 1;
366
+ if (other) this.#permissionDenialsByOperation.other = other;
367
+ else delete this.#permissionDenialsByOperation.other;
368
+ }
369
+ for (const [operation, name] of operations) {
370
+ if (!this.#permissionDenialsByOperation[operation]) {
371
+ this.#countPermissionDenial(name, {});
372
+ }
373
+ }
374
+ }
375
+
264
376
  #consumeTerminal(event: Record<string, unknown>): void {
265
377
  this.#terminalSeen = true;
378
+ this.#consumeTerminalDenials(event);
266
379
  const status = eventStatus(event);
267
380
  if (isErrorStatus(status) || !hasExplicitSuccess(event, status)) {
268
381
  throw new AntigravityResponseError("terminal-failed");
@@ -273,94 +386,6 @@ export class AntigravityEventAccumulator {
273
386
  }
274
387
  }
275
388
 
276
- function consumeNestedToolBlocks(
277
- accumulator: AntigravityEventAccumulator,
278
- event: Record<string, unknown>,
279
- ): boolean {
280
- const message = isRecord(event.message) ? event.message : undefined;
281
- const content = message?.content;
282
- if (!Array.isArray(content)) return false;
283
- let consumed = false;
284
- for (const block of content) {
285
- if (!isRecord(block)) continue;
286
- const normalizedBlock = normalizeProtocolEvent(block);
287
- if (!isToolActivityBlock(normalizedBlock)) continue;
288
- accumulator.consume(normalizedBlock);
289
- consumed = true;
290
- }
291
- return consumed;
292
- }
293
-
294
- function isToolActivityBlock(event: Record<string, unknown>): boolean {
295
- if (isToolStartEvent(event) || isToolResultEvent(event)) return true;
296
- return eventType(event) === "step_update" && !isNonToolStepUpdate(event);
297
- }
298
-
299
- function hasExplicitSuccess(event: Record<string, unknown>, status: string | undefined): boolean {
300
- return status !== undefined
301
- ? isSuccessStatus(status)
302
- : event.is_error === false || event.isError === false || event.success === true;
303
- }
304
-
305
- function isSuccessfulActivity(event: Record<string, unknown>): boolean {
306
- const status = eventStatus(event);
307
- return (
308
- !isErrorStatus(status) &&
309
- isSuccessStatus(status) &&
310
- (status !== undefined || event.success === true || event.ok === true)
311
- );
312
- }
313
-
314
- function isNonToolStepUpdate(event: Record<string, unknown>): boolean {
315
- const type = eventType(event);
316
- if (!["assistant", "step_update"].includes(type)) return false;
317
- const declaredType = safeString(event.step_type ?? event.stepType, 40)?.toLowerCase();
318
- if (declaredType) return declaredType !== "tool";
319
- return toolName(event) === undefined && !isRecord(event.tool_info) && !isRecord(event.toolInfo);
320
- }
321
-
322
- function classifyToolResult(event: Record<string, unknown>): ToolResultClassification {
323
- const status = eventStatus(event);
324
- const denied = isPermissionDenial(event);
325
- if (denied || isErrorStatus(status)) return { kind: "failure", denied };
326
- return { kind: isSuccessStatus(status) ? "success" : "pending", denied };
327
- }
328
-
329
- function isPermissionDenial(event: Record<string, unknown>): boolean {
330
- const status = eventStatus(event);
331
- if (["denied", "permission_denied", "forbidden", "blocked"].includes(status ?? "")) return true;
332
- const type = eventType(event);
333
- if (type.includes("permission") || type.includes("denied") || type.includes("blocked"))
334
- return true;
335
- const hasExplicitFailure =
336
- event.is_error === true ||
337
- event.isError === true ||
338
- isErrorStatus(status) ||
339
- event.error !== undefined ||
340
- event.reason !== undefined;
341
- if (!hasExplicitFailure) return false;
342
- const message = [event.error, event.reason, event.message, event.content]
343
- .map((value) => safeDenialText(value))
344
- .join(" ")
345
- .toLowerCase();
346
- return message.includes("permission") || message.includes("denied");
347
- }
348
-
349
- function safeDenialText(value: unknown, depth = 0): string {
350
- if (depth > 3) return "";
351
- if (typeof value === "string") return value.slice(0, 4_000);
352
- if (Array.isArray(value)) {
353
- return value
354
- .slice(0, 20)
355
- .map((item) => safeDenialText(item, depth + 1))
356
- .join(" ");
357
- }
358
- if (!isRecord(value)) return "";
359
- return ["text", "message", "content", "error", "reason"]
360
- .map((key) => safeDenialText(value[key], depth + 1))
361
- .join(" ");
362
- }
363
-
364
389
  function mergePendingTool(current: PendingTool, previous: PendingTool | undefined): PendingTool {
365
390
  if (previous?.urlHash && !current.urlHash) current.urlHash = previous.urlHash;
366
391
  if (previous?.pathHash && !current.pathHash) current.pathHash = previous.pathHash;
@@ -14,6 +14,8 @@ export interface ProjectHookProbeResult {
14
14
  export async function probeProjectHooks(options: {
15
15
  paths: IsolatedAntigravityPaths;
16
16
  cwd: string;
17
+ /** Match the paid process's inspection roots when checking project hook state. */
18
+ additionalReadOnlyDirectories?: readonly string[];
17
19
  signal?: AbortSignal;
18
20
  onProcessStart?: () => void;
19
21
  }): Promise<ProjectHookProbeResult> {
@@ -21,7 +23,13 @@ export async function probeProjectHooks(options: {
21
23
  const result = await runAntigravityProbe({
22
24
  paths: options.paths,
23
25
  cwd: options.cwd,
24
- args: [...PROJECT_HOOK_PROBE_ARGUMENTS],
26
+ args: [
27
+ ...PROJECT_HOOK_PROBE_ARGUMENTS,
28
+ ...(options.additionalReadOnlyDirectories ?? []).flatMap((directory) => [
29
+ "--add-dir",
30
+ directory,
31
+ ]),
32
+ ],
25
33
  signal: options.signal,
26
34
  timeoutMs: 15_000,
27
35
  onProcessStart: options.onProcessStart,