@gajae-code/coding-agent 0.11.9 → 0.11.10

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 (59) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/dist/types/config/model-registry.d.ts +2 -0
  3. package/dist/types/gjc-runtime/state-writer.d.ts +1 -0
  4. package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
  5. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  6. package/dist/types/sdk/bus/index.d.ts +8 -1
  7. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
  8. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -7
  9. package/dist/types/sdk/bus/telegram-daemon.d.ts +74 -5
  10. package/dist/types/sdk/session.d.ts +7 -0
  11. package/dist/types/session/agent-session.d.ts +2 -0
  12. package/dist/types/session/internal/managed-session-scope.d.ts +1 -1
  13. package/dist/types/task/provider-retry-status.d.ts +13 -0
  14. package/dist/types/task/types.d.ts +3 -0
  15. package/dist/types/tools/index.d.ts +3 -1
  16. package/package.json +8 -8
  17. package/scripts/generate-sdk-operation-inventory.ts +1 -0
  18. package/src/cli/notify-cli.ts +1 -0
  19. package/src/config/model-profiles.ts +7 -7
  20. package/src/config/model-registry.ts +4 -2
  21. package/src/eval/js/tool-bridge.ts +1 -1
  22. package/src/gjc-runtime/deep-interview-draft.ts +2 -1
  23. package/src/gjc-runtime/deep-interview-recorder.ts +16 -1
  24. package/src/gjc-runtime/state-writer.ts +1 -1
  25. package/src/hooks/skill-state.ts +23 -1
  26. package/src/internal-urls/docs-index.generated.ts +3 -3
  27. package/src/modes/controllers/selector-controller.ts +38 -8
  28. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  29. package/src/prompts/agents/architect.md +8 -1
  30. package/src/prompts/agents/critic.md +8 -1
  31. package/src/prompts/agents/planner.md +8 -1
  32. package/src/sdk/broker/identity.ts +22 -12
  33. package/src/sdk/bus/daemon-paths.ts +2 -0
  34. package/src/sdk/bus/index.ts +60 -5
  35. package/src/sdk/bus/notification-service.ts +19 -5
  36. package/src/sdk/bus/operator-runtime.ts +31 -5
  37. package/src/sdk/bus/telegram-cli.ts +6 -13
  38. package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
  39. package/src/sdk/bus/telegram-daemon-contract.ts +13 -7
  40. package/src/sdk/bus/telegram-daemon-control.ts +14 -5
  41. package/src/sdk/bus/telegram-daemon.ts +667 -118
  42. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  43. package/src/sdk/session.ts +12 -0
  44. package/src/session/agent-session.ts +54 -10
  45. package/src/session/internal/managed-session-scope.ts +133 -117
  46. package/src/skill-state/workflow-mutation-guard.ts +41 -7
  47. package/src/task/executor.ts +141 -39
  48. package/src/task/provider-retry-status.ts +88 -0
  49. package/src/task/render.ts +45 -26
  50. package/src/task/types.ts +3 -0
  51. package/src/tools/bash-allowed-prefixes.ts +45 -1
  52. package/src/tools/index.ts +3 -1
  53. package/src/tools/subagent-render.ts +74 -16
  54. package/src/tools/subagent.ts +2 -0
  55. package/vendor/insane-search/MANIFEST.json +4 -1
  56. package/vendor/insane-search/engine/bias_check.py +5 -0
  57. package/vendor/insane-search/engine/fetch_chain.py +67 -1
  58. package/vendor/insane-search/engine/transport.py +13 -2
  59. package/vendor/insane-search/engine/validators.py +4 -1
@@ -2659,6 +2659,17 @@
2659
2659
  "testIds": "not_applicable"
2660
2660
  }
2661
2661
  },
2662
+ {
2663
+ "sourceId": "agent_session:getToolForExecution",
2664
+ "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
2665
+ "sourceKind": "agent_session",
2666
+ "decision": "exclude",
2667
+ "rationale": "internal accessor/plumbing, not a user-facing control seam",
2668
+ "exclusionMetadata": {
2669
+ "adapterMappings": "not_applicable",
2670
+ "testIds": "not_applicable"
2671
+ }
2672
+ },
2662
2673
  {
2663
2674
  "sourceId": "agent_session:registerForegroundBashBackgroundRequestHandler",
2664
2675
  "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
@@ -428,6 +428,13 @@ export interface CreateAgentSessionOptions {
428
428
  goalToolAllowedOps?: readonly ("create" | "get" | "complete" | "resume" | "drop" | "pause")[];
429
429
  /** Optional per-session allowlist for tools exposed through search_tool_bm25. */
430
430
  discoverableToolAllowedNames?: readonly string[];
431
+ /**
432
+ * Discoverable built-in tools that must stay in the initial active set even when
433
+ * `tools.discoveryMode === "all"` would otherwise hide them behind `search_tool_bm25`.
434
+ * Used for coordination tools (e.g. `irc`) that a subagent must be able to use
435
+ * immediately without first spending a discovery round-trip to find them.
436
+ */
437
+ alwaysActiveToolNames?: readonly string[];
431
438
  /** Optional shared agent registry for IRC routing. Default: AgentRegistry.global(). */
432
439
  agentRegistry?: AgentRegistry;
433
440
  /** Parent task ID prefix for nested artifact naming (e.g., "6-Extensions") */
@@ -1542,6 +1549,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1542
1549
  goalToolAllowedOps: options.goalToolAllowedOps,
1543
1550
  discoverableToolAllowedNames: options.discoverableToolAllowedNames,
1544
1551
  getToolByName: name => session?.getToolByName(name),
1552
+ getToolForExecution: name => session?.getToolForExecution(name),
1545
1553
  agentRegistry,
1546
1554
  getSessionSpawns: () => options.spawns ?? "*",
1547
1555
  getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined),
@@ -2329,6 +2337,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2329
2337
  // from the initial set unless they were explicitly requested or restored from persistence.
2330
2338
  // The model finds them via search_tool_bm25 and activates them on demand.
2331
2339
  if (effectiveDiscoveryMode === "all") {
2340
+ const alwaysActiveDiscoveredBuiltinNames = new Set(
2341
+ (options.alwaysActiveToolNames ?? []).map(name => name.toLowerCase()),
2342
+ );
2332
2343
  const essentialBuiltinNames = new Set(computeEssentialBuiltinNames(settings));
2333
2344
  const allowedDiscoveredBuiltinNames = options.discoverableToolAllowedNames
2334
2345
  ? new Set(options.discoverableToolAllowedNames.map(name => name.toLowerCase()))
@@ -2337,6 +2348,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2337
2348
  const tool = toolRegistry.get(name);
2338
2349
  if (!tool?.loadMode) return true; // not a built-in — leave MCP/custom/extension to existing logic
2339
2350
  if (tool.loadMode === "essential") return true;
2351
+ if (alwaysActiveDiscoveredBuiltinNames.has(name)) return true;
2340
2352
  return essentialBuiltinNames.has(name);
2341
2353
  });
2342
2354
  explicitlyRequestedDiscoveredBuiltinToolNames = selectRestorableDiscoveredBuiltinToolNames(
@@ -1048,12 +1048,16 @@ function createHandoffContext(document: string): string {
1048
1048
  // ============================================================================
1049
1049
 
1050
1050
  /** Tools that require user permission before execution when an ACP client is connected. */
1051
- const PERMISSION_REQUIRED_TOOLS = new Set(["bash", "monitor", "edit", "delete", "move"]);
1051
+ const PERMISSION_REQUIRED_TOOLS = new Set(["bash", "monitor", "eval", "edit", "delete", "move"]);
1052
1052
 
1053
1053
  function isShellExecutionPermissionTool(toolName: string): boolean {
1054
1054
  return toolName === "bash" || toolName === "monitor";
1055
1055
  }
1056
1056
 
1057
+ function isExecutionPermissionTool(toolName: string): boolean {
1058
+ return isShellExecutionPermissionTool(toolName) || toolName === "eval";
1059
+ }
1060
+
1057
1061
  /** Permission options presented to the client on each gated tool call. */
1058
1062
  const PERMISSION_OPTIONS: ClientBridgePermissionOption[] = [
1059
1063
  { optionId: "allow_once", name: "Allow once", kind: "allow_once" },
@@ -1121,6 +1125,18 @@ function getPermissionIntent(
1121
1125
  const cmd = getStringProperty(a, "command")?.slice(0, 80);
1122
1126
  return { toolName, title: cmd || toolName, cacheKey: toolName };
1123
1127
  }
1128
+ if (toolName === "eval") {
1129
+ const cells = Array.isArray(a.cells) ? a.cells : [];
1130
+ const firstCell = cells.find(cell => cell && typeof cell === "object" && !Array.isArray(cell));
1131
+ const cell = firstCell as Record<string, unknown> | undefined;
1132
+ const title = cell ? getStringProperty(cell, "title") : undefined;
1133
+ const language = cell ? getStringProperty(cell, "language") : undefined;
1134
+ return {
1135
+ toolName,
1136
+ title: title ?? (language ? `Eval ${language}` : "eval"),
1137
+ cacheKey: toolName,
1138
+ };
1139
+ }
1124
1140
  if (toolName === "delete") {
1125
1141
  const p = getStringProperty(a, "path");
1126
1142
  return { toolName, title: p ? `Delete ${p}` : toolName, paths: p ? [p] : undefined, cacheKey: toolName };
@@ -5502,6 +5518,12 @@ export class AgentSession {
5502
5518
  return undefined;
5503
5519
  }
5504
5520
 
5521
+ /** Get a registered tool with the same guards used for model-facing execution. */
5522
+ getToolForExecution(name: string): AgentTool | undefined {
5523
+ const tool = this.getToolByName(name);
5524
+ return tool ? this.#prepareToolForExecution(tool) : undefined;
5525
+ }
5526
+
5505
5527
  /**
5506
5528
  * Register a UI/control-plane request handler for a currently foregrounded
5507
5529
  * managed bash execution. This is intentionally narrower than generic
@@ -5766,6 +5788,7 @@ export class AgentSession {
5766
5788
  return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
5767
5789
  }
5768
5790
  const isShellExecutionTool = isShellExecutionPermissionTool(target.name);
5791
+ const isExecutionTool = isExecutionPermissionTool(target.name);
5769
5792
  const command =
5770
5793
  isShellExecutionTool && args && typeof args === "object" && !Array.isArray(args)
5771
5794
  ? getStringProperty(args as Record<string, unknown>, "command")
@@ -5808,7 +5831,7 @@ export class AgentSession {
5808
5831
  toolCallId,
5809
5832
  toolName: target.name,
5810
5833
  title: permissionIntent.title,
5811
- ...(isShellExecutionTool ? { kind: "execute" } : {}),
5834
+ ...(isExecutionTool ? { kind: "execute" } : {}),
5812
5835
  status: "pending",
5813
5836
  rawInput: args,
5814
5837
  ...(commandContent ? { content: commandContent } : {}),
@@ -7530,7 +7553,11 @@ export class AgentSession {
7530
7553
  }
7531
7554
 
7532
7555
  // Validate API key
7533
- const apiKey = await this.#modelRegistry.getApiKey(this.model, this.sessionId);
7556
+ const apiKey = await this.#awaitPromptPreflight(
7557
+ generation,
7558
+ preflightSignal,
7559
+ this.#modelRegistry.getApiKey(this.model, this.sessionId, { signal: preflightSignal }),
7560
+ );
7534
7561
  if (!apiKey) {
7535
7562
  throw new Error(formatNoCredentialOnboardingError(this.model.provider));
7536
7563
  }
@@ -9013,6 +9040,10 @@ export class AgentSession {
9013
9040
  this.agent.reset();
9014
9041
  if (!options?.drop) await this.sessionManager.flush();
9015
9042
  await this.sessionManager.newSession(options);
9043
+ // Gate the successor local:// root before the successor identity is
9044
+ // published to the agent, the workflow-gate emitter, hooks, or any
9045
+ // synchronous resolver (#2797 / #2925).
9046
+ await initializeLocalRoot(this.#localProtocolOptions());
9016
9047
  this.setTodoPhases([]);
9017
9048
  this.#syncAgentSessionId();
9018
9049
  this.#bindWorkflowGateEmitter(previousWorkflowGateSessionId);
@@ -9080,6 +9111,10 @@ export class AgentSession {
9080
9111
  this.#rebindProviderSessionState(new Map());
9081
9112
  this.agent.reset();
9082
9113
  await this.sessionManager.newSession(options);
9114
+ // Gate the successor local:// root before the successor identity is
9115
+ // published to the agent, the workflow-gate emitter, hooks, or any
9116
+ // synchronous resolver (#2797 / #2925).
9117
+ await initializeLocalRoot(this.#localProtocolOptions());
9083
9118
  this.setTodoPhases([]);
9084
9119
  this.#syncAgentSessionId();
9085
9120
  this.#bindWorkflowGateEmitter(previousWorkflowGateSessionId);
@@ -9223,6 +9258,9 @@ export class AgentSession {
9223
9258
  return false;
9224
9259
  }
9225
9260
 
9261
+ // Fork rotates the session identity (and copies artifacts); gate the
9262
+ // successor local:// root before that identity is published (#2797 / #2925).
9263
+ await initializeLocalRoot(this.#localProtocolOptions());
9226
9264
  // Update agent session ID
9227
9265
  this.#syncAgentSessionId();
9228
9266
  this.#bindWorkflowGateEmitter(previousWorkflowGateSessionId);
@@ -10630,6 +10668,10 @@ export class AgentSession {
10630
10668
  previousSessionFile ? { parentSession: previousSessionFile } : undefined,
10631
10669
  );
10632
10670
  successorSessionFile = this.sessionFile;
10671
+ // Gate the successor local:// root inside the reversible prepare
10672
+ // window, before the successor identity is published; a gate failure
10673
+ // here rolls back (#2797 / #2925).
10674
+ await initializeLocalRoot(this.#localProtocolOptions());
10633
10675
  this.agent.reset();
10634
10676
  this.#syncAgentSessionId();
10635
10677
  this.#rekeyHindsightMemoryForCurrentSessionId();
@@ -14557,6 +14599,10 @@ export class AgentSession {
14557
14599
 
14558
14600
  try {
14559
14601
  await this.sessionManager.setSessionFile(sessionPath);
14602
+ // The successor identity is already rotated in the manager but not yet
14603
+ // published; gate its local:// root before publication so no observer
14604
+ // can resolve against an ungated root (#2797 / #2925).
14605
+ if (switchingToDifferentSession) await initializeLocalRoot(this.#localProtocolOptions());
14560
14606
  this.#syncAgentSessionId();
14561
14607
  this.#rekeyHindsightMemoryForCurrentSessionId();
14562
14608
 
@@ -14633,13 +14679,8 @@ export class AgentSession {
14633
14679
  await this.sessionManager.ensureOnDisk();
14634
14680
 
14635
14681
  if (switchingToDifferentSession) {
14636
- // Interactive /resume (and any other switchSession identity change) must
14637
- // await verified legacy local:// migration for the *new* session before
14638
- // post-commit session_switch / local path resolution. createAgentSession
14639
- // already does this at cold start (#2797); switchSession previously omitted
14640
- // it, so resolving local:// after /resume could fail-closed with
14641
- // "legacy migration must complete before path resolution" (#2925).
14642
- await initializeLocalRoot(this.#localProtocolOptions());
14682
+ // The local:// migration gate for this successor already ran above,
14683
+ // before the identity was published (#2797 / #2925).
14643
14684
  this.#resetHindsightConversationTrackingIfHindsight();
14644
14685
  this.#resetIrcRosterDeliveryState();
14645
14686
  }
@@ -14771,6 +14812,9 @@ export class AgentSession {
14771
14812
  } else {
14772
14813
  this.sessionManager.createBranchedSession(selectedEntry.parentId);
14773
14814
  }
14815
+ // Branch/tree-jump establishes a new session identity; gate the successor
14816
+ // local:// root before that identity is published (#2797 / #2925).
14817
+ await initializeLocalRoot(this.#localProtocolOptions());
14774
14818
  this.#syncTodoPhasesFromBranch();
14775
14819
  this.#syncAgentSessionId();
14776
14820
  this.#bindWorkflowGateEmitter(previousWorkflowGateSessionId);
@@ -8,7 +8,7 @@ import {
8
8
  verifyOwnerOnlyPathSecurity,
9
9
  verifyOwnerOnlyPathSecurityExpected,
10
10
  } from "@gajae-code/natives";
11
- import { pathIsWithin } from "@gajae-code/utils";
11
+ import { logger, pathIsWithin } from "@gajae-code/utils";
12
12
  import type { ResumeSessionIdentity } from "../session-manager";
13
13
  import {
14
14
  FileSessionStorage,
@@ -2789,7 +2789,10 @@ function validateCandidateForScope(scope: ManagedScope, candidate: ManagedCandid
2789
2789
  }
2790
2790
 
2791
2791
  /** Resume tombstoned cleanup under its original operation lease without restoring retired candidates. */
2792
- export async function reconcileManagedTombstones(scope: ManagedScope): Promise<void> {
2792
+ export async function reconcileManagedTombstones(
2793
+ scope: ManagedScope,
2794
+ expectedCandidate?: ManagedCandidate,
2795
+ ): Promise<void> {
2793
2796
  const directory = path.join(managedInternalDirectory(scope), MANAGED_TOMBSTONES_DIRECTORY);
2794
2797
  for (const name of fs.readdirSync(directory)) {
2795
2798
  const tombstone = path.join(directory, name);
@@ -2807,135 +2810,148 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
2807
2810
  for (const target of lockedTargets) {
2808
2811
  lock.assertOwned();
2809
2812
  if (cleanupCompleted(scope, tombstone, target)) continue;
2810
- const pending = pendingCleanupReceipt(scope, tombstone, target);
2811
- const observedPending = pending ? probePlannedCleanupDetach(target, pending) : undefined;
2812
2813
  try {
2813
- fs.lstatSync(target.path);
2814
- } catch (error) {
2815
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
2816
- if (!observedPending) continue;
2817
- if (
2818
- cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) &&
2819
- cleanupRootsAbsent(tombstone, target, observedPending)
2820
- ) {
2821
- fsyncManagedParent(target.path);
2822
- await publishCleanupCompleted(scope, tombstone, target, lock);
2823
- continue;
2824
- }
2825
- } else throw error;
2826
- }
2827
- const verified = observedPending ? target : validateCandidateForScope(scope, target);
2828
- if (!verified || !sameCandidate(verified, target)) throw new Error("source_changed");
2829
- const discoveredDetach =
2830
- !!observedPending &&
2831
- (observedPending.detachedArtifactsPath !== pending?.detachedArtifactsPath ||
2832
- observedPending.detachedTranscriptPath !== pending?.detachedTranscriptPath);
2833
- const active =
2834
- discoveredDetach || (observedPending && requiresFreshCleanupPlan(observedPending))
2835
- ? nextCleanupReceipt(target, observedPending)
2836
- : (observedPending ?? nextCleanupReceipt(target, undefined));
2837
- if (!observedPending || discoveredDetach || requiresFreshCleanupPlan(observedPending))
2838
- await publishCleanupPending(scope, tombstone, active, lock);
2839
- let deletion = await new FileSessionStorage().deleteSessionVerified({
2840
- sessionsRoot: scope.sessionsRoot,
2841
- transcriptPath: target.path,
2842
- sessionId: target.sessionId,
2843
- cwd: target.cwd,
2844
- transcriptIdentity: target.identity,
2845
- expectedArtifactsIdentity: active.expectedArtifactsIdentity,
2846
- expectedArtifactsTree: active.expectedArtifactsTree,
2847
- detachedArtifactsPath:
2848
- active.detachedArtifactsPath ??
2849
- observedPending?.detachedArtifactsPath ??
2850
- (fs.existsSync(active.plannedArtifactsPath) ? active.plannedArtifactsPath : undefined),
2851
- detachedTranscriptPath:
2852
- active.detachedTranscriptPath ??
2853
- observedPending?.detachedTranscriptPath ??
2854
- (pending && fs.existsSync(pending.plannedTranscriptPath)
2855
- ? pending.plannedTranscriptPath
2856
- : undefined) ??
2857
- (fs.existsSync(active.plannedTranscriptPath) ? active.plannedTranscriptPath : undefined),
2858
- retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
2859
- retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
2860
- retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
2861
- retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
2862
- retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
2863
- retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
2864
- plannedArtifactsPath: active.plannedArtifactsPath,
2865
- plannedTranscriptPath: active.plannedTranscriptPath,
2866
- ...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt)
2867
- ? { artifactsRemoved: true as const }
2868
- : {}),
2869
- });
2870
- if (deletion.kind === "artifacts_removed") {
2871
- await publishCleanupArtifactsRemoved(scope, tombstone, active, lock);
2872
- deletion = await new FileSessionStorage().deleteSessionVerified({
2814
+ const pending = pendingCleanupReceipt(scope, tombstone, target);
2815
+ const observedPending = pending ? probePlannedCleanupDetach(target, pending) : undefined;
2816
+ try {
2817
+ fs.lstatSync(target.path);
2818
+ } catch (error) {
2819
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
2820
+ if (!observedPending) continue;
2821
+ if (
2822
+ cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) &&
2823
+ cleanupRootsAbsent(tombstone, target, observedPending)
2824
+ ) {
2825
+ fsyncManagedParent(target.path);
2826
+ await publishCleanupCompleted(scope, tombstone, target, lock);
2827
+ continue;
2828
+ }
2829
+ } else throw error;
2830
+ }
2831
+ const verified = observedPending ? target : validateCandidateForScope(scope, target);
2832
+ if (!verified || !sameCandidate(verified, target)) throw new Error("source_changed");
2833
+ const discoveredDetach =
2834
+ !!observedPending &&
2835
+ (observedPending.detachedArtifactsPath !== pending?.detachedArtifactsPath ||
2836
+ observedPending.detachedTranscriptPath !== pending?.detachedTranscriptPath);
2837
+ const active =
2838
+ discoveredDetach || (observedPending && requiresFreshCleanupPlan(observedPending))
2839
+ ? nextCleanupReceipt(target, observedPending)
2840
+ : (observedPending ?? nextCleanupReceipt(target, undefined));
2841
+ if (!observedPending || discoveredDetach || requiresFreshCleanupPlan(observedPending))
2842
+ await publishCleanupPending(scope, tombstone, active, lock);
2843
+ let deletion = await new FileSessionStorage().deleteSessionVerified({
2873
2844
  sessionsRoot: scope.sessionsRoot,
2874
2845
  transcriptPath: target.path,
2875
2846
  sessionId: target.sessionId,
2876
2847
  cwd: target.cwd,
2877
2848
  transcriptIdentity: target.identity,
2878
- plannedArtifactsPath: active.plannedArtifactsPath,
2879
- plannedTranscriptPath: active.plannedTranscriptPath,
2880
- detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
2881
-
2849
+ expectedArtifactsIdentity: active.expectedArtifactsIdentity,
2850
+ expectedArtifactsTree: active.expectedArtifactsTree,
2851
+ detachedArtifactsPath:
2852
+ active.detachedArtifactsPath ??
2853
+ observedPending?.detachedArtifactsPath ??
2854
+ (fs.existsSync(active.plannedArtifactsPath) ? active.plannedArtifactsPath : undefined),
2855
+ detachedTranscriptPath:
2856
+ active.detachedTranscriptPath ??
2857
+ observedPending?.detachedTranscriptPath ??
2858
+ (pending && fs.existsSync(pending.plannedTranscriptPath)
2859
+ ? pending.plannedTranscriptPath
2860
+ : undefined) ??
2861
+ (fs.existsSync(active.plannedTranscriptPath) ? active.plannedTranscriptPath : undefined),
2882
2862
  retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
2883
2863
  retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
2884
2864
  retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
2885
2865
  retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
2886
2866
  retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
2887
2867
  retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
2888
- artifactsRemoved: true,
2868
+ plannedArtifactsPath: active.plannedArtifactsPath,
2869
+ plannedTranscriptPath: active.plannedTranscriptPath,
2870
+ ...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt)
2871
+ ? { artifactsRemoved: true as const }
2872
+ : {}),
2889
2873
  });
2890
- }
2891
- if (deletion.kind === "cleanup_pending") {
2892
- const retry = nextCleanupReceipt(target, active);
2893
- await publishCleanupPending(
2894
- scope,
2874
+ if (deletion.kind === "artifacts_removed") {
2875
+ await publishCleanupArtifactsRemoved(scope, tombstone, active, lock);
2876
+ deletion = await new FileSessionStorage().deleteSessionVerified({
2877
+ sessionsRoot: scope.sessionsRoot,
2878
+ transcriptPath: target.path,
2879
+ sessionId: target.sessionId,
2880
+ cwd: target.cwd,
2881
+ transcriptIdentity: target.identity,
2882
+ plannedArtifactsPath: active.plannedArtifactsPath,
2883
+ plannedTranscriptPath: active.plannedTranscriptPath,
2884
+ detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
2885
+
2886
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
2887
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
2888
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
2889
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
2890
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
2891
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
2892
+ artifactsRemoved: true,
2893
+ });
2894
+ }
2895
+ if (deletion.kind === "cleanup_pending") {
2896
+ const retry = nextCleanupReceipt(target, active);
2897
+ await publishCleanupPending(
2898
+ scope,
2899
+ tombstone,
2900
+ {
2901
+ ...retry,
2902
+ expectedArtifactsIdentity:
2903
+ deletion.phase === "artifacts"
2904
+ ? deletion.artifactsIdentity
2905
+ : active.expectedArtifactsIdentity,
2906
+ expectedArtifactsTree:
2907
+ deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree,
2908
+ detachedArtifactsPath:
2909
+ deletion.phase === "artifacts"
2910
+ ? deletion.detachedArtifactsPath
2911
+ : active.detachedArtifactsPath,
2912
+ detachedTranscriptPath:
2913
+ deletion.phase === "transcript"
2914
+ ? deletion.detachedTranscriptPath
2915
+ : active.detachedTranscriptPath,
2916
+ retainedArtifactsSuccessorPath:
2917
+ deletion.phase === "artifacts"
2918
+ ? deletion.retainedSuccessorPath
2919
+ : active.retainedArtifactsSuccessorPath,
2920
+ retainedArtifactsPlaceholderPath:
2921
+ deletion.phase === "artifacts"
2922
+ ? deletion.retainedPlaceholderPath
2923
+ : active.retainedArtifactsPlaceholderPath,
2924
+ retainedArtifactsUnknownPath:
2925
+ deletion.phase === "artifacts"
2926
+ ? deletion.retainedUnknownPath
2927
+ : active.retainedArtifactsUnknownPath,
2928
+ retainedTranscriptSuccessorPath:
2929
+ deletion.phase === "transcript"
2930
+ ? deletion.retainedSuccessorPath
2931
+ : active.retainedTranscriptSuccessorPath,
2932
+ retainedTranscriptPlaceholderPath:
2933
+ deletion.phase === "transcript"
2934
+ ? deletion.retainedPlaceholderPath
2935
+ : active.retainedTranscriptPlaceholderPath,
2936
+ retainedTranscriptUnknownPath:
2937
+ deletion.phase === "transcript"
2938
+ ? deletion.retainedUnknownPath
2939
+ : active.retainedTranscriptUnknownPath,
2940
+ },
2941
+ lock,
2942
+ );
2943
+ continue;
2944
+ }
2945
+ fsyncManagedParent(target.path);
2946
+ await publishCleanupCompleted(scope, tombstone, target, lock);
2947
+ } catch (error) {
2948
+ if (!expectedCandidate || target.sessionId === expectedCandidate.sessionId) throw error;
2949
+ logger.warn("Tombstone reconciliation failed for one target; will retry on a future scope open", {
2895
2950
  tombstone,
2896
- {
2897
- ...retry,
2898
- expectedArtifactsIdentity:
2899
- deletion.phase === "artifacts" ? deletion.artifactsIdentity : active.expectedArtifactsIdentity,
2900
- expectedArtifactsTree:
2901
- deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree,
2902
- detachedArtifactsPath:
2903
- deletion.phase === "artifacts" ? deletion.detachedArtifactsPath : active.detachedArtifactsPath,
2904
- detachedTranscriptPath:
2905
- deletion.phase === "transcript"
2906
- ? deletion.detachedTranscriptPath
2907
- : active.detachedTranscriptPath,
2908
- retainedArtifactsSuccessorPath:
2909
- deletion.phase === "artifacts"
2910
- ? deletion.retainedSuccessorPath
2911
- : active.retainedArtifactsSuccessorPath,
2912
- retainedArtifactsPlaceholderPath:
2913
- deletion.phase === "artifacts"
2914
- ? deletion.retainedPlaceholderPath
2915
- : active.retainedArtifactsPlaceholderPath,
2916
- retainedArtifactsUnknownPath:
2917
- deletion.phase === "artifacts"
2918
- ? deletion.retainedUnknownPath
2919
- : active.retainedArtifactsUnknownPath,
2920
- retainedTranscriptSuccessorPath:
2921
- deletion.phase === "transcript"
2922
- ? deletion.retainedSuccessorPath
2923
- : active.retainedTranscriptSuccessorPath,
2924
- retainedTranscriptPlaceholderPath:
2925
- deletion.phase === "transcript"
2926
- ? deletion.retainedPlaceholderPath
2927
- : active.retainedTranscriptPlaceholderPath,
2928
- retainedTranscriptUnknownPath:
2929
- deletion.phase === "transcript"
2930
- ? deletion.retainedUnknownPath
2931
- : active.retainedTranscriptUnknownPath,
2932
- },
2933
- lock,
2934
- );
2935
- continue;
2951
+ sessionId: target.sessionId,
2952
+ error: String(error),
2953
+ });
2936
2954
  }
2937
- fsyncManagedParent(target.path);
2938
- await publishCleanupCompleted(scope, tombstone, target, lock);
2939
2955
  }
2940
2956
  } finally {
2941
2957
  if (lock) await lock.release().catch(() => undefined);
@@ -2962,7 +2978,7 @@ export async function prepareManagedSessionScopeForWrite(
2962
2978
  ensureManagedDirectory(path.join(internal, MANAGED_LOCKS_DIRECTORY), root, policy);
2963
2979
  ensureManagedDirectory(path.join(internal, MANAGED_RECEIPTS_DIRECTORY), root, policy);
2964
2980
  ensureManagedDirectory(path.join(internal, MANAGED_TOMBSTONES_DIRECTORY), root, policy);
2965
- await reconcileManagedTombstones(scope);
2981
+ await reconcileManagedTombstones(scope, expectedCandidate);
2966
2982
  return { kind: "resolved", scope };
2967
2983
  } catch (error) {
2968
2984
  const publication = error instanceof ManagedPublishError ? error : undefined;
@@ -35,17 +35,28 @@ function planningPhaseBlockMessage(skill: CanonicalGjcWorkflowSkill): string {
35
35
  }
36
36
 
37
37
  const BLOCKED_TOOL_NAMES = new Set(["edit", "write", "ast_edit", "bash"]);
38
+ /**
39
+ * Only `/dev/null` is exempt. `/dev/stdout`, `/dev/stderr`, and `/dev/fd/<n>` are descriptor
40
+ * aliases: `exec 1<>src/product.ts; printf x >/dev/stdout` reaches a real repository file
41
+ * through a rebound descriptor, so they must stay blocked.
42
+ */
43
+ const DEVICE_SINK_PATHS = new Set(["/dev/null"]);
44
+ /** Bash write forms the plain `>`/`>>` scanner misses: clobber, dup-to-path, and read-write open. */
45
+ const BASH_EXTENDED_WRITE_RE = /(?:>\||<>|>&(?![\s;&|]*(?:\d+|-)(?:[\s;&|]|$)))\s*([^\s;&|]+)/g;
46
+ /** Descriptor rebinding is not statically resolvable; any `exec` redirection forces a fail-closed verdict. */
47
+ const BASH_EXEC_REDIRECT_RE = /(?:^|[;&|\n])\s*(?:sudo\s+)?exec\b[^;&|\n]*[<>]/i;
48
+
38
49
  const ARCHIVE_OR_SQLITE_BASE_RE = /^(.+?\.(?:tar\.gz|sqlite3|sqlite|db3|zip|tgz|tar|db))(?:$|:)/i;
39
50
  const INTERNAL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
40
51
  const VIM_FILE_SWITCH_RE = /^\s*:(?:e|e!|edit|edit!)(?:\s+([^<\r\n]+))?(?:<CR>|\r|\n|$)/i;
41
52
  const BASH_MUTATION_COMMAND_RE =
42
- /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:tee|touch|rm|mkdir|cp|mv|install|truncate)\b([^;&|\n]*)|(?:^|[^<>])(?:>>?|\d>>?)\s*([^\s;&|]+)/gi;
53
+ /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:[^\s;&|]*\/)?(?:tee|touch|rm|mkdir|cp|mv|install|truncate)\b([^;&|\n]*)|(?:^|[^<>])(?:>>?|\d>>?)\s*([^\s;&|]+)/gi;
43
54
  const BASH_IN_PLACE_MUTATION_COMMAND_RE = /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:sed|perl)\b([^;&|\n]*)/gi;
44
55
  const BASH_OPAQUE_INTERPRETER_WRITE_RE =
45
56
  /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:python3?|node|ruby)\b[^;&|\n]*(?:-c|-e)\b[^;&|\n]*(?:open\s*\(|writeFile(?:Sync)?\s*\(|\.write\s*\()/i;
46
57
  const BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE =
47
58
  /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:python3?|node|ruby)\b[^;&|\n]*(?:<<[-]?\s*['"]?\w+['"]?)[\s\S]*(?:open\s*\(|writeFile(?:Sync)?\s*\(|\.write\s*\()/i;
48
- const BASH_DD_OUTPUT_RE = /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?dd\b([^;&|\n]*)/gi;
59
+ const BASH_DD_OUTPUT_RE = /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:[^\s;&|]*\/)?dd\b([^;&|\n]*)/gi;
49
60
 
50
61
  type ToolWithEditMode = AgentTool & {
51
62
  mode?: unknown;
@@ -371,6 +382,10 @@ function cleanShellWord(value: string): string {
371
382
  return value.replace(/^['"]|['"]$/g, "");
372
383
  }
373
384
 
385
+ function isDeviceSinkPath(value: string): boolean {
386
+ return DEVICE_SINK_PATHS.has(cleanShellWord(value));
387
+ }
388
+
374
389
  function extractBashTargets(args: unknown): ExtractedTargets {
375
390
  const record = getRecord(args);
376
391
  const command = safeString(record?.command);
@@ -382,11 +397,25 @@ function extractBashTargets(args: unknown): ExtractedTargets {
382
397
  if (BASH_OPAQUE_INTERPRETER_WRITE_RE.test(command) || BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE.test(command)) {
383
398
  targets.unknown = true;
384
399
  }
400
+ // `exec 1<>file` rebinds a descriptor, so a later `>/dev/null` may not reach the device at all.
401
+ if (BASH_EXEC_REDIRECT_RE.test(command)) {
402
+ targets.unknown = true;
403
+ }
404
+ for (const match of command.matchAll(BASH_EXTENDED_WRITE_RE)) {
405
+ const cleaned = cleanShellWord(match[1] ?? "");
406
+ if (!cleaned) targets.unknown = true;
407
+ else if (!isDeviceSinkPath(cleaned)) addPath(targets, cleaned);
408
+ }
385
409
  for (const match of command.matchAll(BASH_DD_OUTPUT_RE)) {
386
410
  const parts = shellWords(match[1] ?? "").map(cleanShellWord);
387
- const output = parts.find(part => part.startsWith("of="));
388
- if (output) addPath(targets, output.slice(3));
389
- else targets.unknown = true;
411
+ // Every `of=` counts: GNU dd honors the last one, so `of=/dev/null of=real.ts` writes real.ts.
412
+ const outputs = parts.filter(part => part.startsWith("of="));
413
+ if (outputs.length === 0) targets.unknown = true;
414
+ for (const output of outputs) {
415
+ const outputPath = cleanShellWord(output.slice(3));
416
+ if (!outputPath) targets.unknown = true;
417
+ else if (!isDeviceSinkPath(outputPath)) addPath(targets, outputPath);
418
+ }
390
419
  }
391
420
  for (const match of command.matchAll(BASH_IN_PLACE_MUTATION_COMMAND_RE)) {
392
421
  const parts = shellWords(match[1] ?? "").map(cleanShellWord);
@@ -399,12 +428,17 @@ function extractBashTargets(args: unknown): ExtractedTargets {
399
428
  for (const match of command.matchAll(BASH_MUTATION_COMMAND_RE)) {
400
429
  const redirected = match[2]?.trim();
401
430
  if (redirected) {
402
- addPath(targets, cleanShellWord(redirected));
431
+ const cleaned = cleanShellWord(redirected);
432
+ // A capture that dequotes to nothing (e.g. `>" src.ts"`) is a truncated parse, not a safe no-op.
433
+ if (!cleaned) targets.unknown = true;
434
+ else if (!isDeviceSinkPath(cleaned)) addPath(targets, cleaned);
403
435
  continue;
404
436
  }
405
437
  const parts = shellWords(match[1] ?? "");
406
438
  const commandName = match[0]
407
- ?.match(/(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(tee|touch|rm|mkdir|cp|mv|install|truncate)\b/i)?.[1]
439
+ ?.match(
440
+ /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:[^\s;&|]*\/)?(tee|touch|rm|mkdir|cp|mv|install|truncate)\b/i,
441
+ )?.[1]
408
442
  ?.toLowerCase();
409
443
  const targetParts =
410
444
  commandName === "cp" || commandName === "mv" || commandName === "install" || commandName === "truncate"