@code-yeongyu/senpi 2026.11.6-2 → 2026.11.6-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 (165) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/bundle/chunks/{bun-extension-importer-RCGPQTI2.js → bun-extension-importer-GQ6IKEXD.js} +2 -2
  3. package/dist/bundle/chunks/{chunk-QN2BHP3M.js → chunk-44BJPS5I.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-C7OKUC7L.js → chunk-4BATXPB7.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-RQ6CHXQ2.js → chunk-5LKB732P.js} +1 -1
  6. package/dist/bundle/chunks/{chunk-X5ZVYNOL.js → chunk-6CEDL5S2.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-HLM46XEB.js → chunk-7DRWPZ3N.js} +1 -1
  8. package/dist/bundle/chunks/{chunk-7PSBD5KS.js → chunk-BASS47UD.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-ECW7VBMB.js → chunk-CFEHJ4VU.js} +1 -1
  10. package/dist/bundle/chunks/{chunk-TIPKAMSS.js → chunk-EO2VPZ2K.js} +1 -1
  11. package/dist/bundle/chunks/{chunk-LNPAT22L.js → chunk-F5JLDCYD.js} +8 -7
  12. package/dist/bundle/chunks/{chunk-GSKCWXXG.js → chunk-JMQOLOC2.js} +1 -1
  13. package/dist/bundle/chunks/chunk-L7ZIS3MC.js +106 -0
  14. package/dist/bundle/chunks/{chunk-47PPZKPO.js → chunk-LM6DOWQH.js} +4 -4
  15. package/dist/bundle/chunks/chunk-LPYBKMPT.js +2 -0
  16. package/dist/bundle/chunks/{chunk-GNSMNSL7.js → chunk-LUZVGPQ7.js} +2 -2
  17. package/dist/bundle/chunks/{chunk-7III44PV.js → chunk-MJTCZUXS.js} +1 -1
  18. package/dist/bundle/chunks/{chunk-2FDW2LQL.js → chunk-SB6MXNO5.js} +1 -1
  19. package/dist/bundle/chunks/chunk-UODSYQ2D.js +2 -0
  20. package/dist/bundle/chunks/{chunk-D5MRJWUK.js → chunk-V2QAYGOK.js} +216 -224
  21. package/dist/bundle/chunks/{chunk-WGRKM3N6.js → chunk-X53PD4ZC.js} +1 -1
  22. package/dist/bundle/chunks/{cli-main-T74YJGIF.js → cli-main-RWHDZKTQ.js} +1 -1
  23. package/dist/bundle/chunks/{dispatch-BAK2YZKD.js → dispatch-VDIYSXQN.js} +1 -1
  24. package/dist/bundle/chunks/{hand-entry-AYRUGE2A.js → hand-entry-G5LYF62Q.js} +1 -1
  25. package/dist/bundle/chunks/{host-command-YRXZE4TV.js → host-command-I2JZ5JM7.js} +1 -1
  26. package/dist/bundle/chunks/{host-session-command-N7IN7ZDV.js → host-session-command-Y52NFPP2.js} +1 -1
  27. package/dist/bundle/chunks/interactive-mode-PA7DGVS6.js +2 -0
  28. package/dist/bundle/chunks/main-OBL6375C.js +2 -0
  29. package/dist/bundle/chunks/{multi-session-host-XSUJDHFZ.js → multi-session-host-NM6I5BWZ.js} +20 -17
  30. package/dist/bundle/chunks/{package-manager-cli-GLUNPQJU.js → package-manager-cli-GNRQ5MVR.js} +1 -1
  31. package/dist/bundle/chunks/{role-RRFFHZF5.js → role-F56BS2ZQ.js} +1 -1
  32. package/dist/bundle/chunks/rpc-mode-G4KIP7XS.js +2 -0
  33. package/dist/bundle/chunks/{run-attached-tui-W236SEOR.js → run-attached-tui-XTGPAGYM.js} +1 -1
  34. package/dist/bundle/chunks/{runtime-session-OBXYK4PI.js → runtime-session-VO3X4OE3.js} +1 -1
  35. package/dist/bundle/chunks/{schedule-command-B2EJTBOE.js → schedule-command-L4M3TSQ3.js} +1 -1
  36. package/dist/bundle/chunks/{session-control-endpoint-YIMLHGLG.js → session-control-endpoint-V46WMCDF.js} +1 -1
  37. package/dist/bundle/chunks/{session-picker-E6ZONUJB.js → session-picker-AG6KSR5A.js} +1 -1
  38. package/dist/bundle/chunks/session-worker.js +87 -83
  39. package/dist/bundle/cli.js +2 -2
  40. package/dist/bundle/index.js +1 -1
  41. package/dist/bundle/rpc-entry.js +1 -1
  42. package/dist/bundle/runtime-manifest.json +1 -1
  43. package/dist/core/agent-session-runtime.d.ts +2 -0
  44. package/dist/core/agent-session-runtime.js +9 -2
  45. package/dist/core/agent-session.d.ts +24 -3
  46. package/dist/core/agent-session.js +94 -28
  47. package/dist/core/credential-pool/rotation-slots.d.ts +3 -3
  48. package/dist/core/credential-pool/state-store.d.ts +1 -1
  49. package/dist/core/egress/hpke.d.ts +63 -0
  50. package/dist/core/egress/hpke.js +188 -0
  51. package/dist/core/egress/placement-claim.d.ts +69 -0
  52. package/dist/core/egress/placement-claim.js +115 -0
  53. package/dist/core/egress/pool-state.d.ts +18 -0
  54. package/dist/core/egress/pool-state.js +5 -0
  55. package/dist/core/egress/sealed-format.d.ts +101 -0
  56. package/dist/core/egress/sealed-format.js +174 -0
  57. package/dist/core/egress/wire.d.ts +226 -0
  58. package/dist/core/egress/wire.js +47 -0
  59. package/dist/core/extensions/builtin/ask-user/escape-residue.d.ts +50 -0
  60. package/dist/core/extensions/builtin/ask-user/escape-residue.js +113 -0
  61. package/dist/core/extensions/builtin/ask-user/extension.js +7 -1
  62. package/dist/core/extensions/builtin/ask-user/resume.js +2 -1
  63. package/dist/core/extensions/builtin/ask-user/tool.d.ts +3 -0
  64. package/dist/core/extensions/builtin/ask-user/tool.js +8 -0
  65. package/dist/core/extensions/builtin/goal/index.js +46 -0
  66. package/dist/core/extensions/builtin/goal/stop-pause.d.ts +1 -0
  67. package/dist/core/extensions/builtin/goal/stop-pause.js +4 -2
  68. package/dist/core/extensions/builtin/permission-system/index.js +10 -4
  69. package/dist/core/extensions/builtin/permission-system/preset-registry.d.ts +2 -0
  70. package/dist/core/extensions/builtin/permission-system/preset-registry.js +11 -0
  71. package/dist/core/extensions/builtin/permission-system/settings.d.ts +2 -0
  72. package/dist/core/extensions/builtin/permission-system/settings.js +10 -1
  73. package/dist/core/extensions/builtin/terminal/tools/bash.d.ts +1 -1
  74. package/dist/core/extensions/builtin/terminal/tools/bash.js +38 -9
  75. package/dist/core/extensions/builtin/terminal/tools/context.d.ts +3 -0
  76. package/dist/core/extensions/bun-extension-importer.js +4 -3
  77. package/dist/core/extensions/bun-extension-load-turns.d.ts +4 -0
  78. package/dist/core/extensions/bun-extension-load-turns.js +45 -0
  79. package/dist/core/extensions/bun-extension-registry.d.ts +1 -0
  80. package/dist/core/extensions/bun-extension-registry.js +19 -1
  81. package/dist/core/extensions/loader.js +5 -0
  82. package/dist/core/extensions/session-dialog-api.d.ts +5 -0
  83. package/dist/core/extensions/session-dialog-api.js +13 -0
  84. package/dist/core/extensions/types.d.ts +14 -1
  85. package/dist/core/permission-decision.d.ts +22 -0
  86. package/dist/core/permission-decision.js +53 -0
  87. package/dist/core/permission-mode.d.ts +56 -0
  88. package/dist/core/permission-mode.js +133 -0
  89. package/dist/core/retry-fallback/controller-types.d.ts +6 -0
  90. package/dist/core/retry-fallback/controller.d.ts +8 -0
  91. package/dist/core/retry-fallback/controller.js +31 -3
  92. package/dist/core/retry-fallback/unusable.d.ts +23 -1
  93. package/dist/core/retry-fallback/unusable.js +80 -1
  94. package/dist/core/retry-fallback/usage-limit.d.ts +1 -1
  95. package/dist/core/retry-fallback/usage-limit.js +6 -3
  96. package/dist/core/sdk.js +2 -0
  97. package/dist/core/session-log.js +1 -1
  98. package/dist/core/stall-warning.d.ts +41 -0
  99. package/dist/core/stall-warning.js +72 -0
  100. package/dist/core/turn-origin.d.ts +27 -0
  101. package/dist/core/turn-origin.js +54 -0
  102. package/dist/modes/rpc/client-input-handler.d.ts +2 -0
  103. package/dist/modes/rpc/client-input-handler.js +2 -0
  104. package/dist/modes/rpc/connection-handler.js +16 -5
  105. package/dist/modes/rpc/connection-roles.d.ts +6 -0
  106. package/dist/modes/rpc/connection-roles.js +13 -0
  107. package/dist/modes/rpc/delegated-drive-origin.d.ts +9 -0
  108. package/dist/modes/rpc/delegated-drive-origin.js +16 -0
  109. package/dist/modes/rpc/effect-commands.d.ts +6 -0
  110. package/dist/modes/rpc/host-bare-endpoint-registration.d.ts +7 -0
  111. package/dist/modes/rpc/host-bare-endpoint-registration.js +35 -0
  112. package/dist/modes/rpc/host-capabilities.d.ts +2 -0
  113. package/dist/modes/rpc/host-capabilities.js +3 -0
  114. package/dist/modes/rpc/host-handover-parked.d.ts +11 -0
  115. package/dist/modes/rpc/host-handover-parked.js +17 -0
  116. package/dist/modes/rpc/host-handover-reopen.js +18 -6
  117. package/dist/modes/rpc/host-handover-resident-writer.d.ts +16 -0
  118. package/dist/modes/rpc/host-handover-resident-writer.js +57 -0
  119. package/dist/modes/rpc/host-warm.d.ts +16 -1
  120. package/dist/modes/rpc/host-warm.js +33 -6
  121. package/dist/modes/rpc/method-classes-schema-session.d.ts +9 -0
  122. package/dist/modes/rpc/method-classes-schema-session.js +1 -0
  123. package/dist/modes/rpc/method-classes-schema.d.ts +9 -0
  124. package/dist/modes/rpc/method-classes.d.ts +1 -0
  125. package/dist/modes/rpc/method-classes.js +2 -0
  126. package/dist/modes/rpc/multi-session-host.js +6 -1
  127. package/dist/modes/rpc/permission-preset-command.d.ts +6 -0
  128. package/dist/modes/rpc/permission-preset-command.js +27 -0
  129. package/dist/modes/rpc/rpc-mutation-response-types.d.ts +7 -0
  130. package/dist/modes/rpc/rpc-session-command-types.d.ts +4 -0
  131. package/dist/modes/rpc/rpc-session-event-types.d.ts +5 -2
  132. package/dist/modes/rpc/rpc-session-state-types.d.ts +2 -1
  133. package/dist/modes/rpc/rpc-session-state.js +2 -0
  134. package/dist/modes/rpc/session-attachment-commands.js +3 -3
  135. package/dist/modes/rpc/session-event-writer-connections.js +1 -0
  136. package/dist/modes/rpc/session-event-writer.d.ts +1 -1
  137. package/dist/modes/rpc/session-registry-attach.d.ts +0 -9
  138. package/dist/modes/rpc/session-registry-attach.js +16 -3
  139. package/dist/modes/rpc/session-registry-types.d.ts +2 -0
  140. package/dist/modes/rpc/session-registry.d.ts +2 -0
  141. package/dist/modes/rpc/session-registry.js +16 -3
  142. package/dist/modes/rpc/session-router-close.js +43 -6
  143. package/dist/modes/rpc/session-router-context.js +2 -0
  144. package/dist/modes/rpc/session-router-occupancy.js +2 -11
  145. package/dist/modes/rpc/session-router-open-profile.js +4 -0
  146. package/dist/modes/rpc/session-router-open.d.ts +1 -1
  147. package/dist/modes/rpc/session-router-open.js +6 -17
  148. package/dist/modes/rpc/session-router-residency.d.ts +24 -0
  149. package/dist/modes/rpc/session-router-residency.js +90 -0
  150. package/dist/modes/rpc/session-router-routing.js +7 -1
  151. package/dist/modes/rpc/session-status.js +4 -1
  152. package/dist/modes/rpc/session-worker.js +4 -0
  153. package/dist/modes/rpc/worker-session-registry.js +5 -2
  154. package/docs/engine-socket/fixtures/admission-routes/h-legacy.json +1 -1
  155. package/docs/engine-socket/fixtures/rpc-inbound-samples.json +12 -2
  156. package/docs/extensions.md +12 -0
  157. package/docs/rpc.md +72 -12
  158. package/native/socket-secret/prebuilds/win32-arm64/senpi_socket_secret.win32-arm64.node +0 -0
  159. package/native/socket-secret/prebuilds/win32-x64/senpi_socket_secret.win32-x64.node +0 -0
  160. package/package.json +6 -6
  161. package/dist/bundle/chunks/chunk-C7HEEBFH.js +0 -2
  162. package/dist/bundle/chunks/chunk-XCMII2VQ.js +0 -98
  163. package/dist/bundle/chunks/interactive-mode-B5SQDLH7.js +0 -2
  164. package/dist/bundle/chunks/main-UQFVFCNY.js +0 -2
  165. package/dist/bundle/chunks/rpc-mode-JYN3E7P7.js +0 -2
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Sealed custody formats (C6, row CK3): provider fields under the provider DEK, the account config object under K_cfg,
3
+ * and HPKE key wraps (K_cfg, K_proj, DEK) to device and custody keys.
4
+ *
5
+ * Every AAD is rebuilt by the opener from its own context and never stored. Its bytes are a per-kind ASCII label, a NUL
6
+ * and the canonical JSON of a flat record whose members are all required strings or one safe integer: on that domain
7
+ * the encoding is injective, so a ciphertext cannot move between accounts, providers, owners, digests, positions, DEK
8
+ * generations, wrap kinds, recipient keys or format versions. The label names the format version and, for the raw
9
+ * AES-GCM paths, the exact algorithm: AES-256-GCM, 96-bit nonce, 128-bit tag.
10
+ *
11
+ * Nonces come from the CSPRNG only, never a counter: K_cfg is sealed by every enrolled device. NIST SP 800-38D allows
12
+ * 2^32 random-nonce seals per key; a DEK sees about fields x refreshes and K_cfg one seal per publish per device, both
13
+ * orders of magnitude below. AES-GCM is not key-committing; that is safe here because every key is 256-bit random and
14
+ * only a dekId's owner can submit a wrap for it, who could plant any value anyway. AES-GCM-SIV is not in Web Crypto.
15
+ */
16
+ import { canonicalJson } from "@earendil-works/pi-agent-core";
17
+ import { hpkeOpenBase, hpkeSealBase, SealedFormatError } from "./hpke.js";
18
+ export { SealedFormatError } from "./hpke.js";
19
+ const isSourceDigest = (value) => /^[A-Za-z0-9:_-]{1,256}$/.test(value);
20
+ const isDekId = (value) => /^[A-Za-z0-9_-]{1,128}$/.test(value);
21
+ /** The one boundary parser for a source digest read from an entry; a FieldAad cannot be built without one. */
22
+ export function parseSourceDigest(value) {
23
+ if (!isSourceDigest(value))
24
+ throw new SealedFormatError("invalid_aad");
25
+ return value;
26
+ }
27
+ /** The one boundary parser for a dekId read from an entry; a FieldAad cannot be built without one. */
28
+ export function parseDekId(value) {
29
+ if (!isDekId(value))
30
+ throw new SealedFormatError("invalid_aad");
31
+ return value;
32
+ }
33
+ export const HPKE_SUITE = "DHKEM(P-256,HKDF-SHA256)/HKDF-SHA256/AES-256-GCM";
34
+ const NONCE_BYTES = 12;
35
+ const TAG_BYTES = 16;
36
+ const KEY_BYTES = 32;
37
+ const encoder = new TextEncoder();
38
+ function text(value) {
39
+ if (typeof value !== "string")
40
+ throw new SealedFormatError("invalid_aad");
41
+ return value;
42
+ }
43
+ function counter(value) {
44
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
45
+ throw new SealedFormatError("invalid_aad");
46
+ return value;
47
+ }
48
+ function aadBytes(label, record) {
49
+ return encoder.encode(`${label}\u0000${canonicalJson(record)}`);
50
+ }
51
+ /** @internal Exposed for the encoding vectors. */
52
+ export function fieldAadBytes(aad) {
53
+ return aadBytes("omo-custody/field-aad/v1", {
54
+ account: text(aad.account),
55
+ provider: text(aad.provider),
56
+ owner: text(aad.owner),
57
+ sourceDigest: text(aad.sourceDigest),
58
+ field: text(aad.field),
59
+ dekId: text(aad.dekId),
60
+ });
61
+ }
62
+ /** @internal Exposed for the encoding vectors. */
63
+ export function wrapAadBytes(aad) {
64
+ return aadBytes("omo-custody/wrap-aad/v1", {
65
+ account: text(aad.account),
66
+ kind: text(aad.kind),
67
+ id: text(aad.id),
68
+ recipientKeyId: text(aad.recipientKeyId),
69
+ });
70
+ }
71
+ /** @internal Exposed for the encoding vectors. */
72
+ export function configAadBytes(aad) {
73
+ return aadBytes("omo-custody/cfg-aad/v1", {
74
+ accountId: text(aad.accountId),
75
+ version: counter(aad.version),
76
+ cfgKeyId: text(aad.cfgKeyId),
77
+ });
78
+ }
79
+ const wrapInfo = (kind) => encoder.encode(`omo-custody/wrap/v1/${kind}`);
80
+ function encodeBase64Url(bytes) {
81
+ let binary = "";
82
+ for (const byte of bytes)
83
+ binary += String.fromCharCode(byte);
84
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
85
+ }
86
+ /** Strict: no padding, no foreign characters, no non-zero trailing bits, so one byte string has one encoding. */
87
+ export function decodeBase64Url(value) {
88
+ if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1)
89
+ throw new SealedFormatError("open_failed");
90
+ const binary = atob(value.replaceAll("-", "+").replaceAll("_", "/"));
91
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
92
+ if (encodeBase64Url(bytes) !== value)
93
+ throw new SealedFormatError("open_failed");
94
+ return bytes;
95
+ }
96
+ async function aesKey(key, usage) {
97
+ if (key.length !== KEY_BYTES)
98
+ throw new SealedFormatError("invalid_key");
99
+ return crypto.subtle.importKey("raw", Uint8Array.from(key), { name: "AES-GCM" }, false, [usage]);
100
+ }
101
+ async function aes256GcmSeal(key, aad, plaintext) {
102
+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
103
+ const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce, additionalData: aad, tagLength: TAG_BYTES * 8 }, await aesKey(key, "encrypt"), plaintext);
104
+ return { nonce: encodeBase64Url(nonce), ct: encodeBase64Url(new Uint8Array(ct)) };
105
+ }
106
+ /** @internal Raw AES-256-GCM open, exposed for the external known-answer vector. Any failure is `open_failed`. */
107
+ export async function aes256GcmOpen(key, nonce, aad, ct) {
108
+ const cryptoKey = await aesKey(key, "decrypt");
109
+ if (nonce.length !== NONCE_BYTES || ct.length < TAG_BYTES)
110
+ throw new SealedFormatError("open_failed");
111
+ try {
112
+ const pt = await crypto.subtle.decrypt({
113
+ name: "AES-GCM",
114
+ iv: Uint8Array.from(nonce),
115
+ additionalData: Uint8Array.from(aad),
116
+ tagLength: TAG_BYTES * 8,
117
+ }, cryptoKey, Uint8Array.from(ct));
118
+ return new Uint8Array(pt);
119
+ }
120
+ catch {
121
+ throw new SealedFormatError("open_failed");
122
+ }
123
+ }
124
+ const openBlob = (key, aad, blob) => aes256GcmOpen(key, decodeBase64Url(blob.nonce), aad, decodeBase64Url(blob.ct));
125
+ function requireVersion1(v) {
126
+ if (v !== 1)
127
+ throw new SealedFormatError("unsupported_version");
128
+ }
129
+ /** Seals one secret under the provider DEK. Empty secrets are never sealed. */
130
+ export async function sealField(dek, aad, secret, meta) {
131
+ if (secret.length === 0)
132
+ throw new SealedFormatError("empty_secret");
133
+ const blob = await aes256GcmSeal(dek, fieldAadBytes(aad), encoder.encode(secret));
134
+ return { v: 1, state: "sealed", ...blob, meta };
135
+ }
136
+ /** Opens a sealed field under the AAD the opener rebuilt from the entry and the requested position. */
137
+ export async function openField(dek, aad, field) {
138
+ requireVersion1(field.v);
139
+ if (field.state !== "sealed")
140
+ throw new SealedFormatError("open_failed");
141
+ return openBlob(dek, fieldAadBytes(aad), field);
142
+ }
143
+ export function sealConfig(kCfg, aad, plaintext) {
144
+ return aes256GcmSeal(kCfg, configAadBytes(aad), Uint8Array.from(plaintext));
145
+ }
146
+ /** Opens the config object; its AAD is rebuilt from the envelope's own public fields. */
147
+ export async function openConfig(kCfg, envelope) {
148
+ requireVersion1(envelope.v);
149
+ return openBlob(kCfg, configAadBytes(envelope), envelope.cfg);
150
+ }
151
+ export async function wrapKey(recipientPublicKey, aad, key) {
152
+ if (key.length === 0)
153
+ throw new SealedFormatError("invalid_key");
154
+ const sealed = await hpkeSealBase(recipientPublicKey, { info: wrapInfo(aad.kind), aad: wrapAadBytes(aad), pt: key });
155
+ return { suite: HPKE_SUITE, enc: encodeBase64Url(sealed.enc), ct: encodeBase64Url(sealed.ct) };
156
+ }
157
+ /** Opens a wrap at `position` with the recipient's own key id; nothing read from the wrap enters the AAD. */
158
+ export async function unwrapKey(recipient, position, wrap) {
159
+ if (wrap.suite !== HPKE_SUITE)
160
+ throw new SealedFormatError("unsupported_version");
161
+ const aad = {
162
+ account: position.account,
163
+ kind: position.kind,
164
+ id: position.id,
165
+ recipientKeyId: recipient.keyId,
166
+ };
167
+ return hpkeOpenBase(recipient, {
168
+ enc: decodeBase64Url(wrap.enc),
169
+ ct: decodeBase64Url(wrap.ct),
170
+ info: wrapInfo(aad.kind),
171
+ aad: wrapAadBytes(aad),
172
+ });
173
+ }
174
+ //# sourceMappingURL=sealed-format.js.map
@@ -0,0 +1,226 @@
1
+ /**
2
+ * The brain-to-hand `model` lane of hosted model-key custody (row CK7; custody design 2.2, 4.3, 4.6, 15.14, 15.15).
3
+ *
4
+ * Message shapes only: the brain's device-proxy egress (CK9), the hand's provider-call executor (CK8), the hook and
5
+ * refresh channels (CK16, CK21) and the publisher (CK12d) speak these. Every request that makes the hand execute
6
+ * something carries a `SignedPlacementClaim`, checked by `verifyPlacementClaim` before anything runs.
7
+ */
8
+ import type { DeviceId, ProviderId, WaitCondition } from "@earendil-works/pi-agent-core";
9
+ import type { AssistantMessageEvent, SimpleStreamOptions, StopReason, TranscriptContext, Usage } from "@earendil-works/pi-ai";
10
+ import type { OpenAiCompactBody } from "../extensions/builtin/compaction/openai-remote-schema.ts";
11
+ import type { UrlSecretField } from "./endpoint.ts";
12
+ import type { SignedPlacementClaim } from "./placement-claim.ts";
13
+ import type { ServiceKeyEntry } from "./roster-entries.ts";
14
+ /**
15
+ * The stream options a brain may send a hand: serializable request shaping only. Never secrets or headers, callbacks,
16
+ * signals or live objects, or the loop-only `commitStep` (4.3, 2.3 options hygiene). `env` stays hand-local because
17
+ * provider adapters resolve the model `baseUrl` from it: a brain-supplied `env` could redirect the hand's dial, with
18
+ * the hand's own API key, to a gateway of the brain's choosing. `cwd` stays hand-local because the start message's
19
+ * `cwd` is the one source the hand interprets.
20
+ */
21
+ export declare const WIRE_STREAM_OPTION_KEYS: readonly ["abortServerSideFallback", "affinitySessionId", "cacheRetention", "deferred", "extraBody", "maxRetries", "maxRetryDelayMs", "maxTokens", "metadata", "reasoning", "refusalFallbacks", "samplingParams", "serviceTier", "sessionAffinityKey", "sessionId", "streamKind", "temperature", "thinkingBudgets", "thinkingSelection", "timeoutMs", "toolChoice", "transport", "websocketConnectTimeoutMs"];
22
+ export type WireStreamOptions = Pick<SimpleStreamOptions, (typeof WIRE_STREAM_OPTION_KEYS)[number]>;
23
+ export type HandLocalStreamOptionKey = Exclude<keyof SimpleStreamOptions, keyof WireStreamOptions>;
24
+ export declare const PROVIDER_CALL_ERROR_CODES: readonly ["auth", "rate_limited", "unavailable", "aborted", "unknown_provider", "local_only_unsupported", "stale_epoch", "claim_expired", "claim_invalid", "registry_key_unknown", "registry_key_revoked", "definition_mismatch"];
25
+ export type ProviderCallErrorCode = (typeof PROVIDER_CALL_ERROR_CODES)[number];
26
+ export type SecretField = "apiKey" | `credential:${number}` | `header:${string}` | `modelHeader:${string}:${string}` | UrlSecretField;
27
+ /** A JSON value, for payloads whose precise schema another row owns (see `HookModelRequestInput`, `WrapTable`). */
28
+ export type WireJson = string | number | boolean | null | readonly WireJson[] | {
29
+ readonly [key: string]: WireJson;
30
+ };
31
+ /** The serializable projection of today's request-hook input; CK21 pins its golden shape. */
32
+ export type HookModelRequestInput = WireJson;
33
+ /** keyId -> recipientKeyId -> HPKE wrap, the wraps-document tables (2.2); CK3 owns the wrap encoding. */
34
+ export type WrapTable = Readonly<Record<string, Readonly<Record<string, WireJson>>>>;
35
+ export type ProviderCallKind = {
36
+ readonly kind: "step";
37
+ readonly stepId: string;
38
+ readonly attempt: number;
39
+ } | {
40
+ readonly kind: "aux";
41
+ readonly auxId: string;
42
+ readonly streamKind: string;
43
+ readonly auxKind: "stream" | "warm" | "compact-endpoint";
44
+ };
45
+ /** Step, aux `stream` and aux `warm` carry the admitted request; `compact-endpoint` carries only the body (15.14). */
46
+ export type ProviderCallRequest = {
47
+ readonly context: TranscriptContext;
48
+ readonly options: WireStreamOptions;
49
+ } | {
50
+ readonly compactBody: OpenAiCompactBody;
51
+ };
52
+ export interface ProviderCallStart {
53
+ readonly t: "provider.call.start";
54
+ readonly callId: string;
55
+ readonly sessionId: string;
56
+ readonly call: ProviderCallKind;
57
+ readonly claim: SignedPlacementClaim;
58
+ readonly providerId: ProviderId;
59
+ readonly modelId: string;
60
+ readonly expectedSourceDigest?: string;
61
+ /** Always `"disabled"` for aux calls. */
62
+ readonly hooks: "enabled" | "disabled";
63
+ readonly cwd?: string;
64
+ readonly request: ProviderCallRequest;
65
+ readonly budget: {
66
+ readonly deadlineMs: number;
67
+ readonly maxOutputTokens?: number;
68
+ };
69
+ }
70
+ /** Sent once, before the dial, when the hand ran the session's request hooks; seq 0 is reserved for it. */
71
+ export interface ProviderCallHooked {
72
+ readonly t: "provider.call.hooked";
73
+ readonly callId: string;
74
+ readonly seq: 0;
75
+ readonly request: ProviderCallRequest;
76
+ }
77
+ export interface ProviderCallEvent {
78
+ readonly t: "provider.call.event";
79
+ readonly callId: string;
80
+ readonly seq: number;
81
+ readonly event: AssistantMessageEvent;
82
+ }
83
+ export interface ProviderCallEnd {
84
+ readonly t: "provider.call.end";
85
+ readonly callId: string;
86
+ readonly seq: number;
87
+ readonly usage: Usage;
88
+ readonly stopReason: StopReason;
89
+ }
90
+ export interface ProviderCallError {
91
+ readonly t: "provider.call.error";
92
+ readonly callId: string;
93
+ readonly seq: number;
94
+ readonly code: ProviderCallErrorCode;
95
+ readonly until?: number;
96
+ readonly retryable: boolean;
97
+ readonly usage?: Usage;
98
+ readonly message: string;
99
+ }
100
+ export interface ProviderCallCancel {
101
+ readonly t: "provider.call.cancel";
102
+ readonly callId: string;
103
+ readonly reason: "user" | "superseded" | "budget" | "shutdown";
104
+ }
105
+ export interface ProviderCallAck {
106
+ readonly t: "provider.call.ack";
107
+ readonly callId: string;
108
+ }
109
+ export interface ProviderCallResultQuery {
110
+ readonly t: "provider.call.result";
111
+ readonly callId: string;
112
+ readonly claim: SignedPlacementClaim;
113
+ }
114
+ export type ProviderCallResultReply = {
115
+ readonly t: "provider.call.result.reply";
116
+ readonly callId: string;
117
+ readonly state: "unknown";
118
+ } | {
119
+ readonly t: "provider.call.result.reply";
120
+ readonly callId: string;
121
+ readonly state: "running";
122
+ readonly lastSeq: number;
123
+ readonly hooked?: ProviderCallHooked;
124
+ } | {
125
+ readonly t: "provider.call.result.reply";
126
+ readonly callId: string;
127
+ readonly state: "done";
128
+ readonly hooked?: ProviderCallHooked;
129
+ readonly events: readonly AssistantMessageEvent[];
130
+ readonly end: ProviderCallEnd | ProviderCallError;
131
+ };
132
+ export interface CustodyRefreshRequest {
133
+ readonly t: "custody.refresh.request";
134
+ readonly providerId: ProviderId;
135
+ readonly field: SecretField;
136
+ readonly version: number;
137
+ readonly claim: SignedPlacementClaim;
138
+ }
139
+ export interface CustodyRefreshAck {
140
+ readonly t: "custody.refresh.ack";
141
+ readonly providerId: ProviderId;
142
+ readonly field: SecretField;
143
+ readonly outcome: "changed" | "unchanged" | "unresolved";
144
+ readonly version?: number;
145
+ }
146
+ /** `recipient`: a device enrolled during a custody kill that needs K_cfg/K_proj wraps from a peer (6.6(d), 3.6). */
147
+ export interface CustodyRewrapRequest {
148
+ readonly t: "custody.rewrap.request";
149
+ readonly dekIds: readonly string[];
150
+ readonly custodyEntry: ServiceKeyEntry;
151
+ readonly recipient?: DeviceId;
152
+ }
153
+ /** Device to device over the hand channel; never sent to the service. */
154
+ export interface CustodyRewrapDeliver {
155
+ readonly t: "custody.rewrap.deliver";
156
+ readonly recipient: DeviceId;
157
+ readonly cfgWraps: WrapTable;
158
+ readonly projWraps: WrapTable;
159
+ readonly projCheck: Readonly<Record<string, string>>;
160
+ readonly sig: string;
161
+ }
162
+ export interface HookModelRequest {
163
+ readonly t: "hook.model_request";
164
+ readonly sessionId: string;
165
+ readonly stepId: string;
166
+ readonly claim: SignedPlacementClaim;
167
+ readonly input: HookModelRequestInput;
168
+ }
169
+ export interface HookModelRequestReply {
170
+ readonly t: "hook.model_request.reply";
171
+ readonly sessionId: string;
172
+ readonly stepId: string;
173
+ readonly output: HookModelRequestInput;
174
+ }
175
+ export interface HandSessionAdvertisement {
176
+ readonly t: "hand.session.advertise";
177
+ readonly sessionId: string;
178
+ readonly connectionId: string;
179
+ readonly cwd: string;
180
+ /** H(extension set hash, cwd, project-local hook set), per session (4.9). */
181
+ readonly advertHash: string;
182
+ readonly requestHooks: readonly ProviderId[] | "*";
183
+ readonly streamHooks: readonly ProviderId[] | "*";
184
+ readonly extensionProviders: readonly ProviderId[];
185
+ readonly handLocalProviders: readonly {
186
+ readonly id: ProviderId;
187
+ readonly models: readonly string[];
188
+ }[];
189
+ readonly handDefines: readonly ProviderId[];
190
+ }
191
+ export interface HandPresence {
192
+ readonly deviceId: DeviceId;
193
+ readonly online: boolean;
194
+ readonly capabilities: readonly string[];
195
+ /** sessionId -> advertHash for the sessions bound to this hand. */
196
+ readonly sessionAdverts?: Readonly<Record<string, string>>;
197
+ /** providerId -> this device's current live sourceDigest for the providers it defines; re-sent on file change. */
198
+ readonly liveDigests?: Readonly<Record<ProviderId, string>>;
199
+ }
200
+ export type StepNotice = {
201
+ readonly t: "step.waiting";
202
+ readonly sessionId: string;
203
+ readonly stepId: string;
204
+ readonly steer: string;
205
+ readonly waitFor: readonly WaitCondition[];
206
+ } | {
207
+ readonly t: "step.resumed";
208
+ readonly sessionId: string;
209
+ readonly stepId: string;
210
+ } | {
211
+ readonly t: "step.reset";
212
+ readonly sessionId: string;
213
+ readonly stepId: string;
214
+ readonly cause: "superseded" | "readmitted";
215
+ };
216
+ export interface ProviderCallChannel {
217
+ start(msg: ProviderCallStart): Promise<void>;
218
+ result(msg: ProviderCallResultQuery): Promise<ProviderCallResultReply>;
219
+ ack(msg: ProviderCallAck): Promise<void>;
220
+ cancel(msg: ProviderCallCancel): Promise<void>;
221
+ /** Resumes from any seq; `fromSeq: 0` includes `hooked`. */
222
+ events(callId: string, fromSeq: number): AsyncIterable<ProviderCallHooked | ProviderCallEvent | ProviderCallEnd | ProviderCallError>;
223
+ readonly presence: AsyncIterable<HandPresence>;
224
+ readonly hooks: AsyncIterable<HandSessionAdvertisement>;
225
+ }
226
+ //# sourceMappingURL=wire.d.ts.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The stream options a brain may send a hand: serializable request shaping only. Never secrets or headers, callbacks,
3
+ * signals or live objects, or the loop-only `commitStep` (4.3, 2.3 options hygiene). `env` stays hand-local because
4
+ * provider adapters resolve the model `baseUrl` from it: a brain-supplied `env` could redirect the hand's dial, with
5
+ * the hand's own API key, to a gateway of the brain's choosing. `cwd` stays hand-local because the start message's
6
+ * `cwd` is the one source the hand interprets.
7
+ */
8
+ export const WIRE_STREAM_OPTION_KEYS = [
9
+ "abortServerSideFallback",
10
+ "affinitySessionId",
11
+ "cacheRetention",
12
+ "deferred",
13
+ "extraBody",
14
+ "maxRetries",
15
+ "maxRetryDelayMs",
16
+ "maxTokens",
17
+ "metadata",
18
+ "reasoning",
19
+ "refusalFallbacks",
20
+ "samplingParams",
21
+ "serviceTier",
22
+ "sessionAffinityKey",
23
+ "sessionId",
24
+ "streamKind",
25
+ "temperature",
26
+ "thinkingBudgets",
27
+ "thinkingSelection",
28
+ "timeoutMs",
29
+ "toolChoice",
30
+ "transport",
31
+ "websocketConnectTimeoutMs",
32
+ ];
33
+ export const PROVIDER_CALL_ERROR_CODES = [
34
+ "auth",
35
+ "rate_limited",
36
+ "unavailable",
37
+ "aborted",
38
+ "unknown_provider",
39
+ "local_only_unsupported",
40
+ "stale_epoch",
41
+ "claim_expired",
42
+ "claim_invalid",
43
+ "registry_key_unknown",
44
+ "registry_key_revoked",
45
+ "definition_mismatch",
46
+ ];
47
+ //# sourceMappingURL=wire.js.map
@@ -0,0 +1,50 @@
1
+ import type { Question } from "./schema.ts";
2
+ /**
3
+ * Escape residue in question text (oh-my-openagent#12797). Some models write non-ASCII tool-call text as
4
+ * `\uXXXX` escapes and then drop the backslash (`ub0b4 Tailscale 쓰기` for `내 Tailscale 쓰기`) or double it, so
5
+ * the decoded string still holds `\ub0b4`. The JSON is valid either way; only the text is wrong.
6
+ *
7
+ * Heuristic, applied to every header, question, option label and option description after quoted code is blanked
8
+ * out (fenced ``` or ~~~ blocks, then ``...`` spans, then `...` spans), since quoted code may name an escape on purpose:
9
+ * - A literal `\uXXXX` whose code point is non-ASCII is residue.
10
+ * - A bare run of `uXXXX` tokens is a candidate only when no ASCII letter, digit, `_` or `\` precedes it and
11
+ * none follows it, so `menu1234`, `u1a2b3c4d` and other hex-like ids never match. A candidate is residue
12
+ * when it decodes to non-ASCII text and either
13
+ * - the run has two or more tokens (`ub0b4uc4f0`), each decoding to a letter, mark, number, punctuation or
14
+ * symbol (a paired surrogate counts), or
15
+ * - its single token decodes to a letter in a script the question set already uses outside ASCII, with
16
+ * Hangul, Han, Hiragana, Katakana and Bopomofo counted as one family (a wrong high digit lands a Hangul
17
+ * syllable in Han). `u1234` in English text, or beside Korean text, decodes to Ethiopic and stays.
18
+ * Known gap: a token glued to a preceding ASCII letter (`cafu00e9`) is not detected.
19
+ * Known limit: an escape named in plain prose outside code (\u00e9 with no backticks) is refused once like residue;
20
+ * its re-call opens as written.
21
+ */
22
+ export type EscapeResidue = {
23
+ field: string;
24
+ token: string;
25
+ };
26
+ export declare function findEscapeResidue(questions: readonly Question[]): EscapeResidue[];
27
+ /**
28
+ * The one-refusal bound, scoped to assistant turns. One assistant message's tool calls run in parallel inside one turn,
29
+ * and the model sees a refusal only after that turn ends. So every residue call in a turn with no open retry is refused,
30
+ * and every residue call in a later turn opens as written with a warning, whether or not its text changed. The retry
31
+ * stays open until a turn uses it, so no question set is refused twice in a row and a re-call can never loop.
32
+ */
33
+ export type EscapeResidueRetry = {
34
+ refusedThisTurn: boolean;
35
+ retryOpen: boolean;
36
+ retriedThisTurn: boolean;
37
+ };
38
+ export declare function freshEscapeResidueRetry(): EscapeResidueRetry;
39
+ /**
40
+ * Records any call; true means refuse it. A call made while the retry is open uses it up, clean or not, so a clean
41
+ * re-call closes the retry and a later residue set is refused once again. A residue call with no open retry is refused.
42
+ */
43
+ export declare function refuseEscapeResidue(retry: EscapeResidueRetry, hasResidue: boolean): boolean;
44
+ export declare function endEscapeResidueTurn(retry: EscapeResidueRetry): void;
45
+ export declare class AskUserEscapeResidueError extends Error {
46
+ readonly residue: EscapeResidue[];
47
+ constructor(residue: EscapeResidue[]);
48
+ }
49
+ export declare function escapeResidueNote(residue: readonly EscapeResidue[]): string;
50
+ //# sourceMappingURL=escape-residue.d.ts.map
@@ -0,0 +1,113 @@
1
+ const BARE_RUN = /(?<![\\A-Za-z0-9_])((?:u[0-9a-fA-F]{4})+)(?![A-Za-z0-9_])/g;
2
+ const LITERAL = /\\u([0-9a-fA-F]{4})/g;
3
+ // Order matters: a fence holds backticks and newlines, and a double-backtick span may hold a single backtick.
4
+ const QUOTED_CODE = [/(```|~~~)[\s\S]*?\1/g, /``[^\n]*?``/g, /`[^`\n]*`/g];
5
+ const NON_ASCII_LETTER = /(?!\p{ASCII})\p{L}/gu;
6
+ const PRINTABLE = /^[\p{L}\p{M}\p{N}\p{P}\p{S}]$/u;
7
+ const CJK_FAMILY = "CJK";
8
+ const SCRIPTS = [
9
+ [CJK_FAMILY, /[\p{Script=Hangul}\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Bopomofo}]/u],
10
+ ["Latin", /\p{Script=Latin}/u],
11
+ ["Cyrillic", /\p{Script=Cyrillic}/u],
12
+ ["Greek", /\p{Script=Greek}/u],
13
+ ["Arabic", /\p{Script=Arabic}/u],
14
+ ["Hebrew", /\p{Script=Hebrew}/u],
15
+ ["Thai", /\p{Script=Thai}/u],
16
+ ["Devanagari", /\p{Script=Devanagari}/u],
17
+ ];
18
+ function scriptOf(char) {
19
+ return SCRIPTS.find(([, pattern]) => pattern.test(char))?.[0];
20
+ }
21
+ function decode(run) {
22
+ const units = [];
23
+ for (let index = 1; index < run.length; index += 5)
24
+ units.push(Number.parseInt(run.slice(index, index + 4), 16));
25
+ return String.fromCharCode(...units);
26
+ }
27
+ function isResidueRun(run, scripts) {
28
+ const decoded = decode(run);
29
+ const chars = Array.from(decoded);
30
+ if (chars.some((char) => (char.codePointAt(0) ?? 0) < 0x80))
31
+ return false;
32
+ if (run.length > 5)
33
+ return chars.every((char) => PRINTABLE.test(char));
34
+ const char = chars[0] ?? "";
35
+ if (!/\p{L}/u.test(char))
36
+ return false;
37
+ const script = scriptOf(char);
38
+ return script !== undefined && scripts.has(script);
39
+ }
40
+ function textFields(questions) {
41
+ const fields = [];
42
+ questions.forEach((question, index) => {
43
+ const at = `questions[${index}]`;
44
+ fields.push([`${at}.header`, question.header], [`${at}.question`, question.question]);
45
+ question.options.forEach((option, optionIndex) => {
46
+ fields.push([`${at}.options[${optionIndex}].label`, option.label]);
47
+ if (option.description !== undefined)
48
+ fields.push([`${at}.options[${optionIndex}].description`, option.description]);
49
+ });
50
+ });
51
+ return fields;
52
+ }
53
+ export function findEscapeResidue(questions) {
54
+ const fields = textFields(questions).map(([field, text]) => [
55
+ field,
56
+ QUOTED_CODE.reduce((masked, pattern) => masked.replace(pattern, (span) => " ".repeat(span.length)), text),
57
+ ]);
58
+ const scripts = new Set();
59
+ for (const [, text] of fields) {
60
+ for (const [char] of text.matchAll(NON_ASCII_LETTER)) {
61
+ const script = scriptOf(char);
62
+ if (script !== undefined)
63
+ scripts.add(script);
64
+ }
65
+ }
66
+ const residue = [];
67
+ for (const [field, text] of fields) {
68
+ for (const match of text.matchAll(LITERAL)) {
69
+ if (Number.parseInt(match[1] ?? "0", 16) >= 0x80)
70
+ residue.push({ field, token: match[0] });
71
+ }
72
+ for (const match of text.matchAll(BARE_RUN)) {
73
+ const run = match[1] ?? "";
74
+ if (isResidueRun(run, scripts))
75
+ residue.push({ field, token: run });
76
+ }
77
+ }
78
+ return residue;
79
+ }
80
+ export function freshEscapeResidueRetry() {
81
+ return { refusedThisTurn: false, retryOpen: false, retriedThisTurn: false };
82
+ }
83
+ /**
84
+ * Records any call; true means refuse it. A call made while the retry is open uses it up, clean or not, so a clean
85
+ * re-call closes the retry and a later residue set is refused once again. A residue call with no open retry is refused.
86
+ */
87
+ export function refuseEscapeResidue(retry, hasResidue) {
88
+ if (retry.retryOpen) {
89
+ retry.retriedThisTurn = true;
90
+ return false;
91
+ }
92
+ if (hasResidue)
93
+ retry.refusedThisTurn = true;
94
+ return hasResidue;
95
+ }
96
+ export function endEscapeResidueTurn(retry) {
97
+ retry.retryOpen = retry.refusedThisTurn || (retry.retryOpen && !retry.retriedThisTurn);
98
+ retry.refusedThisTurn = false;
99
+ retry.retriedThisTurn = false;
100
+ }
101
+ export class AskUserEscapeResidueError extends Error {
102
+ constructor(residue) {
103
+ const found = residue.map(({ field, token }) => `${field} has "${token}"`).join("; ");
104
+ super(`Escape residue in the question text: ${found}. Non-ASCII text was written as a \\uXXXX escape that lost its backslash or kept a literal one, so the user would see the code instead of the character. Call this tool again with the same questions, writing every non-ASCII character directly as UTF-8 text, never as a \\uXXXX escape.`);
105
+ this.name = "AskUserEscapeResidueError";
106
+ this.residue = residue;
107
+ }
108
+ }
109
+ export function escapeResidueNote(residue) {
110
+ const tokens = [...new Set(residue.map(({ token }) => token))].join(", ");
111
+ return `This question may show undecoded character codes (${tokens}); it is shown as the model wrote it.`;
112
+ }
113
+ //# sourceMappingURL=escape-residue.js.map
@@ -1,4 +1,5 @@
1
1
  import { APP_NAME } from "../../../../config.js";
2
+ import { endEscapeResidueTurn, freshEscapeResidueRetry } from "./escape-residue.js";
2
3
  import { pickVariant, TOOL_NAMES } from "./family.js";
3
4
  import { deliverQueuedQuestionOutcomes, getPendingQuestions } from "./registry.js";
4
5
  import { resumeDanglingQuestion } from "./resume.js";
@@ -19,7 +20,7 @@ export default function askUserExtension(pi) {
19
20
  // In TUI mode interactive-mode's text dispatch handles /answer first.
20
21
  },
21
22
  });
22
- const state = { timedOut: false, unavailable: false };
23
+ const state = { timedOut: false, unavailable: false, escapeResidue: freshEscapeResidueRetry() };
23
24
  let registered = false;
24
25
  const cancelPending = (ctx, origin, message, reportDetachedLoss = false) => {
25
26
  for (const entry of getPendingQuestions(ctx.sessionManager.getSessionId()))
@@ -42,6 +43,7 @@ export default function askUserExtension(pi) {
42
43
  pi.on("session_start", async (event, ctx) => {
43
44
  state.timedOut = false;
44
45
  state.unavailable = false;
46
+ state.escapeResidue = freshEscapeResidueRetry();
45
47
  const sessionId = ctx.sessionManager.getSessionId();
46
48
  for (const entry of getPendingQuestions(sessionId))
47
49
  entry.rebind(pi, ctx, state);
@@ -61,8 +63,12 @@ export default function askUserExtension(pi) {
61
63
  pi.on("model_select", async (event, ctx) => {
62
64
  sync(ctx, event.model);
63
65
  });
66
+ pi.on("turn_end", () => {
67
+ endEscapeResidueTurn(state.escapeResidue);
68
+ });
64
69
  pi.on("agent_end", async () => {
65
70
  state.timedOut = false;
71
+ state.escapeResidue = freshEscapeResidueRetry();
66
72
  });
67
73
  pi.on("session_shutdown", async (event, ctx) => {
68
74
  if (event.reason === "reload") {
@@ -1,3 +1,4 @@
1
+ import { freshEscapeResidueRetry } from "./escape-residue.js";
1
2
  import { TOOL_NAMES } from "./family.js";
2
3
  import { parseAskUserAnswerFrame } from "./format.js";
3
4
  import { ASK_USER_SETTLEMENT_ENTRY, emitAskUserClosed } from "./notify.js";
@@ -143,7 +144,7 @@ export async function resumeDanglingQuestion(pi, event, ctx) {
143
144
  continue;
144
145
  }
145
146
  // The runtime registration owns delivery, including after another reload.
146
- void startQuestion(pi, ctx, request, ctx.signal, { timedOut: false, unavailable: false }, dangling.variant, {
147
+ void startQuestion(pi, ctx, request, ctx.signal, { timedOut: false, unavailable: false, escapeResidue: freshEscapeResidueRetry() }, dangling.variant, {
147
148
  resuming: true,
148
149
  });
149
150
  }
@@ -1,8 +1,11 @@
1
1
  import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "../../types.ts";
2
+ import { type EscapeResidueRetry } from "./escape-residue.ts";
2
3
  import { type AskUserVariant, type QuestionRequest, type QuestionResponse } from "./schema.ts";
3
4
  export interface AskUserState {
4
5
  timedOut: boolean;
5
6
  unavailable: boolean;
7
+ /** One refusal per turn of residue calls; the re-calls in a later turn open as written (oh-my-openagent#12797). */
8
+ escapeResidue: EscapeResidueRetry;
6
9
  }
7
10
  /**
8
11
  * Deliver a settled async answer as one framed user message. The extension owns