@code-yeongyu/senpi 2026.11.10-2 → 2026.11.10-3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/bundle/chunks/{args-77GYOPEO.js → args-HFYB6URH.js} +1 -1
  3. package/dist/bundle/chunks/{azure-openai-responses-YFZ734T3.js → azure-openai-responses-MNN2TINI.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-ZUQCYIRY.js → chunk-2ODGHBUD.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-MSDBBOB2.js → chunk-5H43O4MA.js} +1 -1
  6. package/dist/bundle/chunks/{chunk-IKJCA5SH.js → chunk-6Q7Y2G5A.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-YCPURLPU.js → chunk-AC6MDRAE.js} +64 -64
  8. package/dist/bundle/chunks/{chunk-33MZJTQ3.js → chunk-B4WD5XHP.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-V5FSENW6.js → chunk-COKX65TE.js} +1 -1
  10. package/dist/bundle/chunks/{chunk-YNGEZBTP.js → chunk-DEQLHN2J.js} +2 -2
  11. package/dist/bundle/chunks/{chunk-EJB5E2JK.js → chunk-DHAAWL2H.js} +1 -1
  12. package/dist/bundle/chunks/{chunk-2EJ4BGBM.js → chunk-DHORIIVQ.js} +1 -1
  13. package/dist/bundle/chunks/{chunk-2M54EAAY.js → chunk-DIHIEJFV.js} +1 -1
  14. package/dist/bundle/chunks/{chunk-RCHIMH7N.js → chunk-DQY26AX3.js} +1 -1
  15. package/dist/bundle/chunks/{chunk-3Q5ZENSD.js → chunk-E6ZBTSC6.js} +1 -1
  16. package/dist/bundle/chunks/{chunk-74IB3JPP.js → chunk-H3VJVKUK.js} +4 -4
  17. package/dist/bundle/chunks/{chunk-OF3XVGG4.js → chunk-HZDKQOWB.js} +1 -1
  18. package/dist/bundle/chunks/chunk-IREUIMFE.js +11 -0
  19. package/dist/bundle/chunks/{chunk-TUEZNIBK.js → chunk-JSD6O3RJ.js} +2 -2
  20. package/dist/bundle/chunks/{chunk-UOZDDYBN.js → chunk-L3KVP7VJ.js} +1 -1
  21. package/dist/bundle/chunks/{chunk-7MN7YNO5.js → chunk-L7DGTA3L.js} +1 -1
  22. package/dist/bundle/chunks/{chunk-J2NGJKXL.js → chunk-LXINMDMX.js} +1 -1
  23. package/dist/bundle/chunks/{chunk-QATU6BBC.js → chunk-NN7NESW2.js} +1 -1
  24. package/dist/bundle/chunks/{chunk-2U6HXWXS.js → chunk-NNQ34ZDW.js} +1 -1
  25. package/dist/bundle/chunks/chunk-NQKCJQHB.js +2 -0
  26. package/dist/bundle/chunks/{chunk-XH24X5B2.js → chunk-O5WJVIEU.js} +1 -1
  27. package/dist/bundle/chunks/{chunk-O3FG6HML.js → chunk-PJI5GT7U.js} +1 -1
  28. package/dist/bundle/chunks/{chunk-BPNSOZ2C.js → chunk-QUVSIW2R.js} +1 -1
  29. package/dist/bundle/chunks/{chunk-WXYMAUFX.js → chunk-QX7WD3IM.js} +1 -1
  30. package/dist/bundle/chunks/{chunk-Y4ONXQMR.js → chunk-RGHVPUUX.js} +1 -1
  31. package/dist/bundle/chunks/{chunk-RYPM5BZQ.js → chunk-S7NAJQB3.js} +1 -1
  32. package/dist/bundle/chunks/{chunk-LT353NSF.js → chunk-T4CUD7RF.js} +1 -1
  33. package/dist/bundle/chunks/{chunk-J7FW7MED.js → chunk-TCZORL3V.js} +1 -1
  34. package/dist/bundle/chunks/{chunk-KU72IKV5.js → chunk-VGSWOLIY.js} +1 -1
  35. package/dist/bundle/chunks/{chunk-Q4G5DECT.js → chunk-Z2C5N2LM.js} +2 -2
  36. package/dist/bundle/chunks/{cli-main-Y6ALA5OI.js → cli-main-ZTT3N4DO.js} +1 -1
  37. package/dist/bundle/chunks/{credential-accounts-LVVPKFEY.js → credential-accounts-5KHVGSCY.js} +1 -1
  38. package/dist/bundle/chunks/{dispatch-EP35KKJ3.js → dispatch-YUYZQXRP.js} +1 -1
  39. package/dist/bundle/chunks/{hand-entry-IPDAIOGI.js → hand-entry-UV5XPMBF.js} +3 -3
  40. package/dist/bundle/chunks/{headless-host-HKGNJGYT.js → headless-host-53UMJMM6.js} +1 -1
  41. package/dist/bundle/chunks/{health-write-FBF74QJ7.js → health-write-JLSF5EVV.js} +1 -1
  42. package/dist/bundle/chunks/{help-fast-path-5SCPY3OH.js → help-fast-path-552QLMWT.js} +1 -1
  43. package/dist/bundle/chunks/{host-command-2326GCUP.js → host-command-BZN7WYYV.js} +1 -1
  44. package/dist/bundle/chunks/{host-session-command-BZYBDOIE.js → host-session-command-O5WYHZXH.js} +1 -1
  45. package/dist/bundle/chunks/{interactive-mode-LBQB7DDR.js → interactive-mode-OCU4NDPC.js} +1 -1
  46. package/dist/bundle/chunks/{login-health-EVXVLHB2.js → login-health-TOSQT5RG.js} +1 -1
  47. package/dist/bundle/chunks/{main-FFRFP34W.js → main-CNDP6HCF.js} +1 -1
  48. package/dist/bundle/chunks/{multi-session-host-NDIHHJ2L.js → multi-session-host-ODGJAGJP.js} +1 -1
  49. package/dist/bundle/chunks/openai-codex-responses-VRGOAFEG.js +10 -0
  50. package/dist/bundle/chunks/{openai-responses-NIVBWE2H.js → openai-responses-I7PXRWCM.js} +2 -2
  51. package/dist/bundle/chunks/{package-manager-cli-XCPAMOYI.js → package-manager-cli-HSPUG4RJ.js} +1 -1
  52. package/dist/bundle/chunks/{role-HQUN4XXK.js → role-AEOLJRTP.js} +1 -1
  53. package/dist/bundle/chunks/{rotation-stream-F3BEMXBE.js → rotation-stream-E3FJFHXJ.js} +1 -1
  54. package/dist/bundle/chunks/{rpc-mode-TKNZM4PZ.js → rpc-mode-KOZPXU3U.js} +1 -1
  55. package/dist/bundle/chunks/{run-attached-tui-JDUKXDI2.js → run-attached-tui-4AENRP7P.js} +1 -1
  56. package/dist/bundle/chunks/{schedule-command-HF2DHABB.js → schedule-command-SG44FE23.js} +1 -1
  57. package/dist/bundle/chunks/{session-control-endpoint-A6R6W2JQ.js → session-control-endpoint-5A24JYPE.js} +1 -1
  58. package/dist/bundle/chunks/{session-picker-GWA4S2PO.js → session-picker-73YRS3GE.js} +1 -1
  59. package/dist/bundle/chunks/{session-slot-CZGQQCOC.js → session-slot-RH7JLO3F.js} +1 -1
  60. package/dist/bundle/chunks/session-worker.js +139 -139
  61. package/dist/bundle/chunks/{state-store-S7QAESIF.js → state-store-IECVTYXC.js} +1 -1
  62. package/dist/bundle/cli.js +2 -2
  63. package/dist/bundle/index.js +1 -1
  64. package/dist/bundle/rpc-entry.js +1 -1
  65. package/dist/bundle/runtime-manifest.json +1 -1
  66. package/dist/core/agent-session.d.ts +12 -2
  67. package/dist/core/agent-session.js +59 -8
  68. package/dist/core/assistant-error-redaction.d.ts +9 -0
  69. package/dist/core/assistant-error-redaction.js +27 -0
  70. package/dist/core/config-reference-redaction.js +90 -10
  71. package/dist/core/context-files.d.ts +10 -0
  72. package/dist/core/context-files.js +58 -12
  73. package/dist/core/egress/wire.d.ts +13 -0
  74. package/dist/core/extensions/loader.js +24 -4
  75. package/dist/core/extensions/runner.d.ts +9 -1
  76. package/dist/core/extensions/runner.js +9 -4
  77. package/dist/core/extensions/types.d.ts +17 -3
  78. package/dist/core/hand/hook-advertise.d.ts +41 -0
  79. package/dist/core/hand/hook-advertise.js +98 -0
  80. package/dist/core/hand/hook-contract.d.ts +66 -0
  81. package/dist/core/hand/hook-contract.js +138 -0
  82. package/dist/core/resource-loader.js +2 -0
  83. package/dist/core/retry-fallback/log.d.ts +7 -1
  84. package/dist/core/retry-fallback/log.js +82 -5
  85. package/dist/core/retry-fallback/model-config-error.js +2 -1
  86. package/dist/core/settings-manager.d.ts +6 -0
  87. package/dist/core/settings-manager.js +8 -0
  88. package/dist/core/turn-origin.d.ts +19 -13
  89. package/dist/core/turn-origin.js +34 -30
  90. package/dist/core/ultrafast-lanes.d.ts +1 -0
  91. package/dist/core/ultrafast-lanes.js +4 -2
  92. package/dist/modes/interactive/components/footer.d.ts +23 -5
  93. package/dist/modes/interactive/components/footer.js +45 -16
  94. package/dist/modes/interactive/interactive-mode.js +9 -1
  95. package/dist/modes/rpc/admit-external-message-command.d.ts +2 -2
  96. package/dist/modes/rpc/admit-external-message-command.js +2 -10
  97. package/dist/modes/rpc/connection-handler.js +10 -6
  98. package/dist/modes/rpc/connection-roles.d.ts +2 -0
  99. package/dist/modes/rpc/connection-roles.js +4 -0
  100. package/dist/modes/rpc/custom-capability.d.ts +2 -0
  101. package/dist/modes/rpc/custom-capability.js +2 -0
  102. package/dist/modes/rpc/delegated-drive-origin.d.ts +6 -6
  103. package/dist/modes/rpc/delegated-drive-origin.js +7 -8
  104. package/dist/modes/rpc/delegation-declaration.d.ts +2 -0
  105. package/dist/modes/rpc/delegation-declaration.js +5 -1
  106. package/dist/modes/rpc/host-capabilities.js +3 -1
  107. package/dist/modes/rpc/loopless-tool-handlers.d.ts +6 -0
  108. package/dist/modes/rpc/loopless-tool-handlers.js +14 -1
  109. package/docs/extensions.md +10 -0
  110. package/docs/settings.md +1 -0
  111. package/docs/usage.md +1 -1
  112. package/native/socket-secret/prebuilds/win32-arm64/senpi_socket_secret.win32-arm64.node +0 -0
  113. package/native/socket-secret/prebuilds/win32-x64/senpi_socket_secret.win32-x64.node +0 -0
  114. package/package.json +6 -6
  115. package/dist/bundle/chunks/chunk-DLAY7CIU.js +0 -2
  116. package/dist/bundle/chunks/chunk-XH2MWTXA.js +0 -11
  117. package/dist/bundle/chunks/openai-codex-responses-ABB2J43U.js +0 -10
@@ -1,21 +1,101 @@
1
+ /**
2
+ * Resolved-value registry shared by every copy of this module in one JavaScript realm (omo#12965).
3
+ *
4
+ * A process can evaluate this module more than once (the bundle's shared chunk, its self-contained session-worker
5
+ * entry, and the SDK graph an in-process child loads), so the values are not kept in a module-local set: the first
6
+ * copy pins a frozen registry on `globalThis` under `SLOT_KEY` (non-writable, non-configurable, non-enumerable) and
7
+ * every later copy adopts it. The backing `Set` lives only in the installing copy's closure; the registry offers no
8
+ * delete, clear or setter, so in-realm code cannot remove a value, replace the slot or turn redaction off. Any
9
+ * in-realm code can `add` a string, which can only over-redact. This is a stopgap until one engine instance serves
10
+ * the whole process; other realms (workers, `vm` contexts, child processes) keep their own registry.
11
+ *
12
+ * Compatibility contract, permanent: the key string never changes. The v1 members `version` (1), `add(value)`,
13
+ * `size` (an accessor reporting the live count) and `values()` (a fresh array) are never removed or repurposed; a
14
+ * later contract keeps writing through this same slot and only adds members, and adopters ignore members they do not
15
+ * know. A copy adopts only a slot pinned the way `pin` pins it, frozen, with those four members; anything else (a
16
+ * slot planted before the first copy loaded, an accessor, another version) makes the import throw
17
+ * `ConfigReferenceRedactionSlotError`, because a copy running with a private set is the leak this registry removes.
18
+ */
19
+ const SLOT_KEY_NAME = "@code-yeongyu/senpi/config-reference-redaction/v1";
20
+ const SLOT_KEY = Symbol.for(SLOT_KEY_NAME);
1
21
  const MIN_REDACTED_LENGTH = 4;
2
- const resolvedValues = new Set();
3
- let redactionForms;
22
+ /** The largest `$file` reference value (`config-reference-file.ts`); longer strings are never remembered. */
23
+ const MAX_REMEMBERED_LENGTH = 65_536;
24
+ class ConfigReferenceRedactionSlotError extends Error {
25
+ constructor(cause) {
26
+ super(`The process-wide redaction registry ${SLOT_KEY_NAME} was not installed by senpi or is incompatible; restart the process.`, cause === undefined ? undefined : { cause });
27
+ this.name = "ConfigReferenceRedactionSlotError";
28
+ this.code = "SENPI_REDACTION_SLOT_INCOMPATIBLE";
29
+ }
30
+ }
31
+ function createRegistry() {
32
+ const values = new Set();
33
+ return Object.freeze({
34
+ version: 1,
35
+ add(value) {
36
+ if (typeof value !== "string" || value.length < MIN_REDACTED_LENGTH || value.length > MAX_REMEMBERED_LENGTH)
37
+ return;
38
+ values.add(value);
39
+ },
40
+ get size() {
41
+ return values.size;
42
+ },
43
+ values: () => [...values],
44
+ });
45
+ }
46
+ function isFrozenV1Registry(value) {
47
+ return (typeof value === "object" &&
48
+ value !== null &&
49
+ Object.isFrozen(value) &&
50
+ Reflect.get(value, "version") === 1 &&
51
+ typeof Reflect.get(value, "add") === "function" &&
52
+ typeof Reflect.get(value, "values") === "function" &&
53
+ typeof Reflect.get(value, "size") === "number");
54
+ }
55
+ function pin(registry) {
56
+ try {
57
+ Object.defineProperty(globalThis, SLOT_KEY, {
58
+ value: registry,
59
+ writable: false,
60
+ configurable: false,
61
+ enumerable: false,
62
+ });
63
+ }
64
+ catch (error) {
65
+ throw new ConfigReferenceRedactionSlotError(error);
66
+ }
67
+ return registry;
68
+ }
69
+ function sharedRegistry() {
70
+ const slot = Reflect.getOwnPropertyDescriptor(globalThis, SLOT_KEY);
71
+ if (slot === undefined)
72
+ return pin(createRegistry());
73
+ const value = slot.value;
74
+ if (slot.writable !== false || slot.configurable !== false || !isFrozenV1Registry(value)) {
75
+ throw new ConfigReferenceRedactionSlotError();
76
+ }
77
+ return value;
78
+ }
79
+ const registry = sharedRegistry();
80
+ let redactionForms = [];
81
+ let formsSize = 0;
4
82
  /** Every value a reference resolves to is kept for the process lifetime, including rotated ones: no cap, no eviction. */
5
83
  export function rememberResolvedReferenceValue(value) {
6
- if (value.length < MIN_REDACTED_LENGTH || resolvedValues.has(value))
7
- return;
8
- resolvedValues.add(value);
9
- redactionForms = undefined;
84
+ registry.add(value);
10
85
  }
11
86
  function encodings(value) {
12
87
  const form = new URLSearchParams([["", value]]).toString().slice(1);
13
88
  return [value, JSON.stringify(value).slice(1, -1), encodeURIComponent(value), encodeURI(value), form];
14
89
  }
90
+ /** The registry is append-only, so a changed size is the only way its content can change. */
15
91
  function forms() {
16
- redactionForms ??= [...new Set([...resolvedValues].flatMap(encodings))]
17
- .filter((form) => form.length >= MIN_REDACTED_LENGTH)
18
- .sort((left, right) => right.length - left.length);
92
+ const size = registry.size;
93
+ if (size !== formsSize) {
94
+ redactionForms = [...new Set(registry.values().flatMap(encodings))]
95
+ .filter((form) => form.length >= MIN_REDACTED_LENGTH)
96
+ .sort((left, right) => right.length - left.length);
97
+ formsSize = size;
98
+ }
19
99
  return redactionForms;
20
100
  }
21
101
  /** Replaces every resolved reference value, raw or encoded, before text reaches a sink outside the process. */
@@ -134,7 +214,7 @@ function isProtocolValue(key, value) {
134
214
  return Object.hasOwn(PROTOCOL_VALUES, key) && PROTOCOL_VALUES[key]?.has(value) === true;
135
215
  }
136
216
  export function redactResolvedReferenceData(value) {
137
- if (resolvedValues.size === 0)
217
+ if (registry.size === 0)
138
218
  return value;
139
219
  const copies = new WeakMap();
140
220
  const pending = [];
@@ -16,6 +16,16 @@ export type ContextFiles = ContextFile[] & {
16
16
  export interface ContextFileLimits {
17
17
  maxBytesPerFile?: number;
18
18
  maxTotalBytes?: number;
19
+ /**
20
+ * The global (user-scope) context file to load instead of the agent-dir probe (senpi#5567).
21
+ * Honored from the user's own settings only (`SettingsManager.getGlobalContextFilePath`); a
22
+ * project's settings never set it. A leading `~` is expanded; a relative path resolves against
23
+ * the agent dir, like other paths in global settings; a directory runs the same candidate probe
24
+ * as the agent dir. Unset or blank keeps the agent-dir file. A path that is set but missing,
25
+ * unreadable, a special file, or a directory with no candidate falls back to the agent-dir probe
26
+ * and reports one warning diagnostic instead of erroring.
27
+ */
28
+ globalPath?: string;
19
29
  }
20
30
  export declare const DEFAULT_CONTEXT_FILE_MAX_BYTES: number;
21
31
  export declare const DEFAULT_CONTEXT_FILES_MAX_TOTAL_BYTES: number;
@@ -29,6 +29,47 @@ function loadContextFileFromDir(dir) {
29
29
  }
30
30
  return null;
31
31
  }
32
+ /**
33
+ * The global user-scope context file (senpi#5567). `contextFiles.globalPath` names a file (or a
34
+ * directory to probe for the usual candidates) to load in place of the agent-dir file. Precedence
35
+ * is defined and never errors: a blank or unset path keeps the agent-dir probe silently; a
36
+ * configured target that is missing, unreadable, a special file, or a directory with no candidates
37
+ * falls back to the agent-dir probe and returns one warning naming the configured path.
38
+ */
39
+ function loadGlobalContextFile(agentDir, globalPath) {
40
+ const explicit = typeof globalPath === "string" ? globalPath.trim() : "";
41
+ if (explicit === "")
42
+ return { file: loadContextFileFromDir(agentDir) };
43
+ const resolved = resolvePath(explicit, agentDir);
44
+ const fallBack = (problem) => ({
45
+ file: loadContextFileFromDir(agentDir),
46
+ diagnostic: {
47
+ type: "warning",
48
+ path: resolved,
49
+ message: `contextFiles.globalPath ${problem}; using the global context file from ${agentDir} instead.`,
50
+ },
51
+ });
52
+ let stats;
53
+ try {
54
+ stats = statSync(resolved);
55
+ }
56
+ catch (error) {
57
+ const code = error.code;
58
+ return fallBack(code === "ENOENT" || code === "ENOTDIR" ? "does not exist" : `could not be read (${String(error)})`);
59
+ }
60
+ if (stats.isDirectory()) {
61
+ const file = loadContextFileFromDir(resolved);
62
+ return file ? { file } : fallBack("is a directory with no AGENTS.md or CLAUDE.md");
63
+ }
64
+ if (!stats.isFile())
65
+ return fallBack("is not a regular file or directory");
66
+ try {
67
+ return { file: { path: resolved, content: stripBom(readFileSync(resolved, "utf-8")) } };
68
+ }
69
+ catch (error) {
70
+ return fallBack(`could not be read (${String(error)})`);
71
+ }
72
+ }
32
73
  /**
33
74
  * The main repo's context file that a nested linked worktree's own copy shadows: both
34
75
  * occupy the same logical repository scope, so loading both applies that context twice. Returns
@@ -142,21 +183,22 @@ function applyContextFileLimits(files, limits) {
142
183
  }
143
184
  return Object.assign(kept, { diagnostics });
144
185
  }
145
- /** Compares directories by their real path, ignoring case where the filesystem does (Windows). */
146
- function directoryKey(dir) {
147
- const real = canonicalizePath(resolvePath(dir));
186
+ /** Compares files and directories by their real path, ignoring case where the filesystem does (Windows). */
187
+ function pathKey(path) {
188
+ const real = canonicalizePath(resolvePath(path));
148
189
  return process.platform === "win32" ? real.toLowerCase() : real;
149
190
  }
150
191
  export function loadProjectContextFiles(options) {
151
192
  const resolvedCwd = resolvePath(options.cwd);
152
193
  const resolvedAgentDir = resolvePath(options.agentDir);
153
- const homeKey = directoryKey(options.homeDir ?? homedir());
194
+ const homeKey = pathKey(options.homeDir ?? homedir());
154
195
  const contextFiles = [];
196
+ // Real-path keys: a symlinked ancestor file that points at an already loaded file is the same file.
155
197
  const seenPaths = new Set();
156
- const globalContext = loadContextFileFromDir(resolvedAgentDir);
157
- if (globalContext) {
158
- contextFiles.push({ ...globalContext, scope: "user" });
159
- seenPaths.add(globalContext.path);
198
+ const globalContext = loadGlobalContextFile(resolvedAgentDir, options.globalPath);
199
+ if (globalContext.file) {
200
+ contextFiles.push({ ...globalContext.file, scope: "user" });
201
+ seenPaths.add(pathKey(globalContext.file.path));
160
202
  }
161
203
  const ancestorContextFiles = [];
162
204
  const shadowedContextFile = findShadowedContextFile(resolvedCwd);
@@ -164,9 +206,10 @@ export function loadProjectContextFiles(options) {
164
206
  while (true) {
165
207
  const contextFile = loadContextFileFromDir(currentDir);
166
208
  const isShadowed = shadowedContextFile !== undefined && canonicalizePath(contextFile?.path ?? "") === shadowedContextFile;
167
- if (contextFile && !isShadowed && !seenPaths.has(contextFile.path)) {
168
- ancestorContextFiles.unshift(directoryKey(currentDir) === homeKey ? { ...contextFile, scope: "user" } : contextFile);
169
- seenPaths.add(contextFile.path);
209
+ const contextFileKey = contextFile ? pathKey(contextFile.path) : undefined;
210
+ if (contextFile && contextFileKey !== undefined && !isShadowed && !seenPaths.has(contextFileKey)) {
211
+ ancestorContextFiles.unshift(pathKey(currentDir) === homeKey ? { ...contextFile, scope: "user" } : contextFile);
212
+ seenPaths.add(contextFileKey);
170
213
  }
171
214
  const parentDir = dirname(currentDir);
172
215
  if (parentDir === currentDir)
@@ -174,6 +217,9 @@ export function loadProjectContextFiles(options) {
174
217
  currentDir = parentDir;
175
218
  }
176
219
  contextFiles.push(...ancestorContextFiles);
177
- return applyContextFileLimits(contextFiles, options);
220
+ const limited = applyContextFileLimits(contextFiles, options);
221
+ if (globalContext.diagnostic)
222
+ limited.diagnostics.unshift(globalContext.diagnostic);
223
+ return limited;
178
224
  }
179
225
  //# sourceMappingURL=context-files.js.map
@@ -168,11 +168,24 @@ export interface HookModelRequest {
168
168
  readonly claim: SignedPlacementClaim;
169
169
  readonly input: HookModelRequestInput;
170
170
  }
171
+ /** A user hook's failure on the hand: reported with the reply, as a fused session reports it, never thrown. */
172
+ export interface HookFailure {
173
+ readonly extensionPath: string;
174
+ readonly event: string;
175
+ readonly error: string;
176
+ }
171
177
  export interface HookModelRequestReply {
172
178
  readonly t: "hook.model_request.reply";
173
179
  readonly sessionId: string;
174
180
  readonly stepId: string;
175
181
  readonly output: HookModelRequestInput;
182
+ /** The hooks that failed; `output` is what the others produced. Absent: none failed. */
183
+ readonly errors?: readonly HookFailure[];
184
+ /** The messages and notices the hooks sent during the call, as on every loop-hook reply (S33). Absent: none. */
185
+ readonly sent?: readonly WireJson[];
186
+ readonly notices?: readonly WireJson[];
187
+ /** Late messages and notices the hand dropped past its per-session bound. Absent: none. */
188
+ readonly droppedLate?: number;
176
189
  }
177
190
  export interface HandSessionAdvertisement {
178
191
  readonly t: "hand.session.advertise";
@@ -68,6 +68,19 @@ function assertExtensionSide(extension, runs, registration) {
68
68
  return;
69
69
  throw new Error(`Extension "${extension.path}" declared runs: ${JSON.stringify(runs)} for ${registration}; runs must be "brain" or "hand".`);
70
70
  }
71
+ /** The events whose handlers may declare `{ providers }` (CK21). */
72
+ const PROVIDER_HOOK_EVENTS = [
73
+ "before_provider_request",
74
+ "provider_stream_event",
75
+ "after_provider_response",
76
+ ];
77
+ function declaredProviders(extension, providers, registration) {
78
+ if (Array.isArray(providers) &&
79
+ providers.length > 0 &&
80
+ providers.every((provider) => typeof provider === "string" && provider !== ""))
81
+ return Object.freeze([...providers]);
82
+ throw new Error(`Extension "${extension.path}" declared providers: ${JSON.stringify(providers)} for ${registration}; providers must be a non-empty array of provider ids.`);
83
+ }
71
84
  /** Modules available to extensions via virtualModules (for compiled binaries) */
72
85
  const VIRTUAL_MODULES = {
73
86
  typebox: _bundledTypebox,
@@ -355,11 +368,14 @@ function createExtensionAPI(extension, runtime, cwd, eventBus, session) {
355
368
  on(event, handler, options) {
356
369
  assertActive();
357
370
  const runs = options?.runs;
358
- if (runs !== undefined) {
371
+ if (runs !== undefined)
359
372
  assertExtensionSide(extension, runs, `"${event}"`);
360
- if (session.side !== undefined && runs !== session.side)
361
- return () => { };
362
- }
373
+ // Checked on every side, also for a handler this side does not register: a malformed declaration always throws.
374
+ const providers = PROVIDER_HOOK_EVENTS.includes(event) && options?.providers !== undefined
375
+ ? declaredProviders(extension, options.providers, `"${event}"`)
376
+ : undefined;
377
+ if (runs !== undefined && session.side !== undefined && runs !== session.side)
378
+ return () => { };
363
379
  const registeredHandler = ownedBy(extension, (...args) => {
364
380
  // A child event can run inside its parent's task-tool async scope.
365
381
  // Bind the host context here, preserving registered-handler metadata.
@@ -376,6 +392,10 @@ function createExtensionAPI(extension, runtime, cwd, eventBus, session) {
376
392
  extension.handlerSides ??= new WeakMap();
377
393
  extension.handlerSides.set(registeredHandler, runs);
378
394
  }
395
+ if (providers !== undefined) {
396
+ extension.handlerProviders ??= new WeakMap();
397
+ extension.handlerProviders.set(registeredHandler, providers);
398
+ }
379
399
  if (event === "before_agent_start" && options?.previewSafe === true) {
380
400
  extension.previewSafeHandlers ??= new WeakSet();
381
401
  extension.previewSafeHandlers.add(registeredHandler);
@@ -129,6 +129,14 @@ export type ShutdownHandler = () => void;
129
129
  export declare function emitSessionShutdownEvent(extensionRunner: ExtensionRunner, event: SessionShutdownEvent, options?: {
130
130
  readonly processExit?: boolean;
131
131
  }): Promise<boolean>;
132
+ /**
133
+ * A hand's dispatch of a hosted loop's request hooks (CK21): only the extensions `only` accepts, with the effective
134
+ * model the loop sent and no headers (they never leave the loop's side).
135
+ */
136
+ export interface HandRequestHookDispatch {
137
+ readonly only: (extensionPath: string) => boolean;
138
+ readonly model?: Model<Api>;
139
+ }
132
140
  export declare function emitProjectTrustEvent(extensionsResult: LoadExtensionsResult, event: ProjectTrustEvent, ctx: ProjectTrustContext): Promise<{
133
141
  result?: ProjectTrustEventResult;
134
142
  errors: ExtensionError[];
@@ -422,7 +430,7 @@ export declare class ExtensionRunner {
422
430
  emitBeforeProviderRequest(payload: unknown, excludeExtensionPath?: string, request?: {
423
431
  model: Model<Api>;
424
432
  headers: ProviderHeaders;
425
- }): Promise<unknown>;
433
+ }, hand?: HandRequestHookDispatch): Promise<unknown>;
426
434
  emitBeforeProviderHeaders(headers: ProviderHeaders): Promise<ProviderHeaders>;
427
435
  /** Use the loader's provenance, including bundled modules and generated global-default shims. */
428
436
  getThirdPartySessionStartPaths(): string[];
@@ -2111,11 +2111,15 @@ export class ExtensionRunner {
2111
2111
  transformHeaders: async (headers) => await this.emitBeforeProviderHeaders(headers),
2112
2112
  };
2113
2113
  }
2114
- async emitBeforeProviderRequest(payload, excludeExtensionPath, request) {
2114
+ async emitBeforeProviderRequest(payload, excludeExtensionPath, request, hand) {
2115
2115
  let currentPayload = payload;
2116
- const requestHandlers = this.dropsLoopEvent("before_provider_request")
2117
- ? []
2118
- : snapshotEventHandlers(this.extensions, "before_provider_request");
2116
+ // A hand running a hosted loop's request hooks (CK21) names whose handlers run; it has no loop of its own, so the
2117
+ // sessionless drop does not apply to that explicit dispatch. Every other caller keeps the dispatch it had.
2118
+ const requestHandlers = hand !== undefined
2119
+ ? snapshotEventHandlers(this.extensions, "before_provider_request").filter(({ ext }) => hand.only(ext.path))
2120
+ : this.dropsLoopEvent("before_provider_request")
2121
+ ? []
2122
+ : snapshotEventHandlers(this.extensions, "before_provider_request");
2119
2123
  for (const { ext, handlers } of requestHandlers) {
2120
2124
  if (ext.path === excludeExtensionPath)
2121
2125
  continue;
@@ -2125,6 +2129,7 @@ export class ExtensionRunner {
2125
2129
  type: "before_provider_request",
2126
2130
  payload: currentPayload,
2127
2131
  ...(request ? { model: request.model, headers: request.headers } : {}),
2132
+ ...(hand?.model === undefined ? {} : { model: hand.model }),
2128
2133
  };
2129
2134
  const handlerResult = await handler(event, this.createContext(ext.path));
2130
2135
  if (handlerResult !== undefined) {
@@ -1473,6 +1473,18 @@ export interface HandlerRegistrationOptions {
1473
1473
  */
1474
1474
  runs?: ExtensionSide;
1475
1475
  }
1476
+ /**
1477
+ * Registration options for the provider request and stream hooks (`before_provider_request`, `provider_stream_event`,
1478
+ * `after_provider_response`).
1479
+ */
1480
+ export interface ProviderHookOptions extends HandlerRegistrationOptions {
1481
+ /**
1482
+ * The providers whose requests or streams this handler reads or changes; omitted, every provider. A hand advertises
1483
+ * these to a hosted brain so it knows which calls need the device's hooks (hosted model-key custody CK21). The
1484
+ * declaration never filters dispatch: the handler still receives every event wherever it runs.
1485
+ */
1486
+ providers?: readonly ProviderId[];
1487
+ }
1476
1488
  /** Registration options for `pi.on("before_agent_start", handler, options)`. */
1477
1489
  export interface BeforeAgentStartHandlerOptions extends HandlerRegistrationOptions {
1478
1490
  /**
@@ -2157,10 +2169,10 @@ export interface ExtensionAPI {
2157
2169
  on(event: "session_tree", handler: ExtensionHandler<SessionTreeEvent>, options?: HandlerRegistrationOptions): () => void;
2158
2170
  on(event: "context", handler: ExtensionHandler<ContextEvent, ContextEventResult>, options?: ContextHandlerOptions): () => void;
2159
2171
  on(event: "context_with_system", handler: ExtensionHandler<ContextWithSystemEvent, ContextEventResult>, options?: ContextHandlerOptions): () => void;
2160
- on(event: "before_provider_request", handler: ExtensionHandler<BeforeProviderRequestEvent, BeforeProviderRequestEventResult>, options?: HandlerRegistrationOptions): () => void;
2172
+ on(event: "before_provider_request", handler: ExtensionHandler<BeforeProviderRequestEvent, BeforeProviderRequestEventResult>, options?: ProviderHookOptions): () => void;
2161
2173
  on(event: "before_provider_headers", handler: ExtensionHandler<BeforeProviderHeadersEvent>, options?: HandlerRegistrationOptions): () => void;
2162
- on(event: "after_provider_response", handler: ExtensionHandler<AfterProviderResponseEvent>, options?: HandlerRegistrationOptions): () => void;
2163
- on(event: "provider_stream_event", handler: ExtensionHandler<ProviderStreamEvent>, options?: HandlerRegistrationOptions): () => void;
2174
+ on(event: "after_provider_response", handler: ExtensionHandler<AfterProviderResponseEvent>, options?: ProviderHookOptions): () => void;
2175
+ on(event: "provider_stream_event", handler: ExtensionHandler<ProviderStreamEvent>, options?: ProviderHookOptions): () => void;
2164
2176
  on(event: "before_agent_start", handler: ExtensionHandler<BeforeAgentStartEvent, BeforeAgentStartEventResult>, options?: BeforeAgentStartHandlerOptions): () => void;
2165
2177
  on(event: "agent_start", handler: ExtensionHandler<AgentStartEvent>, options?: HandlerRegistrationOptions): () => void;
2166
2178
  on(event: "agent_end", handler: ExtensionHandler<AgentEndEvent>, options?: HandlerRegistrationOptions): () => void;
@@ -3057,6 +3069,8 @@ export interface Extension {
3057
3069
  nonMutatingContextHandlers?: WeakSet<HandlerFn>;
3058
3070
  /** The declared side of each handler registered with `{ runs }`; a handler that declared none has no entry. */
3059
3071
  handlerSides?: WeakMap<HandlerFn, ExtensionSide>;
3072
+ /** The declared providers of each provider hook registered with `{ providers }`; undeclared hooks have no entry. */
3073
+ handlerProviders?: WeakMap<HandlerFn, readonly ProviderId[]>;
3060
3074
  tools: Map<string, RegisteredTool>;
3061
3075
  /** Optional for compatibility with extension records created before child modules. */
3062
3076
  childModules?: Map<string, RegisteredChildModule>;
@@ -0,0 +1,41 @@
1
+ import type { ProviderId } from "@earendil-works/pi-ai";
2
+ import type { HandSessionAdvertisement, HookModelRequest, HookModelRequestReply } from "../egress/wire.ts";
3
+ import type { Extension } from "../extensions/types.ts";
4
+ export type HookProviders = readonly ProviderId[] | "*";
5
+ export interface HandHookAccess {
6
+ readonly requestHooks: HookProviders;
7
+ readonly streamHooks: HookProviders;
8
+ }
9
+ /** The request and stream hooks of the extensions `only` accepts (the ones that run on this hand). */
10
+ export declare function handHookAccess(extensions: readonly Extension[], only: (extensionPath: string) => boolean): HandHookAccess;
11
+ export interface AdvertHashParts {
12
+ readonly extensionSetHash: string;
13
+ readonly cwd: string;
14
+ /** The project-local hook set, as hook identities; its order does not matter. */
15
+ readonly projectHooks: readonly string[];
16
+ }
17
+ /** `H(extension set hash, cwd, project-local hook set)` (4.9): `sha256:` + hex SHA-256 of their canonical JSON. */
18
+ export declare function computeAdvertHash(parts: AdvertHashParts): string;
19
+ /** A bound session's advertisement, before a connection stamps it. */
20
+ export type HandHookSession = Omit<HandSessionAdvertisement, "t" | "connectionId">;
21
+ /** A hook request for a session not advertised on the connection it names; nothing ran. */
22
+ export declare class HookNotAdvertisedError extends Error {
23
+ readonly name = "HookNotAdvertisedError";
24
+ }
25
+ export interface HandHookLinkOptions {
26
+ send(message: HandSessionAdvertisement | HookModelRequestReply): Promise<void>;
27
+ serve(request: HookModelRequest): Promise<HookModelRequestReply>;
28
+ }
29
+ export interface HandHookLink {
30
+ /** A new connection: every bound session's advertisement goes out first, in order, under `connectionId`. */
31
+ connect(connectionId: string, sessions: readonly HandHookSession[]): Promise<void>;
32
+ /** A session bound (or re-hashed) mid-connection: its advertisement goes out before anything else for it. */
33
+ advertise(session: HandHookSession): Promise<void>;
34
+ /**
35
+ * One `hook.model_request` received on `connectionId`; its reply follows that session's advertisement. Its hooks run
36
+ * outside the outgoing order, so a slow hook holds back no other request's reply and no advertisement.
37
+ */
38
+ receive(connectionId: string, request: HookModelRequest): Promise<void>;
39
+ }
40
+ export declare function createHandHookLink(options: HandHookLinkOptions): HandHookLink;
41
+ //# sourceMappingURL=hook-advertise.d.ts.map
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The hand's hook advertisement of hosted model-key custody (row CK21; custody design 4.9, round-10 fold section 15).
3
+ *
4
+ * A hand tells a hosted brain, per bound session, which providers' calls need the device's hooks: the request hooks
5
+ * (`before_provider_request`) and the stream hooks (`provider_stream_event`, `after_provider_response`) of the user's
6
+ * extensions, each scoped by its `{ providers }` declaration or, undeclared, every provider (`"*"`). The advertisement
7
+ * carries the `connectionId`, the session's `cwd` and its `advertHash`, and it is the first message for its session on
8
+ * every (re)connect: the link serves no `hook.model_request` that its session's advertisement on that connection did
9
+ * not precede.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { canonicalJson } from "@earendil-works/pi-agent-core";
13
+ const REQUEST_HOOK_EVENTS = ["before_provider_request"];
14
+ const STREAM_HOOK_EVENTS = ["provider_stream_event", "after_provider_response"];
15
+ function hookProviders(extensions, events, only) {
16
+ const declared = new Set();
17
+ for (const extension of extensions) {
18
+ if (!only(extension.path))
19
+ continue;
20
+ for (const event of events)
21
+ for (const handler of extension.handlers.get(event) ?? []) {
22
+ const providers = extension.handlerProviders?.get(handler);
23
+ if (providers === undefined)
24
+ return "*";
25
+ for (const provider of providers)
26
+ declared.add(provider);
27
+ }
28
+ }
29
+ return [...declared].sort();
30
+ }
31
+ /** The request and stream hooks of the extensions `only` accepts (the ones that run on this hand). */
32
+ export function handHookAccess(extensions, only) {
33
+ return {
34
+ requestHooks: hookProviders(extensions, REQUEST_HOOK_EVENTS, only),
35
+ streamHooks: hookProviders(extensions, STREAM_HOOK_EVENTS, only),
36
+ };
37
+ }
38
+ /** `H(extension set hash, cwd, project-local hook set)` (4.9): `sha256:` + hex SHA-256 of their canonical JSON. */
39
+ export function computeAdvertHash(parts) {
40
+ const canonical = canonicalJson({
41
+ cwd: parts.cwd,
42
+ extensionSetHash: parts.extensionSetHash,
43
+ projectHooks: [...parts.projectHooks].sort(),
44
+ });
45
+ return `sha256:${createHash("sha256").update(canonical, "utf8").digest("hex")}`;
46
+ }
47
+ /** A hook request for a session not advertised on the connection it names; nothing ran. */
48
+ export class HookNotAdvertisedError extends Error {
49
+ constructor() {
50
+ super(...arguments);
51
+ this.name = "HookNotAdvertisedError";
52
+ }
53
+ }
54
+ export function createHandHookLink(options) {
55
+ let connectionId;
56
+ let advertised = new Set();
57
+ // One outgoing order: an advertisement queued before a reply is sent before it.
58
+ let outgoing = Promise.resolve();
59
+ const enqueue = (work) => {
60
+ const run = outgoing.then(work);
61
+ outgoing = run.then(() => undefined, () => undefined);
62
+ return run;
63
+ };
64
+ const sendAdvert = (connection, session, into) => enqueue(async () => {
65
+ if (connectionId !== connection)
66
+ return;
67
+ await options.send({ t: "hand.session.advertise", connectionId: connection, ...session });
68
+ into.add(session.sessionId);
69
+ });
70
+ return {
71
+ async connect(next, sessions) {
72
+ connectionId = next;
73
+ const fresh = new Set();
74
+ advertised = fresh;
75
+ await Promise.all(sessions.map((session) => sendAdvert(next, session, fresh)));
76
+ },
77
+ async advertise(session) {
78
+ if (connectionId === undefined)
79
+ throw new HookNotAdvertisedError("no connection to advertise on");
80
+ await sendAdvert(connectionId, session, advertised);
81
+ },
82
+ async receive(connection, request) {
83
+ // Only the advertised check and the reply's send take a place in the outgoing order.
84
+ await enqueue(async () => {
85
+ if (connection !== connectionId || !advertised.has(request.sessionId))
86
+ throw new HookNotAdvertisedError(`hook.model_request for session ${JSON.stringify(request.sessionId)} was not preceded by its advertisement on connection ${JSON.stringify(connection)}`);
87
+ });
88
+ const reply = await options.serve(request);
89
+ await enqueue(async () => {
90
+ // A reconnect since the request arrived: its connection is gone, so its reply is not sent.
91
+ if (connection !== connectionId)
92
+ return;
93
+ await options.send(reply);
94
+ });
95
+ },
96
+ };
97
+ }
98
+ //# sourceMappingURL=hook-advertise.js.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The request-hook contract of hosted model-key custody (row CK21; custody design 4.9, round-10 fold section 15).
3
+ *
4
+ * `HookRequestInput` is the serializable projection of the fused `before_provider_request` input: the provider payload
5
+ * and the effective request model, without the request headers or the model's own headers, which can carry a
6
+ * credential and never leave the side that dials. A step call has this input; an aux call has none. A hand serves
7
+ * `hook.model_request` by running its user's request hooks on the input, only after the request's placement claim
8
+ * verified, and replies with the payload they produced, with their failures and the messages and notices they sent.
9
+ */
10
+ import type { Api, Model } from "@earendil-works/pi-ai";
11
+ import type { ExecutionScope } from "@earendil-works/pi-agent-core";
12
+ import { type ClaimVerdict } from "../egress/placement-claim.ts";
13
+ import type { HookModelRequest, HookModelRequestReply, ProviderCallErrorCode, ProviderCallKind, WireJson } from "../egress/wire.ts";
14
+ import type { BeforeProviderRequestEvent } from "../extensions/types.ts";
15
+ /** The pinned shape of `HookModelRequestInput` (and of the reply's `output`). */
16
+ export type HookRequestInput = {
17
+ readonly payload: WireJson;
18
+ readonly model?: {
19
+ readonly [key: string]: WireJson;
20
+ };
21
+ };
22
+ /** The input or output cannot cross the wire: it is not JSON, or not the closed envelope. */
23
+ export declare class HookContractError extends Error {
24
+ readonly name = "HookContractError";
25
+ }
26
+ /** The request's placement claim did not verify, so no hook ran; `code`/`retryable` are the claim's wire answer. */
27
+ export declare class HookRequestRefusedError extends Error {
28
+ readonly name = "HookRequestRefusedError";
29
+ readonly verdict: Exclude<ClaimVerdict, "valid">;
30
+ readonly code: ProviderCallErrorCode;
31
+ readonly retryable: boolean;
32
+ constructor(verdict: Exclude<ClaimVerdict, "valid">);
33
+ }
34
+ /** The projection of a fused `before_provider_request` input. */
35
+ export declare function hookRequestInputOf(event: Omit<BeforeProviderRequestEvent, "type"> & {
36
+ type?: string;
37
+ }): HookRequestInput;
38
+ /** A step call's hook input; an aux call runs no request hooks on the hand and has none. */
39
+ export declare function hookRequestInputForCall(call: ProviderCallKind, event: Omit<BeforeProviderRequestEvent, "type"> & {
40
+ type?: string;
41
+ }): HookRequestInput | undefined;
42
+ /** The reply's `output`: the payload the hooks produced, with the model the request carried. */
43
+ export declare function hookRequestOutputOf(input: HookRequestInput, payload: unknown): HookRequestInput;
44
+ /** Reads a wire value back as the closed envelope `{ payload, model? }`; the model never carries headers. */
45
+ export declare function parseHookRequestInput(value: unknown): HookRequestInput;
46
+ /** The effective model a hand hands its hooks, as the loop sent it. */
47
+ export declare function hookModelOf(input: HookRequestInput): Model<Api> | undefined;
48
+ /** What the user's request hooks produced: the reply's `output`, their failures and what they sent. */
49
+ export type HookRequestHooksResult = Omit<HookModelRequestReply, "t" | "sessionId" | "stepId" | "output"> & {
50
+ readonly output: HookRequestInput;
51
+ };
52
+ export interface HookServePorts {
53
+ /** The hand's placement-claim check (CK7's verifier with the hand's trust and clock). */
54
+ verifyClaim(claim: unknown): Promise<ClaimVerdict>;
55
+ /** Runs the user's request hooks of `sessionId` on `input` and returns what they produced. */
56
+ runRequestHooks(sessionId: string, input: HookRequestInput): Promise<HookRequestHooksResult>;
57
+ }
58
+ /** Serves one `hook.model_request`: claim first, then the closed input, then the hooks. */
59
+ export declare function serveHookModelRequest(request: HookModelRequest, ports: HookServePorts): Promise<HookModelRequestReply>;
60
+ /**
61
+ * `runRequestHooks` over a hand's loop-hook runner (`LooplessHand.loopHooks`): the session's hooks run on the runtime of
62
+ * `scope`, inside a loop-hook call bound to that session, with the loop busy (a model request is mid-turn). The hooks'
63
+ * failures and what they sent travel with the output, as they do on every loop-hook reply.
64
+ */
65
+ export declare function loopHookRequestHooks(loopHooks: (event: string, payload: WireJson) => Promise<unknown>, scope: ExecutionScope): HookServePorts["runRequestHooks"];
66
+ //# sourceMappingURL=hook-contract.d.ts.map