@gajae-code/coding-agent 0.11.7 → 0.11.9

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 (213) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/dist/types/cli/args.d.ts +0 -2
  3. package/dist/types/cli.d.ts +1 -0
  4. package/dist/types/config/keybindings.d.ts +33 -3
  5. package/dist/types/config/model-profile-activation.d.ts +4 -3
  6. package/dist/types/config/model-profile-contract.d.ts +38 -0
  7. package/dist/types/config/model-profiles.d.ts +2 -3
  8. package/dist/types/config/settings-schema.d.ts +15 -2
  9. package/dist/types/edit/modes/patch.d.ts +13 -0
  10. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  11. package/dist/types/eval/py/executor.d.ts +1 -1
  12. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  13. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
  14. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
  15. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
  16. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
  17. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
  18. package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
  19. package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
  20. package/dist/types/gjc-runtime/state-writer.d.ts +44 -0
  21. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +1 -0
  22. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +24 -0
  23. package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
  24. package/dist/types/main.d.ts +8 -0
  25. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  26. package/dist/types/modes/components/tool-status-header.d.ts +3 -2
  27. package/dist/types/modes/components/welcome.d.ts +4 -0
  28. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  30. package/dist/types/modes/interactive-mode.d.ts +5 -2
  31. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  32. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  33. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  34. package/dist/types/modes/types.d.ts +3 -0
  35. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  36. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  37. package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
  38. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
  39. package/dist/types/runtime-mcp/redaction.d.ts +2 -0
  40. package/dist/types/sdk/broker/broker.d.ts +2 -0
  41. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  42. package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
  43. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  44. package/dist/types/sdk/bus/config.d.ts +2 -0
  45. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  46. package/dist/types/sdk/bus/index.d.ts +25 -0
  47. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  48. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
  49. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +15 -3
  50. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  51. package/dist/types/sdk/bus/telegram-daemon.d.ts +128 -1
  52. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  53. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  54. package/dist/types/sdk/host/host.d.ts +1 -1
  55. package/dist/types/sdk/host/query/handlers.d.ts +9 -0
  56. package/dist/types/sdk/index.d.ts +2 -0
  57. package/dist/types/sdk/prompt-status.d.ts +88 -0
  58. package/dist/types/sdk/session-directory.d.ts +1 -1
  59. package/dist/types/sdk/startup-capability.d.ts +3 -0
  60. package/dist/types/session/agent-session.d.ts +2 -0
  61. package/dist/types/session/internal/managed-session-scope.d.ts +22 -3
  62. package/dist/types/session/internal/managed-session-storage.d.ts +24 -4
  63. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  64. package/dist/types/session/session-manager.d.ts +5 -1
  65. package/dist/types/session/session-storage.d.ts +31 -0
  66. package/dist/types/skill-state/active-state.d.ts +4 -0
  67. package/dist/types/task/index.d.ts +3 -1
  68. package/dist/types/task/receipt.d.ts +2 -0
  69. package/dist/types/task/types.d.ts +94 -0
  70. package/dist/types/tools/ask.d.ts +454 -174
  71. package/dist/types/tools/image-gen.d.ts +47 -1
  72. package/dist/types/tools/index.d.ts +4 -0
  73. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  74. package/package.json +8 -8
  75. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  76. package/scripts/dogfood-repository-binding.ts +209 -0
  77. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  78. package/scripts/run-test-manifest.ts +4 -4
  79. package/src/cli/args.ts +2 -92
  80. package/src/cli/mcp-cli.ts +4 -21
  81. package/src/cli/notify-cli.ts +5 -38
  82. package/src/cli.ts +50 -0
  83. package/src/commands/deep-interview.ts +9 -4
  84. package/src/commands/sdk.ts +2 -2
  85. package/src/commit/agentic/index.ts +3 -3
  86. package/src/commit/map-reduce/index.ts +2 -2
  87. package/src/config/keybindings.ts +157 -24
  88. package/src/config/model-profile-activation.ts +7 -18
  89. package/src/config/model-profile-contract.ts +161 -0
  90. package/src/config/model-profiles.ts +2 -3
  91. package/src/config/model-registry.ts +11 -1
  92. package/src/config/settings-schema.ts +17 -2
  93. package/src/coordinator-mcp/model-preset.ts +21 -74
  94. package/src/defaults/gjc/skills/deep-interview/SKILL.md +80 -165
  95. package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
  96. package/src/edit/index.ts +3 -6
  97. package/src/edit/modes/patch.ts +38 -1
  98. package/src/edit/modes/replace.ts +42 -0
  99. package/src/edit/path-mutation-lock.ts +112 -0
  100. package/src/eval/py/executor.ts +30 -11
  101. package/src/eval/py/prelude.py +2 -2
  102. package/src/eval/py/tool-bridge.ts +34 -18
  103. package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
  104. package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
  105. package/src/gjc-runtime/deep-interview-draft.ts +842 -0
  106. package/src/gjc-runtime/deep-interview-payload.ts +262 -0
  107. package/src/gjc-runtime/deep-interview-recorder.ts +294 -226
  108. package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
  109. package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
  110. package/src/gjc-runtime/deep-interview-state.ts +916 -4
  111. package/src/gjc-runtime/launch-tmux.ts +1 -1
  112. package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
  113. package/src/gjc-runtime/ralplan-runtime.ts +78 -4
  114. package/src/gjc-runtime/repository-binding.ts +267 -0
  115. package/src/gjc-runtime/session-state-sidecar.ts +25 -9
  116. package/src/gjc-runtime/state-writer.ts +166 -2
  117. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
  118. package/src/gjc-runtime/tmux-sessions.ts +45 -6
  119. package/src/gjc-runtime/ultragoal-guard.ts +5 -5
  120. package/src/gjc-runtime/ultragoal-runtime.ts +181 -19
  121. package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
  122. package/src/gjc-runtime/workflow-manifest.ts +191 -5
  123. package/src/internal-urls/docs-index.generated.ts +8 -6
  124. package/src/internal-urls/local-protocol.ts +33 -16
  125. package/src/internal-urls/mcp-protocol.ts +73 -11
  126. package/src/main.ts +25 -10
  127. package/src/modes/DESIGN.md +5 -0
  128. package/src/modes/acp/acp-agent.ts +50 -16
  129. package/src/modes/components/model-selector.ts +42 -8
  130. package/src/modes/components/plan-preview-overlay.ts +1 -1
  131. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  132. package/src/modes/components/tool-status-header.ts +20 -4
  133. package/src/modes/components/welcome.ts +32 -20
  134. package/src/modes/controllers/command-controller.ts +97 -54
  135. package/src/modes/controllers/event-controller.ts +10 -0
  136. package/src/modes/controllers/input-controller.ts +7 -2
  137. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  138. package/src/modes/controllers/selector-controller.ts +46 -54
  139. package/src/modes/interactive-mode.ts +70 -36
  140. package/src/modes/prompt-action-autocomplete.ts +32 -13
  141. package/src/modes/prompt-suggestion-controller.ts +96 -0
  142. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  143. package/src/modes/types.ts +3 -0
  144. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  145. package/src/modes/utils/ui-helpers.ts +5 -3
  146. package/src/prompts/agents/architect.md +0 -1
  147. package/src/prompts/agents/critic.md +0 -1
  148. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  149. package/src/prompts/system/system-prompt.md +7 -4
  150. package/src/prompts/tools/cron.md +3 -1
  151. package/src/runtime-mcp/json-rpc.ts +7 -4
  152. package/src/runtime-mcp/manager.ts +51 -53
  153. package/src/runtime-mcp/oauth-flow.ts +0 -72
  154. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  155. package/src/runtime-mcp/redaction.ts +30 -0
  156. package/src/runtime-mcp/transports/http.ts +13 -6
  157. package/src/sdk/broker/broker.ts +34 -20
  158. package/src/sdk/broker/lifecycle.ts +185 -36
  159. package/src/sdk/broker/session-index.ts +11 -1
  160. package/src/sdk/bus/chat-daemon-control.ts +20 -5
  161. package/src/sdk/bus/config-commands.ts +2 -2
  162. package/src/sdk/bus/config.ts +9 -1
  163. package/src/sdk/bus/conversation-store.ts +40 -6
  164. package/src/sdk/bus/index.ts +240 -65
  165. package/src/sdk/bus/lifecycle-commands.ts +47 -3
  166. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  167. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  168. package/src/sdk/bus/notification-orchestration.ts +12 -0
  169. package/src/sdk/bus/notification-service.ts +27 -2
  170. package/src/sdk/bus/prompt-reconciliation.ts +239 -0
  171. package/src/sdk/bus/recent-activity.ts +90 -26
  172. package/src/sdk/bus/telegram-daemon-contract.ts +15 -3
  173. package/src/sdk/bus/telegram-daemon-control.ts +145 -33
  174. package/src/sdk/bus/telegram-daemon.ts +853 -112
  175. package/src/sdk/bus/telegram-reference.ts +20 -2
  176. package/src/sdk/cli/session-cli.ts +15 -2
  177. package/src/sdk/host/control/dispatch.ts +1 -1
  178. package/src/sdk/host/control/operations.ts +1 -1
  179. package/src/sdk/host/host.ts +2 -6
  180. package/src/sdk/host/query/handlers.ts +70 -5
  181. package/src/sdk/index.ts +12 -0
  182. package/src/sdk/prompt-status.ts +97 -0
  183. package/src/sdk/protocol/operation-inventory.generated.json +47 -0
  184. package/src/sdk/protocol/operation-registry.ts +18 -4
  185. package/src/sdk/session-directory.ts +5 -1
  186. package/src/sdk/session.ts +2 -0
  187. package/src/sdk/startup-capability.ts +9 -1
  188. package/src/session/agent-session.ts +138 -5
  189. package/src/session/blob-store.ts +7 -2
  190. package/src/session/internal/managed-session-scope.ts +998 -133
  191. package/src/session/internal/managed-session-storage.ts +268 -203
  192. package/src/session/internal/native-publish-outcome.ts +266 -0
  193. package/src/session/session-manager.ts +406 -360
  194. package/src/session/session-storage.ts +151 -11
  195. package/src/skill-state/active-state.ts +10 -0
  196. package/src/task/executor.ts +13 -5
  197. package/src/task/gjc-command.ts +2 -2
  198. package/src/task/index.ts +121 -15
  199. package/src/task/receipt.ts +3 -0
  200. package/src/task/types.ts +34 -3
  201. package/src/tools/ask.ts +199 -88
  202. package/src/tools/ast-edit.ts +2 -2
  203. package/src/tools/bash-pty-selection.ts +2 -2
  204. package/src/tools/browser/screenshot-format.ts +6 -1
  205. package/src/tools/cron.ts +1 -1
  206. package/src/tools/image-gen.ts +178 -6
  207. package/src/tools/index.ts +4 -0
  208. package/src/tools/render-utils.ts +7 -1
  209. package/src/utils/edit-mode.ts +2 -2
  210. package/src/utils/prompt-suggestion.ts +265 -0
  211. package/src/utils/sixel.ts +3 -3
  212. package/src/web/insane/url-guard.ts +3 -1
  213. package/src/web/scrapers/docs-rs.ts +30 -5
@@ -44,6 +44,7 @@ import {
44
44
  restoreLineEndings,
45
45
  stripBom,
46
46
  } from "../normalize";
47
+ import { withEditPathMutation } from "../path-mutation-lock";
47
48
  import { readEditFileText, serializeEditFileText } from "../read-file";
48
49
  import type { EditToolDetails, LspBatchRequest } from "../renderer";
49
50
  import {
@@ -93,6 +94,15 @@ export interface ApplyPatchOptions {
93
94
  fuzzyThreshold?: number;
94
95
  allowFuzzy?: boolean;
95
96
  fs?: FileSystem;
97
+ /**
98
+ * When false, skip durable cross-process file locks (in-process path mutex
99
+ * still serializes). Defaults to true only for the real `defaultFileSystem`
100
+ * (or when `fs` is omitted). Disk-backed adapters that are not
101
+ * `defaultFileSystem` (notably production `LspFileSystem` via
102
+ * `executePatchSingle`) MUST pass `crossProcessLock: true` explicitly —
103
+ * object identity is not a durable-lock capability probe.
104
+ */
105
+ crossProcessLock?: boolean;
96
106
  }
97
107
 
98
108
  // ═══════════════════════════════════════════════════════════════════════════
@@ -1409,13 +1419,29 @@ function applyHunksToContent(
1409
1419
 
1410
1420
  /**
1411
1421
  * Apply a patch operation to the filesystem.
1422
+ *
1423
+ * Concurrent mutations of the same absolute path are serialized (in-process always;
1424
+ * cross-process file lock when using the real filesystem) so disjoint concurrent
1425
+ * edits cannot silently overwrite each other (#2900).
1412
1426
  */
1413
1427
  export async function applyPatch(input: PatchInput, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
1414
- return applyNormalizedPatch(input, options);
1428
+ const resolvePath = (p: string): string => resolveToCwd(p, options.cwd);
1429
+ const absolutePath = resolvePath(input.path);
1430
+ const mutationPaths = [absolutePath];
1431
+ if (input.rename) {
1432
+ const destPath = resolvePath(input.rename);
1433
+ if (destPath !== absolutePath) mutationPaths.push(destPath);
1434
+ }
1435
+ const usingDefaultFs = options.fs === undefined || options.fs === defaultFileSystem;
1436
+ // dryRun/preview must stay read-only: never create durable `<path>.lock` dirs
1437
+ // (would fail on read-only parents and mutate the FS during preview) (#2900 review).
1438
+ const crossProcess = options.dryRun === true ? false : (options.crossProcessLock ?? usingDefaultFs);
1439
+ return withEditPathMutation(mutationPaths, () => applyNormalizedPatch(input, options), { crossProcess });
1415
1440
  }
1416
1441
 
1417
1442
  /**
1418
1443
  * Apply a normalized patch operation to the filesystem.
1444
+ * Caller must already hold path mutation rights when concurrent writers exist.
1419
1445
  * @internal
1420
1446
  */
1421
1447
  async function applyNormalizedPatch(input: PatchInput, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
@@ -1514,6 +1540,12 @@ async function applyNormalizedPatch(input: PatchInput, options: ApplyPatchOption
1514
1540
  const isMove = Boolean(input.rename) && destPath !== absolutePath;
1515
1541
 
1516
1542
  if (!dryRun) {
1543
+ // Commit-time CAS: reject if another writer mutated the file after our
1544
+ // authoritative read (e.g. a process that did not take the path lock).
1545
+ const commitContent = await readExistingPatchFile(fs, absolutePath, input.path);
1546
+ if (commitContent !== originalContent) {
1547
+ throw new ApplyPatchError(`concurrent edit conflict: file changed since read: ${input.path}`);
1548
+ }
1517
1549
  if (isMove) {
1518
1550
  const parentDir = path.dirname(destPath);
1519
1551
  if (parentDir && parentDir !== ".") {
@@ -1735,11 +1767,16 @@ export async function executePatchSingle(
1735
1767
 
1736
1768
  const input: PatchInput = { path: resolvedPath, op, rename: resolvedRename, diff };
1737
1769
  const patchFileSystem = new LspFileSystem(writethrough, signal, batchRequest, beginDeferredDiagnosticsForPath);
1770
+ // Production edit path uses LspFileSystem (disk-backed writethrough), which is
1771
+ // not `defaultFileSystem` by object identity. Force durable cross-process
1772
+ // locking so multi-process agents cannot silently race past the path mutex
1773
+ // (#2900 review: do not infer lock capability from FileSystem identity).
1738
1774
  const result = await applyPatch(input, {
1739
1775
  cwd: session.cwd,
1740
1776
  fs: patchFileSystem,
1741
1777
  fuzzyThreshold,
1742
1778
  allowFuzzy,
1779
+ crossProcessLock: true,
1743
1780
  });
1744
1781
 
1745
1782
  // Post-write verification: only meaningful for in-place updates where the
@@ -55,6 +55,7 @@ void import("@gajae-code/natives")
55
55
  // Native unavailable; fuzzy matching uses the TS fallback.
56
56
  });
57
57
 
58
+ import { withEditPathMutation } from "../path-mutation-lock";
58
59
  import { readEditFileText, serializeEditFileText } from "../read-file";
59
60
  import type { EditToolDetails, LspBatchRequest } from "../renderer";
60
61
 
@@ -1087,6 +1088,47 @@ export async function executeReplaceSingle(
1087
1088
  }
1088
1089
 
1089
1090
  const absolutePath = resolvePlanPath(session, path);
1091
+ return withEditPathMutation([absolutePath], () =>
1092
+ executeReplaceSingleUnderLock({
1093
+ session,
1094
+ path,
1095
+ params,
1096
+ signal,
1097
+ batchRequest,
1098
+ allowFuzzy,
1099
+ fuzzyThreshold,
1100
+ writethrough,
1101
+ beginDeferredDiagnosticsForPath,
1102
+ absolutePath,
1103
+ old_text,
1104
+ new_text,
1105
+ all,
1106
+ }),
1107
+ );
1108
+ }
1109
+
1110
+ async function executeReplaceSingleUnderLock(
1111
+ options: ExecuteReplaceSingleOptions & {
1112
+ absolutePath: string;
1113
+ old_text: string;
1114
+ new_text: string;
1115
+ all: boolean | undefined;
1116
+ },
1117
+ ): Promise<AgentToolResult<EditToolDetails, typeof replaceEditEntrySchema>> {
1118
+ const {
1119
+ path,
1120
+ signal,
1121
+ batchRequest,
1122
+ allowFuzzy,
1123
+ fuzzyThreshold,
1124
+ writethrough,
1125
+ beginDeferredDiagnosticsForPath,
1126
+ absolutePath,
1127
+ old_text,
1128
+ new_text,
1129
+ all,
1130
+ } = options;
1131
+
1090
1132
  const rawContent = await readEditFileText(absolutePath, path);
1091
1133
  const { bom, text: content } = stripBom(rawContent);
1092
1134
  const originalEnding = detectLineEnding(content);
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Path-scoped mutation coordinator for edit tools.
3
+ *
4
+ * Serializes concurrent read→compute→write windows against the same absolute
5
+ * path so independent sessions cannot silently overwrite each other's successful
6
+ * disjoint edits (https://github.com/Yeachan-Heo/gajae-code/issues/2900).
7
+ *
8
+ * - Always: in-process async mutex keyed by resolved absolute path.
9
+ * - Optionally: durable cross-process `<path>.lock` via `withFileLock` for real
10
+ * filesystem mutations (subagents / separate processes).
11
+ *
12
+ * Nested acquisition of the same path is not supported; callers that already
13
+ * hold the lock must not re-enter.
14
+ */
15
+ import * as path from "node:path";
16
+ import { type FileLockOptions, withFileLock } from "../config/file-lock";
17
+
18
+ type AsyncMutex = {
19
+ acquire(): Promise<() => void>;
20
+ };
21
+
22
+ const pathMutexes = new Map<string, AsyncMutex>();
23
+
24
+ function createAsyncMutex(): AsyncMutex {
25
+ let locked = false;
26
+ const waiters: Array<() => void> = [];
27
+ return {
28
+ async acquire(): Promise<() => void> {
29
+ if (!locked) {
30
+ locked = true;
31
+ return () => release();
32
+ }
33
+ const { promise, resolve } = Promise.withResolvers<void>();
34
+ waiters.push(resolve);
35
+ await promise;
36
+ return () => release();
37
+ },
38
+ };
39
+
40
+ function release(): void {
41
+ const next = waiters.shift();
42
+ if (next) {
43
+ next();
44
+ return;
45
+ }
46
+ locked = false;
47
+ }
48
+ }
49
+
50
+ function mutexFor(absolutePath: string): AsyncMutex {
51
+ const key = path.resolve(absolutePath);
52
+ let mutex = pathMutexes.get(key);
53
+ if (!mutex) {
54
+ mutex = createAsyncMutex();
55
+ pathMutexes.set(key, mutex);
56
+ }
57
+ return mutex;
58
+ }
59
+
60
+ async function withInProcessPathLock<T>(absolutePath: string, fn: () => Promise<T>): Promise<T> {
61
+ const release = await mutexFor(absolutePath).acquire();
62
+ try {
63
+ return await fn();
64
+ } finally {
65
+ release();
66
+ }
67
+ }
68
+
69
+ /** Default lock budget: long enough for large writes + formatter writeback. */
70
+ const DEFAULT_CROSS_PROCESS_LOCK: FileLockOptions = {
71
+ staleMs: 60_000,
72
+ retries: 600,
73
+ retryDelayMs: 50,
74
+ };
75
+
76
+ export type EditPathMutationOptions = {
77
+ /**
78
+ * When true (default), also acquire a durable cross-process file lock.
79
+ * Disable for injectible/in-memory filesystem tests that do not touch disk.
80
+ */
81
+ crossProcess?: boolean;
82
+ fileLock?: FileLockOptions;
83
+ };
84
+
85
+ /**
86
+ * Run `fn` while holding exclusive mutation rights for every absolute path.
87
+ * Paths are locked in lexicographic order to avoid deadlocks on multi-path ops
88
+ * (e.g. rename source + destination).
89
+ */
90
+ export async function withEditPathMutation<T>(
91
+ absolutePaths: readonly string[],
92
+ fn: () => Promise<T>,
93
+ options: EditPathMutationOptions = {},
94
+ ): Promise<T> {
95
+ const unique = [...new Set(absolutePaths.map(entry => path.resolve(entry)))].sort();
96
+ if (unique.length === 0) return fn();
97
+
98
+ const crossProcess = options.crossProcess !== false;
99
+ const fileLock = { ...DEFAULT_CROSS_PROCESS_LOCK, ...options.fileLock };
100
+
101
+ const runAt = async (index: number): Promise<T> => {
102
+ if (index >= unique.length) return fn();
103
+ const target = unique[index];
104
+ const next = () => runAt(index + 1);
105
+ return withInProcessPathLock(target, async () => {
106
+ if (!crossProcess) return next();
107
+ return withFileLock(target, next, fileLock);
108
+ });
109
+ };
110
+
111
+ return runAt(0);
112
+ }
@@ -61,7 +61,7 @@ export interface PythonExecutorOptions {
61
61
  /** @internal Bridge session id, set by `executePython` before delegating. */
62
62
  bridgeSessionId?: string;
63
63
  /** @internal Bridge endpoint info, set by `executePython` before delegating. */
64
- bridge?: { url: string; token: string };
64
+ bridge?: { url: string; capability: string };
65
65
  }
66
66
 
67
67
  export interface PythonKernelExecutor {
@@ -106,6 +106,7 @@ export interface PythonResult {
106
106
  interface PythonSession {
107
107
  sessionId: string;
108
108
  kernel: PythonKernel;
109
+ bridgeCapability?: string;
109
110
  ownerIds: Set<string>;
110
111
  hasFallbackOwner: boolean;
111
112
  queue: Promise<void>;
@@ -261,14 +262,14 @@ function buildKernelEnv(options: {
261
262
  sessionFile?: string;
262
263
  artifactsDir?: string;
263
264
  bridgeSessionId?: string;
264
- bridge?: { url: string; token: string };
265
+ bridge?: { url: string; capability: string };
265
266
  }): Record<string, string> | undefined {
266
267
  const env: Record<string, string> = {};
267
268
  if (options.sessionFile) env.PI_SESSION_FILE = options.sessionFile;
268
269
  if (options.artifactsDir) env.PI_ARTIFACTS_DIR = options.artifactsDir;
269
270
  if (options.bridge && options.bridgeSessionId) {
270
271
  env.PI_TOOL_BRIDGE_URL = options.bridge.url;
271
- env.PI_TOOL_BRIDGE_TOKEN = options.bridge.token;
272
+ env.PI_TOOL_BRIDGE_CAPABILITY = options.bridge.capability;
272
273
  env.PI_TOOL_BRIDGE_SESSION = options.bridgeSessionId;
273
274
  }
274
275
  return Object.keys(env).length > 0 ? env : undefined;
@@ -328,6 +329,7 @@ async function acquireSession(sessionId: string, cwd: string, options: PythonExe
328
329
  const session: PythonSession = {
329
330
  sessionId,
330
331
  kernel,
332
+ bridgeCapability: options.bridge?.capability,
331
333
  ownerIds: new Set(),
332
334
  hasFallbackOwner: false,
333
335
  queue: Promise.resolve(),
@@ -361,12 +363,25 @@ async function replaceSessionKernel(
361
363
  throw new PythonExecutionCancelledError(false);
362
364
  }
363
365
  requireRemainingTimeoutMs(options.deadlineMs);
364
- const next = await startKernel(cwd, options);
365
- if (sessions.get(session.sessionId) !== session) {
366
- await next.shutdown().catch(() => undefined);
367
- throw new PythonExecutionCancelledError(false);
366
+ const bridge = options.bridge;
367
+ const previousCapability = bridge?.capability;
368
+ const nextCapability = bridge ? crypto.randomUUID() : undefined;
369
+ if (bridge && nextCapability) bridge.capability = nextCapability;
370
+ let next: PythonKernel | undefined;
371
+ try {
372
+ next = await startKernel(cwd, options);
373
+ if (sessions.get(session.sessionId) !== session) {
374
+ throw new PythonExecutionCancelledError(false);
375
+ }
376
+ session.kernel = next;
377
+ session.bridgeCapability = nextCapability;
378
+ } catch (err) {
379
+ await next?.shutdown().catch(() => undefined);
380
+ if (bridge && previousCapability && bridge.capability === nextCapability) {
381
+ bridge.capability = previousCapability;
382
+ }
383
+ throw err;
368
384
  }
369
- session.kernel = next;
370
385
  }
371
386
 
372
387
  async function resetSession(sessionId: string): Promise<void> {
@@ -481,8 +496,8 @@ async function executeWithKernel(
481
496
  displayOutputs.push({ type: "status", event });
482
497
  });
483
498
  const unregisterBridge =
484
- options?.toolSession && options?.bridgeSessionId
485
- ? registerPyToolBridge(options.bridgeSessionId, {
499
+ options?.toolSession && options?.bridgeSessionId && options.bridge
500
+ ? registerPyToolBridge(options.bridgeSessionId, options.bridge.capability, {
486
501
  toolSession: options.toolSession,
487
502
  signal: options.signal,
488
503
  emitStatus,
@@ -578,7 +593,8 @@ async function ensureKernelAvailable(cwd: string, options: PythonExecutorOptions
578
593
  async function ensureToolBridge(options: PythonExecutorOptions): Promise<void> {
579
594
  if (!options.toolSession || options.bridge) return;
580
595
  try {
581
- options.bridge = await ensurePyToolBridge();
596
+ const bridge = await ensurePyToolBridge();
597
+ options.bridge = { ...bridge, capability: crypto.randomUUID() };
582
598
  } catch (err) {
583
599
  logger.warn("Failed to start Python tool bridge", {
584
600
  error: err instanceof Error ? err.message : String(err),
@@ -607,6 +623,9 @@ async function executeOnSession(code: string, cwd: string, options: PythonExecut
607
623
  await resetSession(sessionId);
608
624
  }
609
625
  const session = await acquireSession(sessionId, cwd, options);
626
+ if (options.bridge && session.bridgeCapability) {
627
+ options.bridge.capability = session.bridgeCapability;
628
+ }
610
629
  return await runQueued(session, options, async () => {
611
630
  if (options.signal?.aborted) {
612
631
  throw new PythonExecutionCancelledError(isTimedOutCancellation(options.signal.reason, options.signal));
@@ -453,10 +453,10 @@ if "__gjc_prelude_loaded__" not in globals():
453
453
 
454
454
  if all(
455
455
  _k in os.environ
456
- for _k in ("PI_TOOL_BRIDGE_URL", "PI_TOOL_BRIDGE_TOKEN", "PI_TOOL_BRIDGE_SESSION")
456
+ for _k in ("PI_TOOL_BRIDGE_URL", "PI_TOOL_BRIDGE_CAPABILITY", "PI_TOOL_BRIDGE_SESSION")
457
457
  ):
458
458
  tool = _ToolProxy(
459
459
  os.environ["PI_TOOL_BRIDGE_URL"],
460
- os.environ["PI_TOOL_BRIDGE_TOKEN"],
460
+ os.environ["PI_TOOL_BRIDGE_CAPABILITY"],
461
461
  os.environ["PI_TOOL_BRIDGE_SESSION"],
462
462
  )
@@ -19,7 +19,10 @@ export interface PyToolBridgeEntry {
19
19
 
20
20
  export interface PyToolBridgeInfo {
21
21
  url: string;
22
- token: string;
22
+ }
23
+
24
+ interface PyToolBridgeRegistration extends PyToolBridgeEntry {
25
+ sessionId: string;
23
26
  }
24
27
 
25
28
  interface BridgeServer {
@@ -27,11 +30,20 @@ interface BridgeServer {
27
30
  stop: () => Promise<void>;
28
31
  }
29
32
 
30
- const registrations = new Map<string, PyToolBridgeEntry>();
33
+ const registrations = new Map<string, PyToolBridgeRegistration>();
31
34
  let serverPromise: Promise<BridgeServer> | null = null;
32
35
 
36
+ function isCanonicalCapability(capability: string): boolean {
37
+ return capability.length > 0 && !/\s/.test(capability);
38
+ }
39
+
40
+ function parseBearerCapability(authorization: string | null): string | null {
41
+ if (!authorization?.startsWith("Bearer ")) return null;
42
+ const capability = authorization.slice("Bearer ".length);
43
+ return isCanonicalCapability(capability) ? capability : null;
44
+ }
45
+
33
46
  async function startServer(): Promise<BridgeServer> {
34
- const token = crypto.randomUUID();
35
47
  const server = Bun.serve({
36
48
  hostname: "127.0.0.1",
37
49
  port: 0,
@@ -40,7 +52,12 @@ async function startServer(): Promise<BridgeServer> {
40
52
  if (req.method !== "POST" || url.pathname !== "/v1/tool") {
41
53
  return new Response("Not Found", { status: 404 });
42
54
  }
43
- if (req.headers.get("authorization") !== `Bearer ${token}`) {
55
+ const capability = parseBearerCapability(req.headers.get("authorization"));
56
+ if (!capability) {
57
+ return new Response("Forbidden", { status: 403 });
58
+ }
59
+ const registration = registrations.get(capability);
60
+ if (!registration) {
44
61
  return new Response("Forbidden", { status: 403 });
45
62
  }
46
63
 
@@ -55,19 +72,15 @@ async function startServer(): Promise<BridgeServer> {
55
72
  if (!sessionId || !name) {
56
73
  return Response.json({ ok: false, error: "Missing session/name" }, { status: 400 });
57
74
  }
58
- const entry = registrations.get(sessionId);
59
- if (!entry) {
60
- return Response.json(
61
- { ok: false, error: `No active Python tool bridge session: ${sessionId}` },
62
- { status: 200 },
63
- );
75
+ if (sessionId !== registration.sessionId) {
76
+ return new Response("Forbidden", { status: 403 });
64
77
  }
65
78
 
66
79
  try {
67
80
  const value = await callSessionTool(name, body.args, {
68
- session: entry.toolSession,
69
- signal: entry.signal,
70
- emitStatus: entry.emitStatus,
81
+ session: registration.toolSession,
82
+ signal: registration.signal,
83
+ emitStatus: registration.emitStatus,
71
84
  });
72
85
  return Response.json({ ok: true, value });
73
86
  } catch (err) {
@@ -81,7 +94,6 @@ async function startServer(): Promise<BridgeServer> {
81
94
 
82
95
  const info: PyToolBridgeInfo = {
83
96
  url: `http://${server.hostname}:${server.port}`,
84
- token,
85
97
  };
86
98
  logger.debug("Python tool bridge listening", { url: info.url });
87
99
 
@@ -111,11 +123,15 @@ export async function ensurePyToolBridge(): Promise<PyToolBridgeInfo> {
111
123
  * Register a tool session for the duration of one execution. The returned
112
124
  * function MUST be called to remove the entry once execution finishes.
113
125
  */
114
- export function registerPyToolBridge(sessionId: string, entry: PyToolBridgeEntry): () => void {
115
- registrations.set(sessionId, entry);
126
+ export function registerPyToolBridge(sessionId: string, capability: string, entry: PyToolBridgeEntry): () => void {
127
+ if (!isCanonicalCapability(capability)) {
128
+ throw new Error("Python tool bridge capability must be a non-empty canonical bearer token");
129
+ }
130
+ const registration = { ...entry, sessionId };
131
+ registrations.set(capability, registration);
116
132
  return () => {
117
- if (registrations.get(sessionId) === entry) {
118
- registrations.delete(sessionId);
133
+ if (registrations.get(capability) === registration) {
134
+ registrations.delete(capability);
119
135
  }
120
136
  };
121
137
  }
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
+ import { bindPluginMcpToPublicNetwork } from "../../runtime-mcp/plugin-network-boundary";
4
5
  import { loadCustomTools } from "../custom-tools/loader";
5
6
  import type { CustomTool } from "../custom-tools/types";
6
7
  import { loadEffectiveGjcPluginRegistry, registryPathForScope } from "./registry";
@@ -330,7 +331,7 @@ export async function buildPluginMcpConfigs(input: { cwd: string }): Promise<{
330
331
  // resolution path expands ${env:...}/shell templates, which would let
331
332
  // a third-party bundle exfiltrate host secrets. Plugin-bundle MCP
332
333
  // servers connect without bundle-declared headers.
333
- configs[m.name] = { type: cfg.transport, url: cfg.url };
334
+ configs[m.name] = bindPluginMcpToPublicNetwork({ type: cfg.transport, url: url.toString() });
334
335
  }
335
336
  } catch (error) {
336
337
  quarantine.push({
@@ -25,14 +25,6 @@ import {
25
25
  * (no filesystem, no state-writer, no CLI runtime) so every writer can apply it.
26
26
  */
27
27
 
28
- // =============================================================================
29
- // Weights (mirrors the Ouroboros deterministic_floor coefficients)
30
- // =============================================================================
31
-
32
- const DISPUTED_FACT_WEIGHT = 0.1;
33
- const UNSCORED_COMPONENT_WEIGHT = 0.05;
34
- const AUTO_ANSWER_DILUTION_WEIGHT = 0.05;
35
-
36
28
  const CORE_CLARITY_DIMENSIONS = ["goal", "constraints", "criteria"] as const;
37
29
 
38
30
  export interface AmbiguityFloorBreakdown {
@@ -59,64 +51,57 @@ function asArray(value: unknown): unknown[] {
59
51
  return Array.isArray(value) ? value : [];
60
52
  }
61
53
 
62
- function round2(value: number): number {
63
- return Math.round(value * 100) / 100;
64
- }
65
-
66
- /** A disputed fact contributes pressure until it is resolved via `superseded_by` or re-confirmation. */
54
+ /** A disputed fact contributes pressure until it is resolved or superseded. */
67
55
  function isUnresolvedDisputedFact(value: unknown): boolean {
68
56
  if (!isPlainObject(value)) return false;
69
- const fact = value as Partial<DeepInterviewEstablishedFact>;
70
- if (fact.disputed !== true) return false;
57
+ const fact = value as Partial<DeepInterviewEstablishedFact> & { status?: unknown };
58
+ if (fact.disputed !== true && fact.status !== "disputed") return false;
71
59
  return typeof fact.superseded_by !== "string" || fact.superseded_by.trim() === "";
72
60
  }
73
61
 
74
62
  /**
75
63
  * An active component is unscored while any core clarity dimension lacks a finite
76
- * numeric score. Deferred components are excluded (they are outside ambiguity math),
77
- * and the gate only arms once the topology was explicitly confirmed in Round 0.
64
+ * numeric score. Deferred and inactive components are outside ambiguity math.
78
65
  */
79
66
  function countUnscoredActiveComponents(topology: unknown): number {
80
67
  if (!isPlainObject(topology) || topology.status !== "confirmed") return 0;
81
68
  let unscored = 0;
82
69
  for (const component of asArray(topology.components)) {
83
- if (!isPlainObject(component) || component.status === "deferred") continue;
84
- const clarity = isPlainObject(component.clarity_scores) ? component.clarity_scores : {};
85
- const incomplete = CORE_CLARITY_DIMENSIONS.some(dimension => {
86
- const score = clarity[dimension];
87
- return typeof score !== "number" || !Number.isFinite(score);
88
- });
89
- if (incomplete) unscored += 1;
70
+ if (!isPlainObject(component) || component.status === "deferred" || component.active === false) continue;
71
+ const scores = isPlainObject(component.scores)
72
+ ? component.scores
73
+ : isPlainObject(component.clarity_scores)
74
+ ? component.clarity_scores
75
+ : {};
76
+ if (
77
+ CORE_CLARITY_DIMENSIONS.some(dimension => {
78
+ const score = scores[dimension];
79
+ return typeof score !== "number" || !Number.isFinite(score);
80
+ })
81
+ ) {
82
+ unscored += 1;
83
+ }
90
84
  }
91
85
  return unscored;
92
86
  }
93
87
 
94
- function autoAnswerRatio(inner: Record<string, unknown>): number {
95
- const autoAnswered = asArray(inner.auto_answered_rounds).length;
96
- if (autoAnswered === 0) return 0;
97
- const scored = asArray(inner.rounds).filter(round => isPlainObject(round) && round.lifecycle === "scored").length;
98
- return Math.min(1, autoAnswered / Math.max(scored, 1));
99
- }
100
-
101
- // =============================================================================
102
- // Floor computation + clamping
103
- // =============================================================================
104
-
105
- /** Compute the deterministic floor from the inner deep-interview `state` object. */
88
+ /** Compute the deterministic floor from integer units of persisted evidence. */
106
89
  export function computeAmbiguityFloor(inner: unknown): AmbiguityFloorBreakdown {
107
90
  const state = isPlainObject(inner) ? inner : {};
108
91
  const disputedFactCount = asArray(state.established_facts).filter(isUnresolvedDisputedFact).length;
109
92
  const unscoredActiveComponentCount = countUnscoredActiveComponents(state.topology);
110
- const ratio = autoAnswerRatio(state);
111
- const floor =
112
- DISPUTED_FACT_WEIGHT * disputedFactCount +
113
- UNSCORED_COMPONENT_WEIGHT * unscoredActiveComponentCount +
114
- AUTO_ANSWER_DILUTION_WEIGHT * ratio;
93
+ const scored = asArray(state.rounds).filter(round => isPlainObject(round) && round.lifecycle === "scored").length;
94
+ const autoAnswered = new Set(asArray(state.auto_answered_rounds).map(value => JSON.stringify(value))).size;
95
+ const ratioUnits = scored === 0 ? 0 : Math.min(10_000, Math.floor((autoAnswered * 10_000) / scored));
96
+ const floorUnits = Math.min(
97
+ 10_000,
98
+ disputedFactCount * 1_000 + unscoredActiveComponentCount * 500 + Math.floor(ratioUnits / 20),
99
+ );
115
100
  return {
116
- floor: round2(Math.min(1, Math.max(0, floor))),
101
+ floor: floorUnits / 10_000,
117
102
  disputed_fact_count: disputedFactCount,
118
103
  unscored_active_component_count: unscoredActiveComponentCount,
119
- auto_answer_ratio: round2(ratio),
104
+ auto_answer_ratio: ratioUnits / 10_000,
120
105
  };
121
106
  }
122
107
 
@@ -128,10 +113,8 @@ export function clampReportedAmbiguity(reported: number, floor: number): Ambigui
128
113
  }
129
114
 
130
115
  /**
131
- * Mark every established fact recorded in `retractedRound` as disputed. This is the
132
- * mechanical contradiction signal for answer retraction: when the user replaces an
133
- * already-scored answer (A -> B pivot), the facts that answer established can no
134
- * longer be trusted, so they raise the floor until re-confirmed or superseded.
116
+ * Legacy pure utility for migrations and explicit repair flows. The recorder does
117
+ * not dispute facts when an answer is retracted: scored evidence is immutable.
135
118
  * Returns new arrays/objects; never mutates the input.
136
119
  */
137
120
  export function disputeFactsFromRetractedRound(
@@ -204,3 +187,41 @@ export function applyAmbiguityFloorToEnvelope(value: unknown): AppliedAmbiguityF
204
187
  inner.ambiguity_floor = breakdown;
205
188
  return { envelope: { ...envelope, state: inner }, breakdown, clamped };
206
189
  }
190
+ export type DeepInterviewAmbiguityMilestone = "initial" | "progress" | "refined" | "ready";
191
+
192
+ export function deriveAmbiguityMilestone(
193
+ effectiveUnits: number,
194
+ thresholdUnits: number,
195
+ ): DeepInterviewAmbiguityMilestone {
196
+ if (!Number.isSafeInteger(effectiveUnits) || effectiveUnits < 0 || effectiveUnits > 10_000) {
197
+ throw new RangeError("effective ambiguity units must be an integer from 0 to 10000");
198
+ }
199
+ if (!Number.isSafeInteger(thresholdUnits) || thresholdUnits < 1 || thresholdUnits > 10_000) {
200
+ throw new RangeError("threshold units must be an integer from 1 to 10000");
201
+ }
202
+ if (effectiveUnits <= thresholdUnits) return "ready";
203
+ if (effectiveUnits > 6_000) return "initial";
204
+ if (effectiveUnits > 3_000) return "progress";
205
+ return "refined";
206
+ }
207
+
208
+ export function scoreToUnits(score: number): number {
209
+ if (!Number.isFinite(score) || score < 0 || score > 1) throw new RangeError("score must be finite in [0, 1]");
210
+ const units = score * 10_000;
211
+ if (!Number.isSafeInteger(units)) throw new RangeError("score must be expressed in integral 1e-4 units");
212
+ return units;
213
+ }
214
+
215
+ export function weightedAmbiguityUnits(
216
+ scores: { goal: number; constraints: number; criteria: number; context?: number },
217
+ type: "greenfield" | "brownfield",
218
+ ): number {
219
+ const goal = scoreToUnits(scores.goal);
220
+ const constraints = scoreToUnits(scores.constraints);
221
+ const criteria = scoreToUnits(scores.criteria);
222
+ const numerator =
223
+ type === "brownfield"
224
+ ? 35 * goal + 25 * constraints + 25 * criteria + 15 * scoreToUnits(scores.context ?? Number.NaN)
225
+ : 40 * goal + 30 * constraints + 30 * criteria;
226
+ return 10_000 - Math.floor(numerator / 100 + 0.5);
227
+ }