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.
- package/agent-agy/bridge/agy_bridge.py +250 -0
- package/agent-agy/bridge/gemini_openai_server.mjs +98 -0
- package/agent-agy/cordis.patch.yml +49 -0
- package/agent-agy/dist/adapter.d.ts +15 -0
- package/agent-agy/dist/adapter.js +42 -0
- package/agent-agy/dist/agent-preset-agy.d.ts +47 -0
- package/agent-agy/dist/agent-preset-agy.js +169 -0
- package/agent-agy/dist/agent-preset-projection.d.ts +31 -0
- package/agent-agy/dist/agent-preset-projection.js +33 -0
- package/agent-agy/dist/agent.d.ts +137 -0
- package/agent-agy/dist/agent.js +493 -0
- package/agent-agy/dist/agy-cli-client.d.ts +137 -0
- package/agent-agy/dist/agy-cli-client.js +301 -0
- package/agent-agy/dist/agy-client.d.ts +160 -0
- package/agent-agy/dist/agy-client.js +252 -0
- package/agent-agy/dist/agy-sessions.d.ts +24 -0
- package/agent-agy/dist/agy-sessions.js +116 -0
- package/agent-agy/dist/agy-store.d.ts +39 -0
- package/agent-agy/dist/agy-store.js +83 -0
- package/agent-agy/dist/inbox.d.ts +26 -0
- package/agent-agy/dist/inbox.js +53 -0
- package/agent-agy/dist/index.d.ts +80 -0
- package/agent-agy/dist/index.js +505 -0
- package/agent-agy/dist/models.d.ts +41 -0
- package/agent-agy/dist/models.js +74 -0
- package/agent-agy/dist/world-plugin.d.ts +9 -0
- package/agent-agy/dist/world-plugin.js +97 -0
- package/agent-agy/package.json +64 -0
- package/agent-claude/cordis.patch.yml +42 -0
- package/agent-claude/dist/adapter.js +62 -0
- package/agent-claude/dist/agent-preset-claude.js +169 -0
- package/agent-claude/dist/agent-preset-projection.js +30 -0
- package/agent-claude/dist/agent.js +1271 -0
- package/agent-claude/dist/claude-client.js +357 -0
- package/agent-claude/dist/claude-events.js +223 -0
- package/agent-claude/dist/claude-home.js +29 -0
- package/agent-claude/dist/commands.js +182 -0
- package/agent-claude/dist/content.js +134 -0
- package/agent-claude/dist/inbox.js +53 -0
- package/agent-claude/dist/index.js +555 -0
- package/agent-claude/dist/input-queue.js +64 -0
- package/agent-claude/dist/interaction.js +271 -0
- package/agent-claude/dist/models.js +158 -0
- package/agent-claude/dist/permission.js +120 -0
- package/agent-claude/dist/session-id.js +20 -0
- package/agent-claude/dist/session-map.js +165 -0
- package/agent-claude/dist/world-plugin.js +14 -0
- package/agent-claude/package.json +74 -0
- package/agent-codex/README.md +74 -0
- package/agent-codex/cordis.patch.yml +77 -0
- package/agent-codex/dist/adapter.js +55 -0
- package/agent-codex/dist/agent-preset-codex.js +171 -0
- package/agent-codex/dist/agent-preset-projection.js +37 -0
- package/agent-codex/dist/agent.js +1056 -0
- package/agent-codex/dist/app-home.js +65 -0
- package/agent-codex/dist/codex-client.js +469 -0
- package/agent-codex/dist/codex-events.js +297 -0
- package/agent-codex/dist/codex-store.js +190 -0
- package/agent-codex/dist/inbox.js +53 -0
- package/agent-codex/dist/index.js +605 -0
- package/agent-codex/dist/models.js +152 -0
- package/agent-codex/dist/permission.js +71 -0
- package/agent-codex/dist/session-map.js +170 -0
- package/agent-codex/dist/spike.js +46 -0
- package/agent-codex/dist/world-plugin.js +96 -0
- package/agent-codex/package.json +78 -0
- package/agent-hermes/README.md +144 -0
- package/agent-hermes/cordis.patch.yml +83 -0
- package/agent-hermes/dist/adapter.js +83 -0
- package/agent-hermes/dist/agent-preset-hermes.js +171 -0
- package/agent-hermes/dist/agent-preset-projection.js +34 -0
- package/agent-hermes/dist/agent.js +1068 -0
- package/agent-hermes/dist/hermes-client.js +953 -0
- package/agent-hermes/dist/hermes-events.js +289 -0
- package/agent-hermes/dist/hermes-store.js +158 -0
- package/agent-hermes/dist/inbox.js +53 -0
- package/agent-hermes/dist/index.js +541 -0
- package/agent-hermes/dist/models.js +324 -0
- package/agent-hermes/dist/permission.js +60 -0
- package/agent-hermes/dist/world-plugin.js +101 -0
- package/agent-hermes/package.json +76 -0
- package/agent-hub/cordis.patch.yml +12 -0
- package/agent-hub/dist/agent-roster.d.ts +34 -0
- package/agent-hub/dist/agent-roster.js +27 -0
- package/agent-hub/dist/carrier.d.ts +43 -0
- package/agent-hub/dist/carrier.js +241 -0
- package/agent-hub/dist/client-shim.d.ts +28 -0
- package/agent-hub/dist/client-shim.js +251 -0
- package/agent-hub/dist/envelope.d.ts +54 -0
- package/agent-hub/dist/envelope.js +92 -0
- package/agent-hub/dist/gateway.d.ts +28 -0
- package/agent-hub/dist/gateway.js +18 -0
- package/agent-hub/dist/index-pass.d.ts +22 -0
- package/agent-hub/dist/index-pass.js +47 -0
- package/agent-hub/dist/index.d.ts +29 -0
- package/agent-hub/dist/index.js +29 -0
- package/agent-hub/dist/labels.d.ts +45 -0
- package/agent-hub/dist/labels.js +83 -0
- package/agent-hub/dist/ownership.d.ts +37 -0
- package/agent-hub/dist/ownership.js +54 -0
- package/agent-hub/dist/roster.d.ts +28 -0
- package/agent-hub/dist/roster.js +38 -0
- package/agent-hub/dist/rpc.d.ts +38 -0
- package/agent-hub/dist/rpc.js +52 -0
- package/agent-hub/dist/spawn-world.d.ts +46 -0
- package/agent-hub/dist/spawn-world.js +72 -0
- package/agent-hub/dist/targets.d.ts +33 -0
- package/agent-hub/dist/targets.js +30 -0
- package/agent-hub/dist/world-entry.d.ts +31 -0
- package/agent-hub/dist/world-entry.js +51 -0
- package/agent-hub/dist/world-host.d.ts +69 -0
- package/agent-hub/dist/world-host.js +44 -0
- package/agent-hub/dist/world-join.d.ts +16 -0
- package/agent-hub/dist/world-join.js +122 -0
- package/agent-hub/dist/world-mount.d.ts +52 -0
- package/agent-hub/dist/world-mount.js +52 -0
- package/agent-hub/dist/world-mux.d.ts +72 -0
- package/agent-hub/dist/world-mux.js +224 -0
- package/agent-hub/dist/world-provision.d.ts +20 -0
- package/agent-hub/dist/world-provision.js +122 -0
- package/agent-hub/dist/world-web-server.d.ts +162 -0
- package/agent-hub/dist/world-web-server.js +248 -0
- package/agent-hub/lib/client/index.js +242 -0
- package/agent-hub/package.json +65 -0
- package/agent-omp/README.md +77 -0
- package/agent-omp/cordis.patch.yml +92 -0
- package/agent-omp/dist/adapter.js +36 -0
- package/agent-omp/dist/agent-preset-omp.js +171 -0
- package/agent-omp/dist/agent-preset-projection.js +36 -0
- package/agent-omp/dist/agent.js +1145 -0
- package/agent-omp/dist/app-home.js +86 -0
- package/agent-omp/dist/discovery.js +116 -0
- package/agent-omp/dist/inbox.js +53 -0
- package/agent-omp/dist/index.js +574 -0
- package/agent-omp/dist/knobs.js +50 -0
- package/agent-omp/dist/lazy-rpc.js +161 -0
- package/agent-omp/dist/mobile/gesture.js +148 -0
- package/agent-omp/dist/mobile/zoom-guard.js +294 -0
- package/agent-omp/dist/mobile-boot.js +38 -0
- package/agent-omp/dist/models.js +299 -0
- package/agent-omp/dist/omp-cli.js +147 -0
- package/agent-omp/dist/omp-disk-discovery.js +178 -0
- package/agent-omp/dist/omp-store.js +518 -0
- package/agent-omp/dist/pairing.js +45 -0
- package/agent-omp/dist/permission.js +96 -0
- package/agent-omp/dist/protocol.js +13 -0
- package/agent-omp/dist/rpc-types.js +1 -0
- package/agent-omp/dist/sdk-client.js +378 -0
- package/agent-omp/dist/sidecar-client.js +195 -0
- package/agent-omp/dist/store/adapter.js +47 -0
- package/agent-omp/dist/store/db.js +210 -0
- package/agent-omp/dist/store/index.js +59 -0
- package/agent-omp/dist/store/reconcile.js +51 -0
- package/agent-omp/dist/store/schema.js +40 -0
- package/agent-omp/dist/world-plugin.js +96 -0
- package/agent-omp/package.json +97 -0
- package/agent-omp/sidecar/main.ts +481 -0
- package/agent-pi/README.md +67 -0
- package/agent-pi/cordis.patch.yml +80 -0
- package/agent-pi/dist/adapter.js +53 -0
- package/agent-pi/dist/agent-preset-pi.js +169 -0
- package/agent-pi/dist/agent-preset-projection.js +29 -0
- package/agent-pi/dist/agent.js +949 -0
- package/agent-pi/dist/inbox.js +53 -0
- package/agent-pi/dist/index.js +595 -0
- package/agent-pi/dist/knobs.js +24 -0
- package/agent-pi/dist/models.js +200 -0
- package/agent-pi/dist/permission.js +66 -0
- package/agent-pi/dist/pi-client.js +224 -0
- package/agent-pi/dist/pi-events.js +155 -0
- package/agent-pi/dist/pi-home.js +55 -0
- package/agent-pi/dist/safe-json.js +25 -0
- package/agent-pi/dist/session-map.js +119 -0
- package/agent-pi/dist/world-plugin.js +97 -0
- package/agent-pi/package.json +77 -0
- package/cordis.patch.yml +22 -19
- package/package.json +88 -25
- 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
|
+
}
|