super-dsh 0.1.0 → 0.1.2

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 (178) hide show
  1. package/agent-agy/bridge/agy_bridge.py +250 -0
  2. package/agent-agy/bridge/gemini_openai_server.mjs +98 -0
  3. package/agent-agy/cordis.patch.yml +49 -0
  4. package/agent-agy/dist/adapter.d.ts +15 -0
  5. package/agent-agy/dist/adapter.js +42 -0
  6. package/agent-agy/dist/agent-preset-agy.d.ts +47 -0
  7. package/agent-agy/dist/agent-preset-agy.js +169 -0
  8. package/agent-agy/dist/agent-preset-projection.d.ts +31 -0
  9. package/agent-agy/dist/agent-preset-projection.js +33 -0
  10. package/agent-agy/dist/agent.d.ts +137 -0
  11. package/agent-agy/dist/agent.js +493 -0
  12. package/agent-agy/dist/agy-cli-client.d.ts +137 -0
  13. package/agent-agy/dist/agy-cli-client.js +301 -0
  14. package/agent-agy/dist/agy-client.d.ts +160 -0
  15. package/agent-agy/dist/agy-client.js +252 -0
  16. package/agent-agy/dist/agy-sessions.d.ts +24 -0
  17. package/agent-agy/dist/agy-sessions.js +116 -0
  18. package/agent-agy/dist/agy-store.d.ts +39 -0
  19. package/agent-agy/dist/agy-store.js +83 -0
  20. package/agent-agy/dist/inbox.d.ts +26 -0
  21. package/agent-agy/dist/inbox.js +53 -0
  22. package/agent-agy/dist/index.d.ts +80 -0
  23. package/agent-agy/dist/index.js +505 -0
  24. package/agent-agy/dist/models.d.ts +41 -0
  25. package/agent-agy/dist/models.js +74 -0
  26. package/agent-agy/dist/world-plugin.d.ts +9 -0
  27. package/agent-agy/dist/world-plugin.js +97 -0
  28. package/agent-agy/package.json +64 -0
  29. package/agent-claude/cordis.patch.yml +42 -0
  30. package/agent-claude/dist/adapter.js +62 -0
  31. package/agent-claude/dist/agent-preset-claude.js +169 -0
  32. package/agent-claude/dist/agent-preset-projection.js +30 -0
  33. package/agent-claude/dist/agent.js +1271 -0
  34. package/agent-claude/dist/claude-client.js +357 -0
  35. package/agent-claude/dist/claude-events.js +223 -0
  36. package/agent-claude/dist/claude-home.js +29 -0
  37. package/agent-claude/dist/commands.js +182 -0
  38. package/agent-claude/dist/content.js +134 -0
  39. package/agent-claude/dist/inbox.js +53 -0
  40. package/agent-claude/dist/index.js +555 -0
  41. package/agent-claude/dist/input-queue.js +64 -0
  42. package/agent-claude/dist/interaction.js +271 -0
  43. package/agent-claude/dist/models.js +158 -0
  44. package/agent-claude/dist/permission.js +120 -0
  45. package/agent-claude/dist/session-id.js +20 -0
  46. package/agent-claude/dist/session-map.js +165 -0
  47. package/agent-claude/dist/world-plugin.js +14 -0
  48. package/agent-claude/package.json +74 -0
  49. package/agent-codex/README.md +74 -0
  50. package/agent-codex/cordis.patch.yml +77 -0
  51. package/agent-codex/dist/adapter.js +55 -0
  52. package/agent-codex/dist/agent-preset-codex.js +171 -0
  53. package/agent-codex/dist/agent-preset-projection.js +37 -0
  54. package/agent-codex/dist/agent.js +1056 -0
  55. package/agent-codex/dist/app-home.js +65 -0
  56. package/agent-codex/dist/codex-client.js +469 -0
  57. package/agent-codex/dist/codex-events.js +297 -0
  58. package/agent-codex/dist/codex-store.js +190 -0
  59. package/agent-codex/dist/inbox.js +53 -0
  60. package/agent-codex/dist/index.js +605 -0
  61. package/agent-codex/dist/models.js +152 -0
  62. package/agent-codex/dist/permission.js +71 -0
  63. package/agent-codex/dist/session-map.js +170 -0
  64. package/agent-codex/dist/spike.js +46 -0
  65. package/agent-codex/dist/world-plugin.js +96 -0
  66. package/agent-codex/package.json +78 -0
  67. package/agent-hermes/README.md +144 -0
  68. package/agent-hermes/cordis.patch.yml +83 -0
  69. package/agent-hermes/dist/adapter.js +83 -0
  70. package/agent-hermes/dist/agent-preset-hermes.js +171 -0
  71. package/agent-hermes/dist/agent-preset-projection.js +34 -0
  72. package/agent-hermes/dist/agent.js +1068 -0
  73. package/agent-hermes/dist/hermes-client.js +953 -0
  74. package/agent-hermes/dist/hermes-events.js +289 -0
  75. package/agent-hermes/dist/hermes-store.js +158 -0
  76. package/agent-hermes/dist/inbox.js +53 -0
  77. package/agent-hermes/dist/index.js +541 -0
  78. package/agent-hermes/dist/models.js +324 -0
  79. package/agent-hermes/dist/permission.js +60 -0
  80. package/agent-hermes/dist/world-plugin.js +101 -0
  81. package/agent-hermes/package.json +76 -0
  82. package/agent-hub/cordis.patch.yml +12 -0
  83. package/agent-hub/dist/agent-roster.d.ts +34 -0
  84. package/agent-hub/dist/agent-roster.js +27 -0
  85. package/agent-hub/dist/carrier.d.ts +43 -0
  86. package/agent-hub/dist/carrier.js +241 -0
  87. package/agent-hub/dist/client-shim.d.ts +28 -0
  88. package/agent-hub/dist/client-shim.js +251 -0
  89. package/agent-hub/dist/envelope.d.ts +54 -0
  90. package/agent-hub/dist/envelope.js +92 -0
  91. package/agent-hub/dist/gateway.d.ts +28 -0
  92. package/agent-hub/dist/gateway.js +18 -0
  93. package/agent-hub/dist/index-pass.d.ts +22 -0
  94. package/agent-hub/dist/index-pass.js +47 -0
  95. package/agent-hub/dist/index.d.ts +29 -0
  96. package/agent-hub/dist/index.js +29 -0
  97. package/agent-hub/dist/labels.d.ts +45 -0
  98. package/agent-hub/dist/labels.js +83 -0
  99. package/agent-hub/dist/ownership.d.ts +37 -0
  100. package/agent-hub/dist/ownership.js +54 -0
  101. package/agent-hub/dist/roster.d.ts +28 -0
  102. package/agent-hub/dist/roster.js +38 -0
  103. package/agent-hub/dist/rpc.d.ts +38 -0
  104. package/agent-hub/dist/rpc.js +52 -0
  105. package/agent-hub/dist/spawn-world.d.ts +46 -0
  106. package/agent-hub/dist/spawn-world.js +72 -0
  107. package/agent-hub/dist/targets.d.ts +33 -0
  108. package/agent-hub/dist/targets.js +30 -0
  109. package/agent-hub/dist/world-entry.d.ts +31 -0
  110. package/agent-hub/dist/world-entry.js +51 -0
  111. package/agent-hub/dist/world-host.d.ts +69 -0
  112. package/agent-hub/dist/world-host.js +44 -0
  113. package/agent-hub/dist/world-join.d.ts +16 -0
  114. package/agent-hub/dist/world-join.js +122 -0
  115. package/agent-hub/dist/world-mount.d.ts +52 -0
  116. package/agent-hub/dist/world-mount.js +52 -0
  117. package/agent-hub/dist/world-mux.d.ts +72 -0
  118. package/agent-hub/dist/world-mux.js +224 -0
  119. package/agent-hub/dist/world-provision.d.ts +20 -0
  120. package/agent-hub/dist/world-provision.js +122 -0
  121. package/agent-hub/dist/world-web-server.d.ts +162 -0
  122. package/agent-hub/dist/world-web-server.js +248 -0
  123. package/agent-hub/lib/client/index.js +242 -0
  124. package/agent-hub/package.json +65 -0
  125. package/agent-omp/README.md +77 -0
  126. package/agent-omp/cordis.patch.yml +92 -0
  127. package/agent-omp/dist/adapter.js +36 -0
  128. package/agent-omp/dist/agent-preset-omp.js +171 -0
  129. package/agent-omp/dist/agent-preset-projection.js +36 -0
  130. package/agent-omp/dist/agent.js +1145 -0
  131. package/agent-omp/dist/app-home.js +86 -0
  132. package/agent-omp/dist/discovery.js +116 -0
  133. package/agent-omp/dist/inbox.js +53 -0
  134. package/agent-omp/dist/index.js +574 -0
  135. package/agent-omp/dist/knobs.js +50 -0
  136. package/agent-omp/dist/lazy-rpc.js +161 -0
  137. package/agent-omp/dist/mobile/gesture.js +148 -0
  138. package/agent-omp/dist/mobile/zoom-guard.js +294 -0
  139. package/agent-omp/dist/mobile-boot.js +38 -0
  140. package/agent-omp/dist/models.js +299 -0
  141. package/agent-omp/dist/omp-cli.js +147 -0
  142. package/agent-omp/dist/omp-disk-discovery.js +178 -0
  143. package/agent-omp/dist/omp-store.js +518 -0
  144. package/agent-omp/dist/pairing.js +45 -0
  145. package/agent-omp/dist/permission.js +96 -0
  146. package/agent-omp/dist/protocol.js +13 -0
  147. package/agent-omp/dist/rpc-types.js +1 -0
  148. package/agent-omp/dist/sdk-client.js +378 -0
  149. package/agent-omp/dist/sidecar-client.js +195 -0
  150. package/agent-omp/dist/store/adapter.js +47 -0
  151. package/agent-omp/dist/store/db.js +210 -0
  152. package/agent-omp/dist/store/index.js +59 -0
  153. package/agent-omp/dist/store/reconcile.js +51 -0
  154. package/agent-omp/dist/store/schema.js +40 -0
  155. package/agent-omp/dist/world-plugin.js +96 -0
  156. package/agent-omp/package.json +97 -0
  157. package/agent-omp/sidecar/main.ts +481 -0
  158. package/agent-pi/README.md +67 -0
  159. package/agent-pi/cordis.patch.yml +80 -0
  160. package/agent-pi/dist/adapter.js +53 -0
  161. package/agent-pi/dist/agent-preset-pi.js +169 -0
  162. package/agent-pi/dist/agent-preset-projection.js +29 -0
  163. package/agent-pi/dist/agent.js +949 -0
  164. package/agent-pi/dist/inbox.js +53 -0
  165. package/agent-pi/dist/index.js +595 -0
  166. package/agent-pi/dist/knobs.js +24 -0
  167. package/agent-pi/dist/models.js +200 -0
  168. package/agent-pi/dist/permission.js +66 -0
  169. package/agent-pi/dist/pi-client.js +224 -0
  170. package/agent-pi/dist/pi-events.js +155 -0
  171. package/agent-pi/dist/pi-home.js +55 -0
  172. package/agent-pi/dist/safe-json.js +25 -0
  173. package/agent-pi/dist/session-map.js +119 -0
  174. package/agent-pi/dist/world-plugin.js +97 -0
  175. package/agent-pi/package.json +77 -0
  176. package/cordis.patch.yml +22 -19
  177. package/package.json +88 -25
  178. package/client.js +0 -4
@@ -0,0 +1,271 @@
1
+ /**
2
+ * The interaction bridge (plan Task 8): Claude Code's three human-interaction
3
+ * callbacks mapped onto DSH's two native human-interaction seams.
4
+ *
5
+ * Claude Code routes every human decision through exactly three SDK callbacks —
6
+ * `canUseTool` (tool permission), the `AskUserQuestion` tool, and
7
+ * `ExitPlanMode` (plan approval) — while DSH exposes exactly two native
8
+ * interaction waterfalls and **no generic extension-UI seam**. So all three
9
+ * Claude callbacks must be expressed through those two:
10
+ *
11
+ * - tool permission → `ctx.approval.request()`, whose closed outcome union is
12
+ * `"allowed-once" | "rejected" | "cancelled" | "unavailable"`;
13
+ * - `AskUserQuestion` → `ctx.userQuestions.ask()`, returning the same
14
+ * `{ answers: [{ id, selected, custom? }] }` batch shape;
15
+ * - plan approval → **the same** `ask()` seam, distinguished by an
16
+ * `intent: { kind: "plan-review", approve }` on the question.
17
+ *
18
+ * **Fail-closed is the contract.** `"allowed-once"` is the only outcome that
19
+ * grants anything: every other outcome, every thrown/rejected promise, and
20
+ * every malformed response becomes `{ behavior: "deny", message }` (or `false`
21
+ * for {@link askPlanReview}) — never `allow`, never a silent pass.
22
+ *
23
+ * The module is **pure and type-only**: it imports nothing at all (not the
24
+ * Claude SDK, not Cordis) and touches no storage, settings, or session log.
25
+ * {@link ApprovalLike} / {@link UserQuestionsLike} are the minimal structural
26
+ * types of the DSH seams, so the real `ctx.approval` / `ctx.userQuestions` are
27
+ * structurally compatible and are injected later (Task 9). Persisting a
28
+ * selected permission mode is explicitly **not** this module's concern.
29
+ */
30
+ /** Narrow an untrusted value to a plain JSON object. */
31
+ function isRecord(value) {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+ /** Human-readable text for an unknown thrown value (the deny message's trace). */
35
+ function errorText(error) {
36
+ return error instanceof Error ? error.message : String(error);
37
+ }
38
+ /** The `canUseTool` denial: the word `denied` keeps every refusal visible. */
39
+ function toolCallDenied(reason) {
40
+ return { behavior: "deny", message: `Claude Code tool call denied (${reason})` };
41
+ }
42
+ /** The denial reason for an approval outcome or a rogue value. */
43
+ function outcomeReason(outcome) {
44
+ return typeof outcome === "string" ? outcome : "unrecognized approval outcome";
45
+ }
46
+ /**
47
+ * Map Claude Code's `canUseTool` callback onto `ctx.approval.request()`.
48
+ *
49
+ * The returned callback is the SDK's fail-closed permission gate:
50
+ *
51
+ * - `"allowed-once"` → `{ behavior: "allow" }` (the input is never rewritten —
52
+ * the tool call proceeds with the arguments Claude already supplied);
53
+ * - `"rejected"` / `"cancelled"` / `"unavailable"` / anything else → deny;
54
+ * - a rejected `approval.request` promise (an idle turn, a failed audit append,
55
+ * a missing answerer that throws instead of normalizing) → deny.
56
+ *
57
+ * The approval request carries `toolName` and `callId` (from
58
+ * `options.toolUseID`) so the DSH UI attaches the prompt to the tool call it
59
+ * already streamed; the arguments are deliberately *not* duplicated here,
60
+ * which is why `input` is unused.
61
+ *
62
+ * @param deps - the approval seam and the agent asking.
63
+ * @returns a `CanUseTool`-compatible callback.
64
+ */
65
+ export function makeCanUseTool(deps) {
66
+ return async (toolName, _input, options) => {
67
+ let outcome;
68
+ try {
69
+ outcome = await deps.approval.request({
70
+ agent: deps.agent,
71
+ toolName,
72
+ callId: options?.toolUseID,
73
+ ...(options?.decisionReason !== undefined ? { reason: options.decisionReason } : {}),
74
+ signal: options?.signal,
75
+ });
76
+ }
77
+ catch (error) {
78
+ return toolCallDenied(`the approval request failed: ${errorText(error)}`);
79
+ }
80
+ // Exact-match grant: a rogue or missing value must never open the gate.
81
+ if (outcome === "allowed-once")
82
+ return { behavior: "allow" };
83
+ return toolCallDenied(outcomeReason(outcome));
84
+ };
85
+ }
86
+ /** True when one untrusted answer entry is a well-formed {@link AskAnswerItem}. */
87
+ function isAnswerItem(value) {
88
+ return isRecord(value)
89
+ && typeof value.id === "string"
90
+ && Array.isArray(value.selected)
91
+ && value.selected.every((label) => typeof label === "string")
92
+ && (value.custom === undefined || typeof value.custom === "string");
93
+ }
94
+ /**
95
+ * Resolve the answer entry for one converted question by **id only**.
96
+ *
97
+ * The DSH service echoes the id it was given, so an id match is the one proof
98
+ * that an answer belongs to this question. There is deliberately **no**
99
+ * positional fallback: a response of the right length but with foreign or
100
+ * mismatched ids (a reordered batch, a foreign answerer, a test double) would
101
+ * otherwise pair `answers[i]` with `questions[i]` and silently feed Claude
102
+ * somebody else's answer. Such a response fails closed instead, as does a
103
+ * missing/empty/non-array `answers` or any entry that is not a well-formed
104
+ * {@link AskAnswerItem}.
105
+ *
106
+ * @param answer - the untrusted response from `ask()`.
107
+ * @param question - the question being resolved.
108
+ * @returns the matching answer entry, or undefined when the response cannot
109
+ * answer this question.
110
+ */
111
+ function resolveAnswer(answer, question) {
112
+ if (!isRecord(answer) || !Array.isArray(answer.answers))
113
+ return undefined;
114
+ if (!answer.answers.every(isAnswerItem))
115
+ return undefined;
116
+ return answer.answers.find((item) => item.id === question.id);
117
+ }
118
+ /**
119
+ * Encode one question's answer the way Claude's `answers` map wants it:
120
+ * multi-select labels joined by `", "`, then the custom "Other" text appended
121
+ * as one more comma-separated segment. Neither an absent/blank `custom` nor an
122
+ * empty `selected` may leave a stray or doubled `", "`.
123
+ */
124
+ function encodeAnswer(selected, custom) {
125
+ const segments = [];
126
+ if (selected.length > 0)
127
+ segments.push(selected.join(", "));
128
+ const extra = typeof custom === "string" ? custom.trim() : "";
129
+ if (extra !== "")
130
+ segments.push(extra);
131
+ return segments.join(", ");
132
+ }
133
+ /** Claude's `AskUserQuestionInput` question list, narrowed to a usable array. */
134
+ function claudeQuestions(input) {
135
+ return isRecord(input) && Array.isArray(input.questions) ? input.questions : [];
136
+ }
137
+ /** Convert one Claude question into the DSH ask item, or undefined when unusable. */
138
+ function toAskQuestion(raw, index) {
139
+ if (!isRecord(raw) || typeof raw.question !== "string" || raw.question === "")
140
+ return undefined;
141
+ const options = Array.isArray(raw.options)
142
+ ? raw.options.flatMap((option) => {
143
+ if (!isRecord(option) || typeof option.label !== "string")
144
+ return [];
145
+ return [{
146
+ label: option.label,
147
+ ...(typeof option.description === "string" ? { description: option.description } : {}),
148
+ }];
149
+ })
150
+ : [];
151
+ const multiSelect = typeof raw.multiSelect === "boolean" ? raw.multiSelect : undefined;
152
+ return {
153
+ id: `q${index}`,
154
+ question: raw.question,
155
+ ...(typeof raw.header === "string" ? { header: raw.header } : {}),
156
+ ...(options.length > 0 ? { options } : {}),
157
+ // The service contract's own spelling — the only one the seam reads.
158
+ ...(multiSelect !== undefined ? { multiSelect } : {}),
159
+ };
160
+ }
161
+ /**
162
+ * Map Claude Code's `AskUserQuestion` tool call onto `ctx.userQuestions.ask()`
163
+ * and translate the human's answer back into Claude's `updatedInput.answers`.
164
+ *
165
+ * The batch is converted per question: a fresh DSH id (`q${i}`), the question
166
+ * text, the header when present, the option labels/descriptions (Claude's
167
+ * UI-only `preview` is dropped — DSH has no field for it), and the multi-select
168
+ * flag. The answer comes back keyed by the **question text** (Claude's `answers`
169
+ * is `{ [questionText]: string }`), each value being the selected labels joined
170
+ * by `", "` with any custom "Other" text appended as one more segment.
171
+ *
172
+ * Fail-closed: a missing/empty/malformed question batch, a rejected `ask()`
173
+ * promise, a malformed answer batch, and any unanswered question all produce
174
+ * `{ behavior: "deny", message }`. A question *without* options is legitimate
175
+ * (DSH renders it as a free-text answer) and is asked with no option menu.
176
+ *
177
+ * @param deps - the user-questions seam and the agent asking.
178
+ * @param input - Claude's `AskUserQuestionInput` (its `questions` array is input).
179
+ * @param signal - the tool call's cancellation lifetime.
180
+ * @returns an allow carrying `updatedInput` with the `answers` map, or a denial.
181
+ */
182
+ export async function answerAskUserQuestion(deps, input, signal) {
183
+ const raw = claudeQuestions(input);
184
+ if (raw.length === 0) {
185
+ // Nothing to ask is malformed input (the seam itself rejects it as
186
+ // EMPTY_QUESTIONS); fabricating an empty answer set would hand Claude a
187
+ // result no human produced.
188
+ return { behavior: "deny", message: "Claude Code AskUserQuestion denied (no questions to ask)" };
189
+ }
190
+ const questions = [];
191
+ for (let index = 0; index < raw.length; index += 1) {
192
+ const question = toAskQuestion(raw[index], index);
193
+ if (question === undefined) {
194
+ return {
195
+ behavior: "deny",
196
+ message: `Claude Code AskUserQuestion denied (question ${index} is malformed)`,
197
+ };
198
+ }
199
+ questions.push(question);
200
+ }
201
+ let answer;
202
+ try {
203
+ answer = await deps.questions.ask({
204
+ questions,
205
+ agent: deps.agent,
206
+ signal,
207
+ });
208
+ }
209
+ catch (error) {
210
+ return {
211
+ behavior: "deny",
212
+ message: `Claude Code AskUserQuestion denied (the user-question request failed: ${errorText(error)})`,
213
+ };
214
+ }
215
+ const answers = {};
216
+ for (let index = 0; index < questions.length; index += 1) {
217
+ const question = questions[index];
218
+ const item = resolveAnswer(answer, question);
219
+ if (item === undefined) {
220
+ return {
221
+ behavior: "deny",
222
+ message: `Claude Code AskUserQuestion denied (no answer for question ${question.id})`,
223
+ };
224
+ }
225
+ answers[question.question] = encodeAnswer(item.selected, item.custom);
226
+ }
227
+ return { behavior: "allow", updatedInput: { ...input, answers } };
228
+ }
229
+ /** The DSH question id the plan review asks under (and answers by). */
230
+ const PLAN_QUESTION_ID = "plan";
231
+ /** The option label that approves the plan. */
232
+ const PLAN_APPROVE_LABEL = "Approve";
233
+ /** The option label that declines the plan. */
234
+ const PLAN_DECLINE_LABEL = "Keep planning";
235
+ /**
236
+ * Map Claude Code's `ExitPlanMode` callback onto the **same**
237
+ * `ctx.userQuestions.ask()` seam, tagged for plan-review presentation.
238
+ *
239
+ * The question carries `detail = plan`, the binary Approve/Keep-planning option
240
+ * pair, and `intent: { kind: "plan-review", approve: "Approve" }` — the intent's
241
+ * `approve` names one of the question's own options, as `ask()` requires. Only
242
+ * an answer whose `selected` contains `"Approve"` approves; every other answer,
243
+ * a rejected `ask()` promise, a missing/malformed answer, and any thrown error
244
+ * return `false` (fail-closed: declining keeps the plan unapproved).
245
+ *
246
+ * @param deps - the user-questions seam and the agent asking.
247
+ * @param plan - the plan markdown under review.
248
+ * @param signal - the call's cancellation lifetime.
249
+ * @returns true only when the human approved.
250
+ */
251
+ export async function askPlanReview(deps, plan, signal) {
252
+ const question = {
253
+ id: PLAN_QUESTION_ID,
254
+ question: "Approve this plan?",
255
+ detail: plan,
256
+ options: [{ label: PLAN_APPROVE_LABEL }, { label: PLAN_DECLINE_LABEL }],
257
+ intent: { kind: "plan-review", approve: PLAN_APPROVE_LABEL },
258
+ };
259
+ try {
260
+ const answer = await deps.questions.ask({
261
+ questions: [question],
262
+ agent: deps.agent,
263
+ signal,
264
+ });
265
+ const item = resolveAnswer(answer, question);
266
+ return item !== undefined && item.selected.includes(PLAN_APPROVE_LABEL);
267
+ }
268
+ catch {
269
+ return false;
270
+ }
271
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Claude model catalog for the Dash model selector.
3
+ *
4
+ * Claude owns the truth about which models exist. Sources, in authority
5
+ * order:
6
+ * - the boot probe (`probeClaudeModels`, claude-client.ts): one ephemeral SDK
7
+ * query at provider boot asks the CLI's `supportedModels()` and publishes
8
+ * the full alias roster (sonnet/opus/haiku/fable/...) via
9
+ * {@link setModelCatalog};
10
+ * - the live session's `supportedModels()` (agent.ts), which overwrites the
11
+ * same catalog once a session is running;
12
+ * - until the first probe answer, the degraded single-entry catalog derived
13
+ * from the runtime's own `session_init.model` (`catalogFromInit`).
14
+ *
15
+ * This module is only the in-memory projection those observations are
16
+ * published into — the browser model selector reads it through
17
+ * `ClaudeLlmAdapter` under the `claude` route.
18
+ *
19
+ * Observed-only (the invariant this file exists to hold):
20
+ * - Nothing here ever synthesises a placeholder model id (`claude-default`,
21
+ * a `deepseek-flash` fallback, ...), because a fabricated id would leak
22
+ * into the picker and onto the wire.
23
+ * - `CLAUDE_DEFAULT_MODEL` is a REAL CLI alias kept ONLY as the boot/fallback
24
+ * default (and the probe-failure catalog): every other entry must come from
25
+ * a runtime observation. A degraded value stays in memory — it is never
26
+ * written to settings, and only the alias the CLI itself resolves ever
27
+ * reaches the runtime as a selection.
28
+ *
29
+ * The catalog is process-global module state, not a per-adapter instance
30
+ * field: exactly one Claude app per world observes it, and the settings-UI
31
+ * round trip constructs its own adapter instance.
32
+ */
33
+ /** The empty catalog after a legitimate empty observation (`setModelCatalog([])`). */
34
+ const EMPTY_CATALOG = Object.freeze({ models: Object.freeze([]) });
35
+ /**
36
+ * The ONE real model this adapter is configured to use at boot: the Claude
37
+ * Code model alias `sonnet` (a genuine CLI-resolvable id, never a fabricated
38
+ * placeholder). It names the BOOT catalog row and the fallback default used
39
+ * when neither the boot probe nor a live session has answered — a probe
40
+ * failure must never grow fabricated rows, only keep this one real alias.
41
+ */
42
+ export const CLAUDE_DEFAULT_MODEL = "sonnet";
43
+ /** The boot catalog: the declared default, served before any observation. */
44
+ const BOOT_CATALOG = Object.freeze({
45
+ models: Object.freeze([Object.freeze({ id: CLAUDE_DEFAULT_MODEL, label: CLAUDE_DEFAULT_MODEL })]),
46
+ defaultModel: CLAUDE_DEFAULT_MODEL,
47
+ });
48
+ let catalog = BOOT_CATALOG;
49
+ /** Detach one entry so neither side of a read/write can mutate the other. */
50
+ function cloneEntry(entry) {
51
+ return {
52
+ id: entry.id,
53
+ label: entry.label,
54
+ ...(entry.description === undefined ? {} : { description: entry.description }),
55
+ ...(entry.contextWindow === undefined ? {} : { contextWindow: entry.contextWindow }),
56
+ ...(entry.resolvedModel === undefined ? {} : { resolvedModel: entry.resolvedModel }),
57
+ ...(entry.reasoningEfforts === undefined ? {} : { reasoningEfforts: Object.freeze([...entry.reasoningEfforts]) }),
58
+ };
59
+ }
60
+ /** A detached entry that neither side of a read/write can mutate. */
61
+ function frozenEntry(entry) {
62
+ return Object.freeze(cloneEntry(entry));
63
+ }
64
+ /**
65
+ * Publish the observed catalog. This is an **overwrite**, never a merge:
66
+ * calling it twice leaves only the second list, so the catalog can shrink
67
+ * (including back to empty) when Claude reports fewer models.
68
+ *
69
+ * Entries are copied in, so a caller mutating its own array afterwards cannot
70
+ * reach back into module state.
71
+ *
72
+ * @param entries - exactly the models Claude reported; `[]` is a legitimate
73
+ * observation and must never be replaced by a placeholder.
74
+ * @param defaultModel - the model Claude named as default. It is stored only
75
+ * when it is non-empty **and** names one of `entries`: a dangling default
76
+ * would render as a selection the picker cannot show and `resolveModel`
77
+ * cannot resolve, which is the same fabricated reference onto the wire that
78
+ * a placeholder model id would be.
79
+ */
80
+ export function setModelCatalog(entries, defaultModel) {
81
+ catalog = Object.freeze({
82
+ models: Object.freeze(entries.map(frozenEntry)),
83
+ ...(defaultModel !== undefined && defaultModel !== "" && entries.some((entry) => entry.id === defaultModel)
84
+ ? { defaultModel }
85
+ : {}),
86
+ });
87
+ }
88
+ /**
89
+ * Read the current catalog as a detached, frozen snapshot: a caller cannot
90
+ * mutate module state through it, nor observe a later write through it.
91
+ */
92
+ export function readModelCatalog() {
93
+ if (catalog.models.length === 0 && catalog.defaultModel === undefined)
94
+ return EMPTY_CATALOG;
95
+ return Object.freeze({
96
+ models: Object.freeze(catalog.models.map(frozenEntry)),
97
+ ...(catalog.defaultModel === undefined ? {} : { defaultModel: catalog.defaultModel }),
98
+ });
99
+ }
100
+ /**
101
+ * Derive the **degraded** single-entry catalog from a projected `session_init`
102
+ * (Task 4) while `supportedModels()` has not answered yet.
103
+ *
104
+ * The runtime's own reported model is real, so it may stand in for the catalog
105
+ * until the authoritative list arrives. It is a read of memory only: callers
106
+ * must not persist it into settings, and must not pass it back to the runtime
107
+ * as a selection.
108
+ *
109
+ * @param init - the projected `session_init` shape; anything unusable —
110
+ * non-string, empty, or whitespace-only — yields `[]` (still empty — no
111
+ * placeholder).
112
+ * @returns zero or one observed entry.
113
+ */
114
+ export function catalogFromInit(init) {
115
+ const model = init?.model;
116
+ if (typeof model !== "string" || model.trim() === "")
117
+ return [];
118
+ return [{ id: model, label: model }];
119
+ }
120
+ /**
121
+ * Map one SDK `ModelInfo` row onto a Dash {@link ClaudeModelEntry} (skip
122
+ * unusable rows). Lives here so both the boot probe (index.ts) and the live
123
+ * session observation (agent.ts) project rows through ONE mapping.
124
+ */
125
+ export function modelEntryFromSdk(raw) {
126
+ if (raw === null || typeof raw !== "object")
127
+ return [];
128
+ const m = raw;
129
+ const id = typeof m.value === "string" && m.value !== "" ? m.value : undefined;
130
+ if (id === undefined)
131
+ return [];
132
+ const label = typeof m.displayName === "string" && m.displayName !== "" ? m.displayName : id;
133
+ // The alias's canonical wire id is appended to the description, never the
134
+ // id itself: the picker selects by the CLI alias (the wire contract).
135
+ const resolved = typeof m.resolvedModel === "string" && m.resolvedModel !== "" && m.resolvedModel !== id
136
+ ? m.resolvedModel
137
+ : undefined;
138
+ const base = typeof m.description === "string" && m.description !== "" ? m.description : undefined;
139
+ const description = resolved === undefined
140
+ ? base
141
+ : base === undefined
142
+ ? `resolves to ${resolved}`
143
+ : `${base} (resolves to ${resolved})`;
144
+ // `supportedEffortLevels` → adapter reasoning efforts (omp thinking.efforts
145
+ // parity); the CLI names no per-model default, so none is invented.
146
+ const levels = Array.isArray(m.supportedEffortLevels) ? m.supportedEffortLevels : [];
147
+ const efforts = [];
148
+ for (const level of levels) {
149
+ if (typeof level === "string" && level !== "" && !efforts.includes(level))
150
+ efforts.push(level);
151
+ }
152
+ return [{
153
+ id,
154
+ label,
155
+ ...(description === undefined ? {} : { description }),
156
+ ...(efforts.length === 0 ? {} : { reasoningEfforts: Object.freeze(efforts) }),
157
+ }];
158
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The 3-preset skeleton → Claude mode table. Typed as an exhaustive record, so
3
+ * adding a {@link PresetName} without a mapping is a compile error.
4
+ */
5
+ export const PRESET_TO_CLAUDE = Object.freeze({
6
+ "read-only": "default",
7
+ "workspace-write": "acceptEdits",
8
+ "danger-full-access": "bypassPermissions",
9
+ });
10
+ /**
11
+ * Claude-only tiers that sit ON TOP of the skeleton. Ordered as the UI lists
12
+ * them. None of these is a DSH preset name — `Object.values(PRESET_TO_CLAUDE)`
13
+ * must never contain one.
14
+ */
15
+ export const EXTRA_TIERS = Object.freeze(["plan", "auto", "dontAsk"]);
16
+ /**
17
+ * The Dash sandbox + approval each skeleton preset composes (the profile pins
18
+ * the same table in its `permission` row). Claude's own mode rides alongside;
19
+ * these two events are the DSH-side facts the UI folds.
20
+ */
21
+ const DSH_SKELETON = {
22
+ "read-only": { sandbox: "read-only", approval: "ask" },
23
+ "workspace-write": { sandbox: "workspace-write", approval: "ask" },
24
+ "danger-full-access": { sandbox: "danger-full-access", approval: "never" },
25
+ };
26
+ /** Narrow an untrusted value to a known DSH preset name. */
27
+ export function isPresetName(raw) {
28
+ return typeof raw === "string" && Object.hasOwn(PRESET_TO_CLAUDE, raw);
29
+ }
30
+ /**
31
+ * Map a DSH preset name **or** an {@link EXTRA_TIERS} tier onto the Claude
32
+ * permission mode it selects. An extra tier is already Claude vocabulary and
33
+ * passes through; anything unknown — including `undefined` — degrades to
34
+ * `"default"`, the most restrictive skeleton mode.
35
+ */
36
+ export function claudePermissionMode(preset) {
37
+ if (preset === "plan" || preset === "auto" || preset === "dontAsk")
38
+ return preset;
39
+ return isPresetName(preset) ? PRESET_TO_CLAUDE[preset] : "default";
40
+ }
41
+ /**
42
+ * Inverse of {@link PRESET_TO_CLAUDE}: the DSH skeleton preset whose Claude
43
+ * mode this is, or undefined for the {@link EXTRA_TIERS} (`plan` / `auto` /
44
+ * `dontAsk`) — they are mode-only runtime state with no DSH preset of their
45
+ * own, so a live mode change to one of them must NOT rewrite the recorded
46
+ * preset. Used by `onModeChange` to upsert `{claudeMode, preset}` TOGETHER.
47
+ */
48
+ export function presetFromClaudeMode(mode) {
49
+ for (const [preset, mapped] of Object.entries(PRESET_TO_CLAUDE)) {
50
+ if (mapped === mode)
51
+ return preset;
52
+ }
53
+ return undefined;
54
+ }
55
+ /**
56
+ * The session-record patch a live mode change persists (RC-5): the mode
57
+ * always rides along, and the preset TOGETHER with it whenever the mode is a
58
+ * skeleton image — an extra tier (`plan` / `auto` / `dontAsk`) changes the
59
+ * mode only, leaving the recorded preset untouched. `onModeChange` feeds
60
+ * this straight into `upsertSession`.
61
+ */
62
+ export function modeChangePatch(mode) {
63
+ const preset = presetFromClaudeMode(mode);
64
+ return { claudeMode: mode, ...(preset === undefined ? {} : { preset }) };
65
+ }
66
+ /**
67
+ * The default preset for FUTURE sessions, as the permission service resolves
68
+ * it (settings-backed; the profile patch pins the base default). Undefined when
69
+ * the service is absent or its getter fails — callers fall back to the most
70
+ * restrictive Claude mode.
71
+ */
72
+ export function defaultPermissionPreset(ctx) {
73
+ const service = ctx.get("permissionPresets");
74
+ if (service === undefined)
75
+ return undefined;
76
+ try {
77
+ const preset = service.defaultPreset;
78
+ return isPresetName(preset) ? preset : undefined;
79
+ }
80
+ catch {
81
+ return undefined;
82
+ }
83
+ }
84
+ /**
85
+ * The session's effective DSH preset: the last revivable `permission/preset`
86
+ * event.
87
+ *
88
+ * Only the 3-preset skeleton round-trips here: an {@link EXTRA_TIERS} tier is
89
+ * not a preset name, so an event carrying `preset: "plan"` is skipped. Extra
90
+ * tiers are Claude-side runtime state (switchable via `setPermissionMode`), not
91
+ * DSH presets — they are read back through {@link claudePermissionMode} and the
92
+ * event's `claudeMode` field, never promoted to a second event type.
93
+ */
94
+ export function presetFromEvents(events) {
95
+ for (let index = events.length - 1; index >= 0; index -= 1) {
96
+ const event = events[index];
97
+ if (event.type === "permission/preset" && isPresetName(event.data?.preset))
98
+ return event.data.preset;
99
+ }
100
+ return undefined;
101
+ }
102
+ /**
103
+ * The DSH permission events stamped for a session's preset, in append order
104
+ * (preset, sandbox, approval) — the same shape `agent-codex/src/permission.ts`
105
+ * emits. The first event additionally carries `claudeMode`: the effective
106
+ * Claude permission mode the live `setPermissionMode` call and the UI both
107
+ * read from one place.
108
+ *
109
+ * The parameter is a {@link PresetName} on purpose. `plan` / `auto` / `dontAsk`
110
+ * are not DSH presets and have no sandbox/approval skeleton to stamp; they
111
+ * change only the Claude-side mode at runtime.
112
+ */
113
+ export function permissionEventsFor(preset, time) {
114
+ const spec = DSH_SKELETON[preset];
115
+ return [
116
+ { type: "permission/preset", time, data: { preset, claudeMode: claudePermissionMode(preset) } },
117
+ { type: "sandbox/mode", time, data: { mode: spec.sandbox } },
118
+ { type: "approval/policy", time, data: { policy: spec.approval } },
119
+ ];
120
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * DSH session id <-> Claude session id anchoring (spec ruling R8, route A).
3
+ *
4
+ * The DSH id is `<prefix>-<uuid>` (minted by the session store). Claude accepts
5
+ * any UUID version (its predicate is the version-agnostic regex below, verified
6
+ * byte-identical in the SDK and the CLI). So we preset Claude's sessionId from
7
+ * the DSH id's UUID tail: no mapping file, and the id survives restarts.
8
+ *
9
+ * Route B (SDK-minted id + a persisted map) is the fallback ONLY for a DSH id
10
+ * that is not a UUID; the caller must trace it, never fall back silently.
11
+ */
12
+ export const CLAUDE_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13
+ export function isClaudeSessionId(value) {
14
+ return typeof value === "string" && CLAUDE_SESSION_ID_RE.test(value);
15
+ }
16
+ /** UUID tail of a DSH session id, or `undefined` when it is not a UUID. */
17
+ export function claudeSessionIdFromDsh(dshSessionId) {
18
+ const tail = dshSessionId.replace(/^session-/, "");
19
+ return isClaudeSessionId(tail) ? tail.toLowerCase() : undefined;
20
+ }