@code-yeongyu/senpi 2026.10.25-3 → 2026.10.26-1

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 (218) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/bundle/chunks/{anthropic-messages-WBASBTIW.js → anthropic-messages-M235FJQV.js} +3 -3
  3. package/dist/bundle/chunks/{args-B444OQBK.js → args-FBNJOJSR.js} +1 -1
  4. package/dist/bundle/chunks/azure-openai-responses-YQRTQ7H6.js +2 -0
  5. package/dist/bundle/chunks/bedrock-converse-stream.js +3 -3
  6. package/dist/bundle/chunks/{chunk-FFQOEC76.js → chunk-3A3XCLNH.js} +3 -3
  7. package/dist/bundle/chunks/{chunk-EHQXLHRB.js → chunk-3LPWILUZ.js} +2 -2
  8. package/dist/bundle/chunks/{chunk-3BTN337S.js → chunk-3WP5RFQR.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-WLHNEUGD.js → chunk-5FQMNHXV.js} +1 -1
  10. package/dist/bundle/chunks/{chunk-HVJI5T75.js → chunk-5KZH6EYQ.js} +2 -2
  11. package/dist/bundle/chunks/{chunk-4P7WARQZ.js → chunk-5LWUUXMH.js} +1 -1
  12. package/dist/bundle/chunks/{chunk-ODTGC6YU.js → chunk-6U3TBFBA.js} +1 -1
  13. package/dist/bundle/chunks/chunk-6VWIMHMH.js +2 -0
  14. package/dist/bundle/chunks/{chunk-BWA4FVFW.js → chunk-7KOUQSBX.js} +2 -2
  15. package/dist/bundle/chunks/{chunk-7CNOCJCN.js → chunk-ADQAPDPV.js} +1 -1
  16. package/dist/bundle/chunks/{chunk-ITGYN22E.js → chunk-CDQPA4FP.js} +1 -1
  17. package/dist/bundle/chunks/{chunk-WDPQ6CZJ.js → chunk-DMFAVE5N.js} +1 -1
  18. package/dist/bundle/chunks/{chunk-XF3LJC3O.js → chunk-DYWIFF2Q.js} +1 -1
  19. package/dist/bundle/chunks/{chunk-YRF4I43T.js → chunk-EZP6AYW2.js} +37 -31
  20. package/dist/bundle/chunks/chunk-GQJYNFMO.js +2 -0
  21. package/dist/bundle/chunks/{chunk-O3Q2ET6W.js → chunk-GWGNVYPB.js} +1 -1
  22. package/dist/bundle/chunks/{chunk-Z3UYNSVR.js → chunk-H4WJ4LY3.js} +1 -1
  23. package/dist/bundle/chunks/{chunk-UQNRW3VU.js → chunk-HWTD4QF3.js} +1 -1
  24. package/dist/bundle/chunks/{chunk-WD6WBYC5.js → chunk-I7QWTWVZ.js} +1 -1
  25. package/dist/bundle/chunks/{chunk-SWJXRC2I.js → chunk-J2YAYTB2.js} +1 -1
  26. package/dist/bundle/chunks/{chunk-TTU2RNVX.js → chunk-J5FCH7TI.js} +1 -1
  27. package/dist/bundle/chunks/chunk-JFQUWEN7.js +13 -0
  28. package/dist/bundle/chunks/{chunk-JA2R2ZSE.js → chunk-L2O6K3SJ.js} +1 -1
  29. package/dist/bundle/chunks/{chunk-4AOMNOGY.js → chunk-LO5MYLGF.js} +1 -1
  30. package/dist/bundle/chunks/{chunk-P3M2PF6K.js → chunk-MSHRIZGT.js} +8 -6
  31. package/dist/bundle/chunks/{chunk-T36LJ6MW.js → chunk-MY4ZD3EW.js} +1 -1
  32. package/dist/bundle/chunks/{chunk-UFJ2K7Q7.js → chunk-OQ3WDMB3.js} +1 -1
  33. package/dist/bundle/chunks/{chunk-7GZUFOM3.js → chunk-PH3JDRB2.js} +1 -1
  34. package/dist/bundle/chunks/{chunk-E6WYH3ZL.js → chunk-QREKAXKX.js} +7 -5
  35. package/dist/bundle/chunks/{chunk-MJ5NNWX4.js → chunk-QYFOHFPS.js} +1 -1
  36. package/dist/bundle/chunks/chunk-SOXQ42VS.js +2 -0
  37. package/dist/bundle/chunks/{chunk-FHXR4ZAC.js → chunk-UDM7VO6O.js} +1 -1
  38. package/dist/bundle/chunks/{chunk-OLCDIITS.js → chunk-UW6M3EXY.js} +1 -1
  39. package/dist/bundle/chunks/chunk-VICDRG3J.js +410 -0
  40. package/dist/bundle/chunks/chunk-VL7O5PO4.js +5 -0
  41. package/dist/bundle/chunks/chunk-XMJKZK5H.js +2 -0
  42. package/dist/bundle/chunks/{chunk-C5UFJ7B3.js → chunk-XMNPK472.js} +1 -1
  43. package/dist/bundle/chunks/{chunk-GSSD3R5X.js → chunk-XQ45JMGN.js} +1 -1
  44. package/dist/bundle/chunks/{client-WTYEICLH.js → chunk-Y3TMJV3J.js} +3 -3
  45. package/dist/bundle/chunks/{chunk-KXB5WIS5.js → chunk-Z6SAQ5O3.js} +1 -1
  46. package/dist/bundle/chunks/{chunk-CNL6QSUB.js → chunk-ZAUVY6ZC.js} +3 -3
  47. package/dist/bundle/chunks/{chunk-E4LBKOZG.js → chunk-ZLIVZQDY.js} +4 -4
  48. package/dist/bundle/chunks/{chunk-6AOVUTUR.js → chunk-ZXHLH4MW.js} +2 -2
  49. package/dist/bundle/chunks/cli-main-55EQA5PS.js +3 -0
  50. package/dist/bundle/chunks/client-LOS6GSEW.js +2 -0
  51. package/dist/bundle/chunks/{cloudflare-workers-ai-system-one-K5H6BJP3.js → cloudflare-workers-ai-system-one-ZJQGIIRC.js} +1 -1
  52. package/dist/bundle/chunks/cursor-agent.js +3 -3
  53. package/dist/bundle/chunks/devin-agent.js +3 -3
  54. package/dist/bundle/chunks/dispatch-C2IBP5VV.js +9 -0
  55. package/dist/bundle/chunks/google-generative-ai-5G4RIQA6.js +2 -0
  56. package/dist/bundle/chunks/google-vertex-ALD4P27O.js +2 -0
  57. package/dist/bundle/chunks/{help-fast-path-3Z26DQSV.js → help-fast-path-PEIKGCKZ.js} +1 -1
  58. package/dist/bundle/chunks/host-command-657VCQ34.js +2 -0
  59. package/dist/bundle/chunks/{host-lifecycle-ONQIMWMX.js → host-lifecycle-HX3EGRRW.js} +1 -1
  60. package/dist/bundle/chunks/host-lifecycle.js +2 -2
  61. package/dist/bundle/chunks/{host-session-command-J65ENSV5.js → host-session-command-OV5XDOJK.js} +1 -1
  62. package/dist/bundle/chunks/{host-socket-request-3CWE4265.js → host-socket-request-K4LCUOUD.js} +1 -1
  63. package/dist/bundle/chunks/interactive-mode-MLXSYK2H.js +2 -0
  64. package/dist/bundle/chunks/{mcp-presenter-bootstrap-XUSCQVRC.js → mcp-presenter-bootstrap-FOMKF4TU.js} +1 -1
  65. package/dist/bundle/chunks/{mistral-conversations-DH44NOYV.js → mistral-conversations-RAE4K2XY.js} +1 -1
  66. package/dist/bundle/chunks/{multi-session-host-YI2WZOET.js → multi-session-host-774MACJU.js} +5 -6
  67. package/dist/bundle/chunks/{openai-codex-responses-SERAFKAS.js → openai-codex-responses-3CW4R7TG.js} +1 -1
  68. package/dist/bundle/chunks/{openai-completions-BZB5O45A.js → openai-completions-5UIJXQUF.js} +1 -1
  69. package/dist/bundle/chunks/openai-responses-2BO44MRW.js +3 -0
  70. package/dist/bundle/chunks/{package-manager-cli-GHZPJMRF.js → package-manager-cli-MZHVCX2S.js} +1 -1
  71. package/dist/bundle/chunks/{pi-messages-VXATX45B.js → pi-messages-RF6GER4R.js} +2 -2
  72. package/dist/bundle/chunks/{role-JVSBNKGE.js → role-ETSFSDTS.js} +1 -1
  73. package/dist/bundle/chunks/rotation-stream-ZMQB4GPO.js +2 -0
  74. package/dist/bundle/chunks/rpc-mode-UJXOIZCW.js +2 -0
  75. package/dist/bundle/chunks/{rpc-monitor-source-FFEQS744.js → rpc-monitor-source-DRZXJHTL.js} +1 -1
  76. package/dist/bundle/chunks/{schedule-command-YWG6D6ZF.js → schedule-command-KA7LFFNK.js} +1 -1
  77. package/dist/bundle/chunks/session-control-endpoint-MFWXUR7Z.js +3 -0
  78. package/dist/bundle/chunks/session-picker-BP33NRSL.js +2 -0
  79. package/dist/bundle/chunks/session-worker.js +212 -203
  80. package/dist/bundle/chunks/{typesafe-system-one-P75WJ767.js → typesafe-system-one-GNNZ5O5U.js} +1 -1
  81. package/dist/bundle/cli.js +2 -2
  82. package/dist/bundle/host-supervisor.js +2 -2
  83. package/dist/bundle/index.js +1 -1
  84. package/dist/bundle/rpc-entry.js +1 -1
  85. package/dist/bundle/runtime-manifest.json +1 -1
  86. package/dist/cli/host-command-args.d.ts +1 -0
  87. package/dist/cli/host-command-args.js +5 -0
  88. package/dist/cli/host-command.js +7 -1
  89. package/dist/cli.js +21 -3
  90. package/dist/core/extensions/builtin/mcp/catalog-cache.d.ts +2 -1
  91. package/dist/core/extensions/builtin/mcp/catalog-cache.js +19 -6
  92. package/dist/core/extensions/builtin/mcp/catalog.d.ts +3 -2
  93. package/dist/core/extensions/builtin/mcp/catalog.js +20 -3
  94. package/dist/core/extensions/builtin/mcp/elicitation.js +2 -4
  95. package/dist/core/extensions/builtin/mcp/expose/call.d.ts +2 -1
  96. package/dist/core/extensions/builtin/mcp/expose/call.js +2 -1
  97. package/dist/core/extensions/builtin/mcp/expose/register.js +1 -1
  98. package/dist/core/extensions/builtin/mcp/expose/schema-compat.d.ts +2 -2
  99. package/dist/core/extensions/builtin/mcp/expose/schema-compat.js +11 -1
  100. package/dist/core/extensions/builtin/mcp/invocation.js +16 -2
  101. package/dist/core/extensions/builtin/mcp/modern/request.d.ts +1 -1
  102. package/dist/core/extensions/builtin/mcp/modern/request.js +1 -1
  103. package/dist/core/extensions/builtin/mcp/modern/sdk-bridge.js +18 -3
  104. package/dist/core/extensions/builtin/mcp/modern/stdio-probe.js +23 -0
  105. package/dist/core/extensions/builtin/mcp/modern/subscriptions.d.ts +14 -0
  106. package/dist/core/extensions/builtin/mcp/modern/subscriptions.js +226 -0
  107. package/dist/core/extensions/builtin/mcp/modern/tool-schema.d.ts +13 -0
  108. package/dist/core/extensions/builtin/mcp/modern/tool-schema.js +75 -0
  109. package/dist/core/extensions/builtin/mcp/modern/tools.d.ts +107 -0
  110. package/dist/core/extensions/builtin/mcp/modern/tools.js +53 -0
  111. package/dist/core/extensions/builtin/mcp/resources.js +4 -0
  112. package/dist/core/extensions/builtin/mcp/tool-client.d.ts +13 -0
  113. package/dist/core/extensions/builtin/mcp/tool-client.js +17 -0
  114. package/dist/core/extensions/builtin/mcp/tool-contracts.d.ts +19 -0
  115. package/dist/core/extensions/builtin/mcp/tool-contracts.js +2 -0
  116. package/dist/core/extensions/builtin/mcp/wrap.js +2 -2
  117. package/dist/core/extensions/builtin/permission-system/bash-command-segments.d.ts +37 -0
  118. package/dist/core/extensions/builtin/permission-system/bash-command-segments.js +899 -0
  119. package/dist/core/extensions/builtin/permission-system/parsers.d.ts +2 -0
  120. package/dist/core/extensions/builtin/permission-system/parsers.js +44 -10
  121. package/dist/core/session-control-actions.js +0 -2
  122. package/dist/modes/interactive/session-control-attach.d.ts +20 -0
  123. package/dist/modes/interactive/session-control-attach.js +13 -0
  124. package/dist/modes/interactive/session-control-commands.js +37 -10
  125. package/dist/modes/interactive/session-control-registry.d.ts +2 -1
  126. package/dist/modes/interactive/session-control-registry.js +3 -2
  127. package/dist/modes/interactive/session-control-rpc-commands.d.ts +4 -2
  128. package/dist/modes/interactive/session-control-rpc-commands.js +133 -4
  129. package/dist/modes/interactive/session-control-server.js +9 -3
  130. package/dist/modes/rpc/compact-content-store.d.ts +96 -0
  131. package/dist/modes/rpc/compact-content-store.js +269 -0
  132. package/dist/modes/rpc/compact-session-state.d.ts +111 -0
  133. package/dist/modes/rpc/compact-session-state.js +206 -0
  134. package/dist/modes/rpc/compact-spool-dir.d.ts +5 -0
  135. package/dist/modes/rpc/compact-spool-dir.js +61 -0
  136. package/dist/modes/rpc/compact-storage-io.d.ts +27 -0
  137. package/dist/modes/rpc/compact-storage-io.js +19 -0
  138. package/dist/modes/rpc/connection-handler.d.ts +2 -0
  139. package/dist/modes/rpc/connection-handler.js +45 -47
  140. package/dist/modes/rpc/connection-question-bridge.d.ts +2 -1
  141. package/dist/modes/rpc/connection-question-bridge.js +6 -2
  142. package/dist/modes/rpc/custom-capability.d.ts +6 -0
  143. package/dist/modes/rpc/custom-capability.js +6 -0
  144. package/dist/modes/rpc/extension-ui-response.d.ts +7 -2
  145. package/dist/modes/rpc/extension-ui-response.js +27 -7
  146. package/dist/modes/rpc/get-entries-command.d.ts +13 -0
  147. package/dist/modes/rpc/get-entries-command.js +61 -0
  148. package/dist/modes/rpc/get-entries-page.d.ts +22 -0
  149. package/dist/modes/rpc/get-entries-page.js +41 -2
  150. package/dist/modes/rpc/get-snapshot.d.ts +18 -0
  151. package/dist/modes/rpc/get-snapshot.js +43 -0
  152. package/dist/modes/rpc/host-capabilities.js +7 -1
  153. package/dist/modes/rpc/host-ensure-start.js +1 -0
  154. package/dist/modes/rpc/host-ensure-types.d.ts +2 -0
  155. package/dist/modes/rpc/host-ensure.js +1 -0
  156. package/dist/modes/rpc/host-handoff.d.ts +2 -0
  157. package/dist/modes/rpc/host-handover-request.d.ts +1 -1
  158. package/dist/modes/rpc/host-handover-request.js +4 -3
  159. package/dist/modes/rpc/host-handover-wire.js +6 -0
  160. package/dist/modes/rpc/host-idle-handover.d.ts +2 -0
  161. package/dist/modes/rpc/host-idle-handover.js +3 -1
  162. package/dist/modes/rpc/host-observe-request.d.ts +2 -1
  163. package/dist/modes/rpc/host-observe-request.js +5 -1
  164. package/dist/modes/rpc/host-runner.d.ts +1 -0
  165. package/dist/modes/rpc/host-runner.js +9 -7
  166. package/dist/modes/rpc/host-successor.js +4 -0
  167. package/dist/modes/rpc/json-normal-value.d.ts +6 -0
  168. package/dist/modes/rpc/json-normal-value.js +100 -0
  169. package/dist/modes/rpc/mcp-presenter-bootstrap.js +5 -0
  170. package/dist/modes/rpc/media-placeholders.d.ts +9 -3
  171. package/dist/modes/rpc/media-placeholders.js +12 -4
  172. package/dist/modes/rpc/rpc-host-command-types.d.ts +2 -0
  173. package/dist/modes/rpc/rpc-question-types.d.ts +1 -0
  174. package/dist/modes/rpc/rpc-read-response-types.d.ts +9 -0
  175. package/dist/modes/rpc/rpc-session-command-types.d.ts +14 -1
  176. package/dist/modes/rpc/rpc-session-state-types.d.ts +3 -0
  177. package/dist/modes/rpc/rpc-session-state.js +2 -0
  178. package/dist/modes/rpc/rpc-types.d.ts +1 -0
  179. package/dist/modes/rpc/session-attachment-gate.js +1 -0
  180. package/dist/modes/rpc/session-binding.js +21 -18
  181. package/dist/modes/rpc/session-command-activity.js +1 -0
  182. package/dist/modes/rpc/session-event-fanout.d.ts +1 -1
  183. package/dist/modes/rpc/session-event-fanout.js +17 -9
  184. package/dist/modes/rpc/session-event-writer.js +22 -5
  185. package/dist/modes/rpc/session-extension-ui-requests.d.ts +2 -1
  186. package/dist/modes/rpc/session-extension-ui-requests.js +15 -10
  187. package/dist/modes/rpc/session-held.js +1 -0
  188. package/dist/modes/rpc/session-status-stream.d.ts +2 -1
  189. package/dist/modes/rpc/session-status-stream.js +8 -2
  190. package/dist/modes/rpc/session-status-subscriptions.d.ts +2 -1
  191. package/dist/modes/rpc/session-status-subscriptions.js +4 -0
  192. package/dist/modes/rpc/session-worker.js +5 -2
  193. package/dist/modes/rpc/snapshot-types.d.ts +29 -0
  194. package/dist/modes/rpc/snapshot-types.js +2 -0
  195. package/dist/modes/rpc/ui-answer-ledger.d.ts +13 -0
  196. package/dist/modes/rpc/ui-answer-ledger.js +34 -0
  197. package/dist/senpi +21 -3
  198. package/docs/mcp.md +20 -0
  199. package/docs/rpc.md +87 -11
  200. package/package.json +7 -6
  201. package/dist/bundle/chunks/azure-openai-responses-7I45DI72.js +0 -2
  202. package/dist/bundle/chunks/chunk-3LNSLJ7N.js +0 -2
  203. package/dist/bundle/chunks/chunk-6O4SEW2W.js +0 -5
  204. package/dist/bundle/chunks/chunk-7W7OJDIY.js +0 -410
  205. package/dist/bundle/chunks/chunk-DMI55M2N.js +0 -2
  206. package/dist/bundle/chunks/chunk-H3HNMY2W.js +0 -13
  207. package/dist/bundle/chunks/chunk-MSQCTH6V.js +0 -2
  208. package/dist/bundle/chunks/cli-main-HEKFFB5S.js +0 -3
  209. package/dist/bundle/chunks/dispatch-NYAULNVW.js +0 -9
  210. package/dist/bundle/chunks/google-generative-ai-UM5Y7T6B.js +0 -2
  211. package/dist/bundle/chunks/google-vertex-OKU2VQHL.js +0 -2
  212. package/dist/bundle/chunks/host-command-Z3WPHJOW.js +0 -2
  213. package/dist/bundle/chunks/interactive-mode-DEPQC6TX.js +0 -2
  214. package/dist/bundle/chunks/openai-responses-TNHQ6SJS.js +0 -3
  215. package/dist/bundle/chunks/rotation-stream-ZPVCFOVD.js +0 -2
  216. package/dist/bundle/chunks/rpc-mode-ORHBGBBV.js +0 -2
  217. package/dist/bundle/chunks/session-control-endpoint-36HAQSQO.js +0 -3
  218. package/dist/bundle/chunks/session-picker-25H5KNYP.js +0 -2
@@ -7,6 +7,8 @@ export type PermissionRequest = Pick<Request, "permission" | "patterns" | "alway
7
7
  };
8
8
  /** Parser function that extracts permission requests from tool input */
9
9
  export type ToolPermissionParser = (toolName: string, input: Record<string, unknown>, cwd: string) => PermissionRequest[];
10
+ /** Marks a bash request target that no command-specific rule can match: only a `*` rule decides it. */
11
+ export declare const UNPARSED_BASH_PREFIX = "(unparsed) ";
10
12
  /** The `eval` class a cell carries on its own, used when its tool-owned parser names anything narrower. */
11
13
  export declare function fallbackEvalPermissionRequest(): PermissionRequest;
12
14
  /**
@@ -2,6 +2,7 @@ import { dirname, resolve } from "node:path";
2
2
  import { realpathWithoutOpen } from "../../../../utils/paths.js";
3
3
  import { extractPatchedPaths } from "../gpt-apply-patch/index.js";
4
4
  import { BashArity } from "../permission-system/arity.js";
5
+ import { analyzeBashCommand, bestEffortCommandLines } from "../permission-system/bash-command-segments.js";
5
6
  import { extractExternalPaths, isExternalPath, toParentDirectoryPattern, } from "../permission-system/external-dir.js";
6
7
  import { setApprovedMonitorParent } from "../terminal/monitor-permission.js";
7
8
  import { urlOriginAlias, widerAlwaysScope } from "./always-scope.js";
@@ -73,6 +74,48 @@ function withExternalDirectoryRequests(requests, paths, cwd, scope) {
73
74
  },
74
75
  ];
75
76
  }
77
+ const uniq = (values) => [...new Set(values)];
78
+ /**
79
+ * The targets an "Allow always" answer may store. Stored rules are matched as globs, so a target holding
80
+ * its own `*` or `?` would be remembered as a wildcard that matches other commands; such a target is
81
+ * allowed once and never stored. Request patterns stay raw, so existing rules match exactly as before.
82
+ */
83
+ const rememberable = (targets) => targets.filter((target) => !/[*?]/.test(target));
84
+ /** Marks a bash request target that no command-specific rule can match: only a `*` rule decides it. */
85
+ export const UNPARSED_BASH_PREFIX = "(unparsed) ";
86
+ /**
87
+ * The `bash` requests for one command. Every simple command it runs is decided, so the call is as
88
+ * strict as its strictest command: one pattern per command (its arity prefix), all of which must be
89
+ * allowed, and "Allow always" stores exactly those prefixes. A command the lexer cannot parse is one
90
+ * target only a `*` rule (a preset, a blanket setting) can decide, so no grant for one command can
91
+ * allow it; its plain words are still checked against deny rules.
92
+ */
93
+ function bashCommandRequests(command) {
94
+ const analysis = analyzeBashCommand(command);
95
+ if (analysis.kind === "unparsed") {
96
+ const target = `${UNPARSED_BASH_PREFIX}${command}`;
97
+ const candidates = uniq(bestEffortCommandLines(command)
98
+ .map((words) => BashArity.prefix(words).join(" "))
99
+ .filter(Boolean));
100
+ // The deny-only check runs first, so a denied call is refused before anyone is asked.
101
+ return [
102
+ ...(candidates.length === 0
103
+ ? []
104
+ : [{ permission: "bash", patterns: candidates, always: [], autoApproveAsk: true }]),
105
+ { permission: "bash", patterns: [target], always: rememberable([target]) },
106
+ ];
107
+ }
108
+ const prefixes = uniq(analysis.segments.map((words) => BashArity.prefix([...words]).join(" ")).filter(Boolean));
109
+ if (prefixes.length === 0)
110
+ return [{ permission: "bash", patterns: [command], always: rememberable([command]) }];
111
+ return [
112
+ {
113
+ permission: "bash",
114
+ patterns: prefixes,
115
+ always: rememberable(prefixes).flatMap((prefix) => [prefix, `${prefix} *`]),
116
+ },
117
+ ];
118
+ }
76
119
  /** The `eval` class a cell carries on its own, used when its tool-owned parser names anything narrower. */
77
120
  export function fallbackEvalPermissionRequest() {
78
121
  return { permission: "eval", patterns: ["*"], always: ["*"] };
@@ -135,16 +178,7 @@ export function createBuiltinParserRegistry() {
135
178
  if (!command) {
136
179
  return [fallbackPermissionRequest("bash")];
137
180
  }
138
- const tokens = command.split(/\s+/).filter(Boolean);
139
- const prefix = BashArity.prefix(tokens).join(" ");
140
- const always = prefix ? [prefix, `${prefix} *`] : ["*"];
141
- const requests = [
142
- {
143
- permission: "bash",
144
- patterns: [prefix || command],
145
- always,
146
- },
147
- ];
181
+ const requests = bashCommandRequests(command);
148
182
  const externalPaths = extractExternalPaths(command, cwd);
149
183
  if (externalPaths.length > 0) {
150
184
  requests.push({
@@ -10,8 +10,6 @@ export function createSessionControlActions(deps) {
10
10
  let retired = false;
11
11
  return {
12
12
  registerControlEndpoint: async (options) => {
13
- if (process.platform === "win32")
14
- return { status: "unsupported", reason: "unsupported_platform" };
15
13
  const host = deps.host();
16
14
  if (host === undefined)
17
15
  return { status: "unsupported", reason: "unsupported_mode" };
@@ -7,12 +7,19 @@
7
7
  *
8
8
  * The terminal itself is one attachment; each attached client adds one. A client that only subscribed
9
9
  * (mirror included) is not an attachment.
10
+ *
11
+ * It also keeps what a host keeps per connection and per session for the drive set (senpi#4267): the roles each
12
+ * connection declared with `set_client_info`, and the admission receipt of every input sent with a `clientMessageId`,
13
+ * so a retry is answered from its receipt instead of being delivered twice.
10
14
  */
11
15
  import type { AgentSession } from "../../core/agent-session.ts";
16
+ import { ConnectionRoles } from "../rpc/connection-roles.ts";
12
17
  import { SessionEventWriter } from "../rpc/session-event-writer.ts";
13
18
  import type { ControlConnection } from "./session-control-server.ts";
14
19
  export declare class ControlAttachments {
15
20
  readonly writer: SessionEventWriter;
21
+ readonly roles: ConnectionRoles;
22
+ readonly receipts: Map<string, TerminalInputReceipt>;
16
23
  private readonly attached;
17
24
  private readonly stopRecords;
18
25
  private readonly session;
@@ -21,6 +28,8 @@ export declare class ControlAttachments {
21
28
  get handle(): string;
22
29
  opened(connection: ControlConnection): void;
23
30
  closed(connection: ControlConnection): void;
31
+ /** Drops this connection's attachment (`release_attachment`); false when it was not attached. */
32
+ detach(connection: ControlConnection): boolean;
24
33
  /** Attaches the connection: it is replayed the in-flight snapshot, then receives the session's records. */
25
34
  attach(connection: ControlConnection): boolean;
26
35
  isAttached(connection: ControlConnection): boolean;
@@ -30,6 +39,17 @@ export declare class ControlAttachments {
30
39
  dispose(): void;
31
40
  private entryView;
32
41
  }
42
+ /** The receipt a terminal answers an input sent with a `clientMessageId` with, and answers again on a retry. */
43
+ export interface TerminalInputReceipt {
44
+ /** What was sent: a retry with the same id and different content is a conflict, as on a host. */
45
+ readonly fingerprint: string;
46
+ readonly disposition: "started" | "queued";
47
+ readonly admission: {
48
+ readonly clientMessageId: string;
49
+ readonly durableSessionId: string;
50
+ readonly disposition: "started" | "queued";
51
+ };
52
+ }
33
53
  /** The writer's id for one control connection. */
34
54
  export declare function keyOf(connection: ControlConnection): string;
35
55
  //# sourceMappingURL=session-control-attach.d.ts.map
@@ -1,9 +1,12 @@
1
+ import { ConnectionRoles } from "../rpc/connection-roles.js";
1
2
  import { SessionEventWriter } from "../rpc/session-event-writer.js";
2
3
  import { subscribeSessionRecords } from "../rpc/session-record-stream.js";
3
4
  import { sessionRuntimeView } from "../rpc/session-status-source.js";
4
5
  export class ControlAttachments {
5
6
  constructor(session, pendingQuestions) {
6
7
  this.writer = new SessionEventWriter(() => { });
8
+ this.roles = new ConnectionRoles();
9
+ this.receipts = new Map();
7
10
  this.attached = new Set();
8
11
  this.session = session;
9
12
  this.writer.status.setSource({
@@ -29,8 +32,18 @@ export class ControlAttachments {
29
32
  const key = keyOf(connection);
30
33
  if (this.attached.delete(key))
31
34
  this.writer.status.publish(this.handle, "detach", "client");
35
+ this.roles.release(key);
32
36
  this.writer.unregisterConnection(key);
33
37
  }
38
+ /** Drops this connection's attachment (`release_attachment`); false when it was not attached. */
39
+ detach(connection) {
40
+ const key = keyOf(connection);
41
+ if (!this.attached.delete(key))
42
+ return false;
43
+ this.writer.detachConnectionFromSession(key, this.handle);
44
+ this.writer.status.publish(this.handle, "detach", "client");
45
+ return true;
46
+ }
34
47
  /** Attaches the connection: it is replayed the in-flight snapshot, then receives the session's records. */
35
48
  attach(connection) {
36
49
  const key = keyOf(connection);
@@ -2,16 +2,18 @@
2
2
  * The terminal control endpoint's command surface. It answers who it is, which one session it holds
3
3
  * and that session's state and messages, renames it, streams its feed, wakes its inbox drain, relays
4
4
  * an answer to a question the session itself asked, and applies the pane's own session controls (a
5
- * model, a thinking level, an interrupt - `session-control-session-commands.ts`). Nothing here can
6
- * prompt, steer, queue a follow-up, open a session or run a command - one delivery authority (the
7
- * registrant's drain) applies messages, through admission, and only there. Every other command is
8
- * answered `unsupported` as data; `get_protocol_info` lists the accepted ones in `commands`.
5
+ * model, a thinking level, an interrupt - `session-control-session-commands.ts`), and serves the host's
6
+ * observe-and-drive set over its one session (`session-control-rpc-commands.ts`, senpi#3229, senpi#4267). Input
7
+ * goes through the session's admission and only there. Every other command is answered `unsupported` as data;
8
+ * `get_protocol_info` lists the accepted ones in `commands`.
9
9
  */
10
10
  import { VERSION } from "../../config.js";
11
11
  import { engineBuildIdentity } from "../../core/engine-build-identity.js";
12
- import { SESSION_STATUS_CAPABILITY } from "../rpc/custom-capability.js";
12
+ import { ENTRIES_TAIL_CAPABILITY, OBSERVE_READS_CAPABILITY, SESSION_STATUS_CAPABILITY, TUI_OBSERVER_CAPABILITY, } from "../rpc/custom-capability.js";
13
+ import { answerGetEntries } from "../rpc/get-entries-command.js";
13
14
  import { answeredUiRequestId, settledQuestionStatus, unansweredQuestionIds } from "../rpc/extension-ui-response.js";
14
15
  import { buildRpcSessionState } from "../rpc/rpc-session-state.js";
16
+ import { sessionUiAnswerLedger } from "../rpc/ui-answer-ledger.js";
15
17
  import { runTuiRpcCommand, TUI_RPC_COMMANDS } from "./session-control-rpc-commands.js";
16
18
  import { failure, success } from "./session-control-server.js";
17
19
  import { runSessionControlCommand, SESSION_CONTROL_COMMANDS, } from "./session-control-session-commands.js";
@@ -21,6 +23,7 @@ const TUI_CONTROL_COMMANDS = [
21
23
  "list_sessions",
22
24
  "get_state",
23
25
  "get_messages",
26
+ "get_entries",
24
27
  "set_session_name",
25
28
  "subscribe",
26
29
  "wake",
@@ -37,7 +40,15 @@ export async function runControlCommand(context, connection, command) {
37
40
  return success(id, type, {
38
41
  protocolVersion: 1,
39
42
  serverVersion: VERSION,
40
- capabilities: [TUI_CONTROL_CAPABILITY, SESSION_STATUS_CAPABILITY],
43
+ capabilities: [
44
+ TUI_CONTROL_CAPABILITY,
45
+ SESSION_STATUS_CAPABILITY,
46
+ TUI_OBSERVER_CAPABILITY,
47
+ OBSERVE_READS_CAPABILITY,
48
+ ENTRIES_TAIL_CAPABILITY,
49
+ "snapshot_cursor",
50
+ "ui_answer_ledger",
51
+ ],
41
52
  mode: "tui",
42
53
  endpoint_kind: "tui",
43
54
  instanceId: context.instanceId,
@@ -60,6 +71,8 @@ export async function runControlCommand(context, connection, command) {
60
71
  });
61
72
  case "get_messages":
62
73
  return success(id, type, { messages: session.messages });
74
+ case "get_entries":
75
+ return answerGetEntries(session, command);
63
76
  case "set_session_name": {
64
77
  const name = typeof command.name === "string" ? command.name.trim() : "";
65
78
  if (name === "")
@@ -79,7 +92,7 @@ export async function runControlCommand(context, connection, command) {
79
92
  return success(id, type, { admitted: result.admitted ?? [] });
80
93
  }
81
94
  case "extension_ui_response":
82
- return answerQuestion(context.surface, command);
95
+ return answerQuestion(context, command);
83
96
  default: {
84
97
  const rpc = await runTuiRpcCommand(context, connection, command);
85
98
  if (rpc !== undefined)
@@ -111,12 +124,25 @@ function sessionRow(session) {
111
124
  * settles by the host's rule (`settledQuestionStatus`), so the same frame reaches the model as the same
112
125
  * message on either surface.
113
126
  */
114
- function answerQuestion(surface, command) {
127
+ function answerQuestion(context, command) {
128
+ const { surface, session } = context;
129
+ const ledger = sessionUiAnswerLedger(session);
130
+ const answerId = command.answerId;
131
+ if (answerId !== undefined && (typeof answerId !== "string" || answerId.length < 1 || answerId.length > 256))
132
+ return failure(command.id, "extension_ui_response", "invalid_answer_id");
115
133
  const frameId = command.id;
116
134
  const requestId = answeredUiRequestId(command);
135
+ const receipt = requestId === undefined || answerId === undefined ? undefined : ledger.lookup(requestId, answerId);
136
+ if (receipt === "replay")
137
+ return { ...success(frameId, "extension_ui_response"), disposition: receipt };
138
+ if (receipt === "already_answered")
139
+ return { ...failure(frameId, "extension_ui_response", "already_answered"), disposition: receipt };
117
140
  const request = requestId === undefined ? undefined : surface.pendingQuestion(requestId);
118
141
  if (requestId === undefined || request === undefined) {
119
- return failure(frameId, "extension_ui_response", "unknown_request");
142
+ return {
143
+ ...failure(frameId, "extension_ui_response", answerId === undefined ? "unknown_request" : "unknown_extension_ui_request"),
144
+ disposition: "unknown",
145
+ };
120
146
  }
121
147
  const response = questionResponse(command, request.questions);
122
148
  if (response === "question_incomplete")
@@ -124,7 +150,8 @@ function answerQuestion(surface, command) {
124
150
  if (response === undefined || !surface.answerQuestion(requestId, response)) {
125
151
  return failure(frameId, "extension_ui_response", "invalid_response");
126
152
  }
127
- return success(frameId, "extension_ui_response");
153
+ ledger.record(requestId, answerId);
154
+ return { ...success(frameId, "extension_ui_response"), disposition: "applied" };
128
155
  }
129
156
  function questionResponse(command, questions) {
130
157
  // Another client's cancel (the desktop's Remove) is the user's; the builtin settles it from its own draft.
@@ -3,7 +3,8 @@ import { type HostDaemonPaths } from "../rpc/host-daemon-paths.ts";
3
3
  * `<agentDir>/rpc/tui/t-<16hex>.sock`, or - when the agent directory is too deep for `sun_path` -
4
4
  * the same name under `/tmp/senpi-rpc-<sha256(agentDir)[:8]>/tui/`. The fallback root is
5
5
  * predictable, so it is used only when it is a real directory owned by this user with no group or
6
- * other access; anything else fails the registration instead of binding into it.
6
+ * other access; anything else fails the registration instead of binding into it. On win32 the path is only the
7
+ * logical address (the listener binds a named pipe hashed from it), so it never moves (senpi#4267).
7
8
  */
8
9
  export declare function resolveTuiSocket(agentDir: string, instanceId: string): Promise<string>;
9
10
  export interface TuiRegistryEntry {
@@ -27,12 +27,13 @@ const REGISTRY_LOCK_WAIT_MS = 5_000;
27
27
  * `<agentDir>/rpc/tui/t-<16hex>.sock`, or - when the agent directory is too deep for `sun_path` -
28
28
  * the same name under `/tmp/senpi-rpc-<sha256(agentDir)[:8]>/tui/`. The fallback root is
29
29
  * predictable, so it is used only when it is a real directory owned by this user with no group or
30
- * other access; anything else fails the registration instead of binding into it.
30
+ * other access; anything else fails the registration instead of binding into it. On win32 the path is only the
31
+ * logical address (the listener binds a named pipe hashed from it), so it never moves (senpi#4267).
31
32
  */
32
33
  export async function resolveTuiSocket(agentDir, instanceId) {
33
34
  const name = tuiSocketName(instanceId);
34
35
  const primary = join(agentDir, "rpc", "tui", name);
35
- if (Buffer.byteLength(primary) <= MAX_SOCKET_PATH_BYTES) {
36
+ if (process.platform === "win32" || Buffer.byteLength(primary) <= MAX_SOCKET_PATH_BYTES) {
36
37
  await makePrivateDirectory(dirname(primary));
37
38
  return primary;
38
39
  }
@@ -1,8 +1,10 @@
1
1
  import type { AgentSession } from "../../core/agent-session.ts";
2
2
  import { type ControlAttachments } from "./session-control-attach.ts";
3
- import type { ControlCommand, ControlConnection } from "./session-control-server.ts";
4
- export declare const TUI_RPC_COMMANDS: readonly ["open_session", "subscribe_sessions", "unsubscribe_sessions", "steer", "follow_up", "prompt", "get_effect_status"];
3
+ import { type ControlCommand, type ControlConnection } from "./session-control-server.ts";
4
+ export declare const TUI_RPC_COMMANDS: readonly ["open_session", "get_snapshot", "subscribe_sessions", "unsubscribe_sessions", "steer", "follow_up", "prompt", "get_effect_status", "set_client_info", "attach_session", "release_attachment", "abort"];
5
5
  export interface RpcCommandContext {
6
+ /** This endpoint's instance id: the `expectedHostInstance` an `attach_session` may name. */
7
+ readonly instanceId: string;
6
8
  readonly session: AgentSession;
7
9
  readonly attachments: ControlAttachments;
8
10
  }
@@ -3,28 +3,60 @@
3
3
  * attaches by path or id, `subscribe_sessions`/`unsubscribe_sessions` run the host's status stream over
4
4
  * this one session, `steer`/`follow_up`/`prompt` go through the session's external admission (the
5
5
  * terminal's one delivery authority, so the user's draft still holds them) with the host's `whenIdle`
6
- * meaning, and `get_effect_status` reads the same effect ledger. A frame this file does not own answers
6
+ * meaning, and `get_effect_status` reads the same effect ledger. The rest of the host's drive set answers with the
7
+ * host's contract (senpi#4267): `set_client_info` declares the connection's roles, `attach_session` and
8
+ * `release_attachment` take and drop a live attachment on the session's identity, `abort` runs the host's effect
9
+ * path and its role rule (a Stop needs `interactive_user`), and input sent with a `clientMessageId` is answered
10
+ * with an admission receipt that a retry gets again, marked `replayed`. A frame this file does not own answers
7
11
  * `undefined` and falls through to the endpoint's other commands.
8
12
  */
9
13
  import { randomUUID } from "node:crypto";
10
14
  import { realpathSync } from "node:fs";
11
15
  import { resolve } from "node:path";
12
- import { runGetEffectStatus } from "../rpc/effect-commands.js";
16
+ import { CONNECTION_ROLE_FIXED } from "../rpc/connection-roles.js";
17
+ import { INTERACTIVE_USER_CAPABILITY } from "../rpc/custom-capability.js";
18
+ import { runAbort, runGetEffectStatus } from "../rpc/effect-commands.js";
13
19
  import { buildRpcSessionState } from "../rpc/rpc-session-state.js";
20
+ import { getSnapshot } from "../rpc/get-snapshot.js";
14
21
  import { answerSubscribeSessions, answerUnsubscribeSessions, refusal } from "../rpc/session-status-commands.js";
15
22
  import { keyOf } from "./session-control-attach.js";
23
+ import { failure, success } from "./session-control-server.js";
16
24
  export const TUI_RPC_COMMANDS = [
17
25
  "open_session",
26
+ "get_snapshot",
18
27
  "subscribe_sessions",
19
28
  "unsubscribe_sessions",
20
29
  "steer",
21
30
  "follow_up",
22
31
  "prompt",
23
32
  "get_effect_status",
33
+ "set_client_info",
34
+ "attach_session",
35
+ "release_attachment",
36
+ "abort",
24
37
  ];
38
+ /** The host's answer to a Stop from a connection that never declared the user's role (senpi#3811). */
39
+ const STOP_REQUIRES_INTERACTIVE_CLIENT = "stop_requires_interactive_client: declare interactive_user with set_client_info first";
25
40
  /** `null` = answered on the writer already; `undefined` = not a command this file owns. */
26
41
  export async function runTuiRpcCommand(context, connection, command) {
27
42
  switch (command.type) {
43
+ case "get_snapshot": {
44
+ const { attachments, session } = context;
45
+ if (command.sessionId !== undefined && command.sessionId !== attachments.handle)
46
+ return {
47
+ id: command.id,
48
+ type: "response",
49
+ command: command.type,
50
+ success: false,
51
+ error: "unknown_session",
52
+ };
53
+ const snapshot = getSnapshot(session, { tail: command.tail, maxBytes: command.maxBytes }, {
54
+ enqueue: (markerId) => attachments.run(connection, () => attachments.writer.enqueue(attachments.handle, { type: "snapshot_marker", markerId })),
55
+ });
56
+ return "error" in snapshot
57
+ ? { id: command.id, type: "response", command: command.type, success: false, error: snapshot.error }
58
+ : answered(idOf(command), command.type, snapshot.data);
59
+ }
28
60
  case "open_session":
29
61
  return openSession(context, connection, command);
30
62
  case "subscribe_sessions":
@@ -36,6 +68,14 @@ export async function runTuiRpcCommand(context, connection, command) {
36
68
  return admitInput(context, command, command.type === "steer" ? "steer" : "followUp", command.whenIdle);
37
69
  case "prompt":
38
70
  return admitPrompt(context, command);
71
+ case "set_client_info":
72
+ return setClientInfo(context, connection, command);
73
+ case "attach_session":
74
+ return attachSession(context, connection, command);
75
+ case "release_attachment":
76
+ return releaseAttachment(context, connection, command);
77
+ case "abort":
78
+ return abort(context, connection, frame(command));
39
79
  case "get_effect_status":
40
80
  return runGetEffectStatus({
41
81
  session: context.session,
@@ -74,6 +114,68 @@ function openSession(context, connection, command) {
74
114
  });
75
115
  return null;
76
116
  }
117
+ /** Declares this connection's roles, fixed once declared, exactly as on a host; the other fields change nothing here. */
118
+ function setClientInfo(context, connection, command) {
119
+ const capabilities = command.capabilities;
120
+ if (capabilities === undefined)
121
+ return success(command.id, "set_client_info");
122
+ if (!Array.isArray(capabilities) || !capabilities.every((entry) => typeof entry === "string"))
123
+ return failure(command.id, "set_client_info", "capabilities must be an array of strings");
124
+ if (!context.attachments.roles.declare(keyOf(connection), capabilities))
125
+ return failure(command.id, "set_client_info", CONNECTION_ROLE_FIXED);
126
+ return success(command.id, "set_client_info");
127
+ }
128
+ /**
129
+ * A live attachment on the session's identity, as on a host: `sessionId` is this terminal's routing handle,
130
+ * `durableSessionId` its session id, and `expectedHostInstance`, when sent, this endpoint's instance id. The answer
131
+ * follows the replay of the in-flight snapshot, then the session's records reach the connection.
132
+ */
133
+ function attachSession(context, connection, command) {
134
+ const { session, attachments } = context;
135
+ const id = idOf(command);
136
+ if (command.sessionId !== attachments.handle)
137
+ return refusal(id, "attach_session", "unknown_session", {
138
+ reason: "this terminal hosts one session and the request names another",
139
+ action: "attach with this terminal's sessionId from list_sessions",
140
+ });
141
+ const instanceMatches = command.expectedHostInstance === undefined || command.expectedHostInstance === context.instanceId;
142
+ if (command.durableSessionId !== session.sessionId || !instanceMatches)
143
+ return refusal(id, "attach_session", "session_identity_mismatch", {
144
+ reason: "durableSessionId or expectedHostInstance does not name this terminal's session",
145
+ action: "read list_sessions and get_protocol_info again, then attach with their ids",
146
+ });
147
+ attachments.run(connection, () => {
148
+ attachments.attach(connection);
149
+ attachments.writer.enqueue(attachments.handle, {
150
+ id: command.id,
151
+ type: "response",
152
+ command: "attach_session",
153
+ success: true,
154
+ data: { sessionId: attachments.handle, attached: true },
155
+ });
156
+ });
157
+ return null;
158
+ }
159
+ function releaseAttachment(context, connection, command) {
160
+ if (command.sessionId !== context.attachments.handle || !context.attachments.detach(connection))
161
+ return refusal(idOf(command), "release_attachment", "session_not_attached", {
162
+ reason: "this connection is not attached to that session",
163
+ action: "attach first with attach_session or open_session",
164
+ });
165
+ // Nothing outlives the terminal's own attachment, so a release is never deferred.
166
+ return success(command.id, "release_attachment", { deferred: false });
167
+ }
168
+ /** The host's `abort`: the same effect receipts, and a Stop only from a connection that declared `interactive_user`. */
169
+ async function abort(context, connection, command) {
170
+ const key = keyOf(connection);
171
+ if (command.intent === "stop" && !context.attachments.roles.has(key, INTERACTIVE_USER_CAPABILITY))
172
+ return failure(command.id, "abort", STOP_REQUIRES_INTERACTIVE_CLIENT);
173
+ const { session } = context;
174
+ return runAbort({ session, by: { connectionId: key }, sessionId: context.attachments.handle }, command, () => (command.intent === "stop" ? session.stop() : session.abort()).catch((cause) => {
175
+ // Reported out of band, as on a host; the answer never waits on it.
176
+ connection.send({ type: "rpc_error", error: String(cause) });
177
+ }));
178
+ }
77
179
  function subscribe(context, connection, command) {
78
180
  const { writer } = context.attachments;
79
181
  const key = keyOf(connection);
@@ -145,6 +247,22 @@ function admitInput(context, command, deliverAs, whenIdle) {
145
247
  });
146
248
  const admission = context.session.externalAdmission;
147
249
  const clientMessageId = typeof command.clientMessageId === "string" ? command.clientMessageId : undefined;
250
+ const fingerprint = JSON.stringify([command.type, message, deliverAs, whenIdle ?? null]);
251
+ const prior = clientMessageId === undefined ? undefined : context.attachments.receipts.get(clientMessageId);
252
+ if (prior !== undefined) {
253
+ if (prior.fingerprint !== fingerprint)
254
+ return refusal(id, command.type, "client_message_id_conflict", {
255
+ reason: `clientMessageId ${clientMessageId} was already used for different input`,
256
+ action: "send new input with a new clientMessageId",
257
+ });
258
+ return answered(id, command.type, {
259
+ disposition: prior.disposition,
260
+ clientMessageId,
261
+ replayed: true,
262
+ duplicate: true,
263
+ admission: prior.admission,
264
+ });
265
+ }
148
266
  const expected = typeof command.expected_turn_id === "number" ? command.expected_turn_id : admission.turnEpoch;
149
267
  const result = admission.admit({
150
268
  delivery_id: clientMessageId ?? `tui-rpc-${randomUUID()}`,
@@ -155,12 +273,23 @@ function admitInput(context, command, deliverAs, whenIdle) {
155
273
  whenIdle: whenIdle === "start" ? "start" : "queue",
156
274
  });
157
275
  const identity = clientMessageId === undefined ? {} : { clientMessageId };
276
+ const admitted = (disposition) => {
277
+ if (clientMessageId === undefined)
278
+ return answered(id, command.type, { disposition });
279
+ const receipt = {
280
+ fingerprint,
281
+ disposition,
282
+ admission: { clientMessageId, durableSessionId: context.session.sessionId, disposition },
283
+ };
284
+ context.attachments.receipts.set(clientMessageId, receipt);
285
+ return answered(id, command.type, { disposition, clientMessageId, admission: receipt.admission });
286
+ };
158
287
  switch (result.kind) {
159
288
  case "started":
160
- return answered(id, command.type, { disposition: "started", ...identity });
289
+ return admitted("started");
161
290
  case "steered":
162
291
  case "queued":
163
- return answered(id, command.type, { disposition: "queued", ...identity });
292
+ return admitted("queued");
164
293
  case "already_admitted":
165
294
  return answered(id, command.type, { disposition: "queued", duplicate: true, ...identity });
166
295
  case "held_draft":
@@ -3,13 +3,17 @@
3
3
  * (`authenticateSocket`, the handshake every RPC transport shares); a wrong secret is disconnected
4
4
  * before a single JSONL line is read. After that it is the RPC framing: one LF-terminated JSON
5
5
  * object per request, one response per request carrying its `id`, plus pushed feed events.
6
+ *
7
+ * The socket path is the endpoint's logical address on every platform (its secret lives beside it); on win32 the
8
+ * listener binds the named pipe the host transport derives from that path and the secret (senpi#4267), which is
9
+ * the address every client already connects to.
6
10
  */
7
11
  import { chmod } from "node:fs/promises";
8
12
  import { createServer } from "node:net";
9
13
  import { attachJsonlLineReader, MAX_RPC_LINE_CHARACTERS, serializeJsonLine } from "../rpc/jsonl.js";
10
14
  import { rpcCommandShapeError } from "../rpc/rpc-input-validation.js";
11
15
  import { socketSink } from "../rpc/socket-sink.js";
12
- import { authenticateSocket } from "../rpc/socket-transport.js";
16
+ import { authenticateSocket, resolveSocketTransportAddress } from "../rpc/socket-transport.js";
13
17
  export async function listenControlSocket(socketPath, secret, handlers) {
14
18
  const clients = new Set();
15
19
  let nextConnection = 0;
@@ -21,13 +25,15 @@ export async function listenControlSocket(socketPath, secret, handlers) {
21
25
  });
22
26
  await new Promise((resolve, reject) => {
23
27
  server.once("error", reject);
24
- server.listen(socketPath, () => {
28
+ server.listen(resolveSocketTransportAddress(socketPath, process.platform, secret), () => {
25
29
  server.off("error", reject);
26
30
  resolve();
27
31
  });
28
32
  });
29
33
  server.on("error", (error) => handlers.listenerError(error));
30
- await chmod(socketPath, 0o600);
34
+ // A named pipe has no file to restrict: the secret, 0600 beside the logical path, is its gate.
35
+ if (process.platform !== "win32")
36
+ await chmod(socketPath, 0o600);
31
37
  return { close: () => closeServer(server, clients) };
32
38
  }
33
39
  function acceptConnection(socket, id, handlers) {
@@ -0,0 +1,96 @@
1
+ import { type CompactStorageFailure, type CompactStorageIo } from "./compact-storage-io.ts";
2
+ /**
3
+ * Owned bounded leaves for compact current state (senpi#4272, R3.C6.4 / R3.C2.4).
4
+ *
5
+ * Acceptance never waits here: a leaf is created synchronously in RAM, and a spill only relocates
6
+ * that immutable value. Its RAM copy is released only after the whole file was written and closed;
7
+ * a failed write keeps the bytes resident, halts further spilling and is reported through
8
+ * `status()`, never dropped. All storage work runs as one serial asynchronous job chain.
9
+ */
10
+ /** 32,768 UTF-16 code units (64 KiB as UTF-16LE), so a split surrogate survives a leaf boundary exactly. */
11
+ export declare const COMPACT_LEAF_CODE_UNITS = 32768;
12
+ export type LeafId = number;
13
+ export interface CompactStorageStatus {
14
+ readonly residentPayloadBytes: number;
15
+ readonly pendingIoCopyBytes: number;
16
+ readonly pinnedOnlyBytes: number;
17
+ readonly spilledLiveBytes: number;
18
+ readonly spillBytesWritten: number;
19
+ readonly reclaimDebtBytes: number;
20
+ /** Resident bytes above the spill target that a halted spool keeps in RAM (ruling 08 condition 1). */
21
+ readonly spillFailureRetainedBytes: number;
22
+ readonly spillHalted: boolean;
23
+ readonly failures: readonly CompactStorageFailure[];
24
+ }
25
+ export interface CompactContentStoreOptions {
26
+ /** Private epoch spool directory the store creates itself (0700, never an existing one; files 0600). Not a transcript. */
27
+ readonly dir: string;
28
+ readonly io?: CompactStorageIo;
29
+ /** Spilling starts above this many resident leaf bytes and stops at the target. */
30
+ readonly spillAboveBytes?: number;
31
+ readonly spillTargetBytes?: number;
32
+ }
33
+ export declare class CompactContentStore {
34
+ readonly dir: string;
35
+ private readonly io;
36
+ private readonly spillAboveBytes;
37
+ private readonly spillTargetBytes;
38
+ private readonly leaves;
39
+ /** Resident leaves in creation order, so each spill takes the oldest without rescanning committed ones. */
40
+ private readonly ramLeaves;
41
+ private readonly failures;
42
+ private nextLeafId;
43
+ private nextFile;
44
+ private dirReady;
45
+ private spoolToken;
46
+ private chain;
47
+ private queued;
48
+ private spillQueued;
49
+ private halted;
50
+ private retired;
51
+ private residentPayloadBytes;
52
+ private pendingIoCopyBytes;
53
+ private pinnedOnlyBytes;
54
+ private spilledLiveBytes;
55
+ private spillBytesWritten;
56
+ private reclaimDebtBytes;
57
+ constructor(options: CompactContentStoreOptions);
58
+ /** Takes ownership of an immutable value with one live reference. */
59
+ createLeaf(text: string): LeafId;
60
+ /** Moves live and cut-pin references; the last reference drops RAM or reclaims the file. */
61
+ adjust(id: LeafId, live: number, pins: number): void;
62
+ /** Reads a leaf the caller still references, from RAM or its committed file. */
63
+ readLeaf(id: LeafId): Promise<string>;
64
+ /** Resolves once every queued storage job, including the ones they queued, has completed. */
65
+ idle(): Promise<void>;
66
+ /**
67
+ * Invalidates spilling, joins owned I/O, then removes the epoch directory only when this store
68
+ * created it and it still passes the ownership checks; otherwise rejects with `CompactSpoolRefusedError`.
69
+ */
70
+ retire(): Promise<void>;
71
+ status(): CompactStorageStatus;
72
+ /** Reads a committed file and rejects a missing, unreadable or short one. */
73
+ private readFile;
74
+ /** Writes one new spool file; undefined means the write failed, was reported, and spilling halted. */
75
+ private writeFile;
76
+ /**
77
+ * An unreferenced (or partially written) spool file: reclamation debt until its unlink completes or
78
+ * finds it gone. Files dropped after retirement started belong to the directory retirement removes.
79
+ */
80
+ private discard;
81
+ /** A failed unlink keeps its debt and is retried with bounded backoff outside the job chain. */
82
+ private reclaim;
83
+ private get spillAllowed();
84
+ /** True while a storage failure or unreclaimed debt above the work allowance holds spilling back. */
85
+ private get spillStopped();
86
+ private enqueue;
87
+ private scheduleSpill;
88
+ /** Spills one oldest resident leaf per job, so other storage work interleaves fairly. */
89
+ private spillNext;
90
+ /** Creates the private epoch directory once; a refusal or failure halts spilling and is reported. */
91
+ private ensureDir;
92
+ private readFailure;
93
+ private fail;
94
+ private record;
95
+ }
96
+ //# sourceMappingURL=compact-content-store.d.ts.map