dsh-claude-compat 0.3.0 → 0.7.0

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.
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env bash
2
+ # dsh-restart.sh — restart the DSH Web GUI safely, with an optional
3
+ # terminal-bash prompt patch.
4
+ #
5
+ # What it does:
6
+ # 1. (optional, --no-patch to skip) Patch the dsh-terminal-bash plugin's
7
+ # hardcoded "dsh> " prompt so DSH's own placeholder prompt is honored.
8
+ # Idempotent: skips when already patched. Re-run after every npx/npm
9
+ # refresh of DSH (the cache overwrite reverts the patch).
10
+ # 2. Stop any running `dsh web` (graceful wait, up to 10s).
11
+ # 3. Start `dsh web` in the background (nohup), log to a temp file.
12
+ # 4. Wait until http://127.0.0.1:3080 responds (up to 30s); on timeout
13
+ # print the last 20 log lines and exit 1.
14
+ #
15
+ # Requires: bash, curl. Works with a global `dsh`, an nvm-installed `dsh`,
16
+ # or the npx cache layout (`~/.npm/_npx/*/node_modules/.bin/dsh`).
17
+ set -euo pipefail
18
+
19
+ PORT="${DSH_RESTART_PORT:-3080}"
20
+ PATCH=1
21
+ for arg in "$@"; do
22
+ case "$arg" in
23
+ --no-patch) PATCH=0 ;;
24
+ -h|--help) sed -n '2,16p' "$0"; exit 0 ;;
25
+ *) echo "unknown arg: $arg (see --help)" >&2; exit 2 ;;
26
+ esac
27
+ done
28
+
29
+ # ── 1. terminal-bash prompt patch (idempotent) ───────────────────────────────
30
+ if [ "$PATCH" = 1 ]; then
31
+ OLD_PROMPT='const CONTROLLED_PROMPT = "dsh> ";'
32
+ NEW_PROMPT='const CONTROLLED_PROMPT = "__DSH_PERSISTENT_BASH_PROMPT__ ";'
33
+ OLD_LEN='Math.max(0, 6 - this.promptTail.length)'
34
+ NEW_LEN='Math.max(0, CONTROLLED_PROMPT.length + 1 - this.promptTail.length)'
35
+
36
+ # Find every installed copy of dsh-terminal-bash: npx cache + any global
37
+ # node_modules reachable from `dsh`'s real location.
38
+ CANDIDATES="$(ls "$HOME"/.npm/_npx/*/node_modules/@deepseek-ai/dsh-terminal-bash/lib/index.js 2>/dev/null || true)"
39
+ DSH_ON_PATH="$(command -v dsh || true)"
40
+ if [ -n "$DSH_ON_PATH" ]; then
41
+ REAL="$(readlink -f "$DSH_ON_PATH" 2>/dev/null || echo "$DSH_ON_PATH")"
42
+ ROOT="$(dirname "$(dirname "$(dirname "$(dirname "$REAL")")")")"
43
+ [ -f "$ROOT/@deepseek-ai/dsh-terminal-bash/lib/index.js" ] && CANDIDATES="$CANDIDATES
44
+ $ROOT/@deepseek-ai/dsh-terminal-bash/lib/index.js"
45
+ fi
46
+
47
+ if [ -z "$CANDIDATES" ]; then
48
+ echo "[fix] no dsh-terminal-bash installation found; skipping patch"
49
+ else
50
+ for FILE in $CANDIDATES; do
51
+ if grep -qF "$NEW_PROMPT" "$FILE" && grep -qF "$NEW_LEN" "$FILE"; then
52
+ echo "[fix] already patched: $FILE"
53
+ continue
54
+ fi
55
+ echo "[fix] patching: $FILE"
56
+ if sed -i.bak -e "s|$OLD_PROMPT|$NEW_PROMPT|" -e "s|$OLD_LEN|$NEW_LEN|" "$FILE" 2>/dev/null \
57
+ || sed -i '' -e "s|$OLD_PROMPT|$NEW_PROMPT|" -e "s|$OLD_LEN|$NEW_LEN|" "$FILE"; then
58
+ rm -f "$FILE.bak"
59
+ grep -qF "$NEW_PROMPT" "$FILE" && grep -qF "$NEW_LEN" "$FILE" \
60
+ || { echo "[fix] patch failed on $FILE"; exit 1; }
61
+ else
62
+ echo "[fix] cannot write $FILE (permissions?); skipping"; continue
63
+ fi
64
+ done
65
+ fi
66
+ fi
67
+
68
+ # ── 2. stop running dsh web ──────────────────────────────────────────────────
69
+ echo "[restart] stopping dsh web"
70
+ pkill -f "dsh web" 2>/dev/null || true
71
+ for _ in $(seq 1 20); do pgrep -f "dsh web" >/dev/null || break; sleep 0.5; done
72
+ if pgrep -f "dsh web" >/dev/null; then
73
+ echo "[restart] dsh web did not stop; last resort SIGKILL"
74
+ pkill -9 -f "dsh web" 2>/dev/null || true
75
+ sleep 1
76
+ pgrep -f "dsh web" >/dev/null && { echo "[restart] cannot stop dsh web"; exit 1; }
77
+ fi
78
+
79
+ # ── 3. locate the dsh binary ─────────────────────────────────────────────────
80
+ DSH_BIN="$(command -v dsh || true)"
81
+ if [ -z "$DSH_BIN" ]; then
82
+ # newest npx cache entry first (npm exec resolves to the same one)
83
+ DSH_BIN="$(ls -t "$HOME"/.npm/_npx/*/node_modules/.bin/dsh 2>/dev/null | head -1 || true)"
84
+ fi
85
+ if [ -z "$DSH_BIN" ]; then
86
+ echo "[restart] no dsh binary found (PATH or npx cache). Install with: npm install -g @deepseek-ai/dsh" >&2
87
+ exit 1
88
+ fi
89
+ echo "[restart] using: $DSH_BIN"
90
+
91
+ # ── 4. start + health-check ──────────────────────────────────────────────────
92
+ LOG="$(mktemp -t dsh-web-restart)"
93
+ echo "[restart] starting dsh web on :$PORT"
94
+ nohup "$DSH_BIN" web >"$LOG" 2>&1 &
95
+ PID=$!
96
+ echo "[restart] pid=$PID log=$LOG"
97
+ for _ in $(seq 1 30); do
98
+ if curl -sf -o /dev/null "http://127.0.0.1:$PORT"; then
99
+ echo "[restart] http://127.0.0.1:$PORT is up ✅"
100
+ exit 0
101
+ fi
102
+ kill -0 "$PID" 2>/dev/null || break
103
+ sleep 1
104
+ done
105
+ echo "[restart] failed; last log lines:"
106
+ tail -20 "$LOG"
107
+ exit 1
@@ -0,0 +1,19 @@
1
+ // zstd-compat.mjs — compress strings with the `zstd` binary via stdin.
2
+ // (macOS: `brew install zstd`; most Linux CI images and the DSH runtime
3
+ // environment already ship it — DSH itself shell-outs to zstd for its own
4
+ // session logs.)
5
+ import { execFileSync } from 'node:child_process';
6
+
7
+ export function zstdSync(text) {
8
+ return execFileSync('zstd', ['-3', '-T0', '-q', '-'], {
9
+ input: text,
10
+ maxBuffer: 1 << 28,
11
+ stdio: ['pipe', 'pipe', 'inherit'],
12
+ });
13
+ }
14
+
15
+ // DSH session logs require the first zstd frame to hold exactly the header
16
+ // line (assertZstdHeaderFrame); events must be in separate frames.
17
+ export function zstdFramesSync(...texts) {
18
+ return Buffer.concat(texts.map((t) => zstdSync(t)));
19
+ }
package/src/agents.js ADDED
@@ -0,0 +1,77 @@
1
+ // dsh-claude-compat: .claude/agents/*.md compatibility. DSH has no markdown
2
+ // subagent-definition format, so each agent file is surfaced as a DSH *skill*
3
+ // candidate: the frontmatter name (or filename stem) becomes the skill name
4
+ // (model- and user-invocable, so `/agent-name` works), the description doubles
5
+ // as whenToUse, and get() prefixes the body with an explicit subagent-delegation
6
+ // instruction. Project .claude/agents wins over ~/.claude/agents by name via
7
+ // the provider's rank (50 < 700) plus the shared dedupe flow.
8
+
9
+ import { join } from 'node:path';
10
+ import { isSkillName } from '@deepseek-ai/dsh-skill';
11
+ import {
12
+ readTextSafe,
13
+ stringField,
14
+ parseFrontmatter,
15
+ pathExists,
16
+ } from './lib.js';
17
+
18
+ const AGENT_HEADER = 'Agent delegation: use the subagent/background-agent capability with this persona and instructions.';
19
+
20
+ // Discover agent candidates from one agents dir (flat: *.md). Returns DSH skill
21
+ // candidates in the same shape as discoverSkills/discoverCommands so the
22
+ // registry's dedupe treats them uniformly.
23
+ export async function discoverAgents(rootDir, providerName, source, rank) {
24
+ const out = [];
25
+ if (!(await pathExists(rootDir))) return out;
26
+ let entries;
27
+ try {
28
+ const { readdir } = await import('node:fs/promises');
29
+ entries = await readdir(rootDir, { withFileTypes: true, encoding: 'utf8' });
30
+ } catch { return out; }
31
+ for (const entry of entries) {
32
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
33
+ const path = join(rootDir, entry.name);
34
+ const raw = await readTextSafe(path);
35
+ if (raw === undefined) continue;
36
+ const parsed = parseFrontmatter(raw);
37
+ const data = parsed?.data ?? {};
38
+ const stem = entry.name.slice(0, -3);
39
+ let name = stringField(data, 'name');
40
+ if (name === undefined || !isSkillName(name)) name = stem;
41
+ if (!isSkillName(name)) continue; // non-kebab names rejected by registry anyway
42
+ const description = stringField(data, 'description')
43
+ ?? firstLine(parsed?.body ?? raw, 100);
44
+ const tools = stringField(data, 'tools');
45
+ out.push({
46
+ name,
47
+ description,
48
+ whenToUse: description,
49
+ invocation: { modelInvocable: true, userInvocable: true },
50
+ provider: providerName,
51
+ source,
52
+ rank,
53
+ locator: { path, directory: rootDir },
54
+ resourceBase: { kind: 'directory', path: rootDir },
55
+ path,
56
+ agentTools: tools,
57
+ });
58
+ }
59
+ return out;
60
+ }
61
+
62
+ function firstLine(body, max) {
63
+ if (typeof body !== 'string') return undefined;
64
+ const line = body.split('\n').find((l) => l.trim() !== '') ?? '';
65
+ const trimmed = line.trim();
66
+ if (trimmed === '') return undefined;
67
+ return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed;
68
+ }
69
+
70
+ // Render the content the skill tool returns for an agent-backed candidate.
71
+ export function renderAgentContent(candidate, body) {
72
+ const lines = [AGENT_HEADER];
73
+ if (candidate.agentTools !== undefined) lines.push(`Tools: ${candidate.agentTools}`);
74
+ lines.push('');
75
+ lines.push(body);
76
+ return lines.join('\n');
77
+ }
@@ -0,0 +1,35 @@
1
+ // Skill body for /cc-resume — instructs the model to drive scripts/cc-resume.mjs.
2
+ export const CC_RESUME_SKILL_BODY = `# Claude Code Session Resume
3
+
4
+ List and import Claude Code conversation sessions into DSH. Imported sessions
5
+ appear in the DSH session list (refresh the GUI) with full user/assistant/tool
6
+ history and can be resumed like any native session.
7
+
8
+ ## Commands
9
+ Run from the session's working directory (or pass \`--cwd <dir>\`):
10
+
11
+ 1. **List** Claude sessions for the current project:
12
+ \`\`\`bash
13
+ node <plugin-root>/scripts/cc-resume.mjs list --limit 20
14
+ \`\`\`
15
+ Output: one line per session — imported marker (\`*\`), id, date, message
16
+ count, first-user-text preview. Pick the \`sessionId\` the user wants.
17
+
18
+ 2. **Import** a session:
19
+ \`\`\`bash
20
+ node <plugin-root>/scripts/cc-resume.mjs import <sessionId>
21
+ \`\`\`
22
+ Options: \`--limit-turns N\` imports only the last N turns (useful for huge
23
+ sessions), \`--cwd <dir>\` overrides the project directory.
24
+
25
+ 3. Tell the user: refresh the DSH GUI session list and open the session titled
26
+ \`cc: <preview>\`, or the imported id can be resumed directly.
27
+
28
+ ## Behavior notes
29
+ - Imports are main-thread only (Claude sidechain/subagent messages skipped).
30
+ - Thinking blocks are dropped (DSH reasoning is stream-based; replaying
31
+ reconstructed reasoning would corrupt the log).
32
+ - Re-importing the same session is idempotent (same target, events rewritten).
33
+ - The imported conversation is static history — continuing it starts a fresh
34
+ DSH turn flow on top of that context.
35
+ - Requires the \`zstd\` binary on PATH (macOS: \`brew install zstd\`).`;
package/src/hooks.js ADDED
@@ -0,0 +1,275 @@
1
+ // dsh-claude-compat: Claude Code hooks subset (PreToolUse / PostToolUse /
2
+ // UserPromptSubmit) bridged onto DSH's tools/pre-execute, tools/post-execute,
3
+ // and agent/pre-step waterfalls.
4
+ //
5
+ // Hooks are read from <projectRoot>/.claude/settings.json (and ~/.claude/
6
+ // settings.json; project wins on duplicate command strings). Commands run via
7
+ // /bin/sh -c with a Claude-style JSON payload on stdin. Decisions follow Claude
8
+ // Code semantics (exit code 2 = deny/block, hookSpecificOutput override,
9
+ // timeouts allowed-through with a warning).
10
+
11
+ import { execFile } from 'node:child_process';
12
+ import { readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
15
+ import {
16
+ findProjectRootSync,
17
+ resolveUserClaudeDir,
18
+ } from './lib.js';
19
+
20
+ // Compile a Claude Code hook matcher ("Bash", "Bash|Edit", ".*") into a
21
+ // predicate. Exact names match exactly; `*` / `.*` are wildcards; `|`
22
+ // alternates.
23
+ export function compileMatcher(matcher) {
24
+ if (typeof matcher !== 'string') return () => false;
25
+ const trimmed = matcher.trim();
26
+ if (trimmed === '' || trimmed === '*' || trimmed === '.*') return () => true;
27
+ try {
28
+ const pattern = trimmed
29
+ .split('|')
30
+ .map((part) => part.trim().split('*').join('.*'))
31
+ .join('|');
32
+ const re = new RegExp(`^(?:${pattern})$`);
33
+ return (name) => re.test(name);
34
+ } catch {
35
+ return () => false;
36
+ }
37
+ }
38
+
39
+ // ----- payloads ---------------------------------------------------------------
40
+
41
+ function preToolPayload(exec) {
42
+ return JSON.stringify({
43
+ session_id: String(exec.callId),
44
+ tool_name: exec.name,
45
+ tool_input: exec.arguments ?? {},
46
+ });
47
+ }
48
+
49
+ function postToolPayload(exec, result) {
50
+ return JSON.stringify({
51
+ tool_name: exec.name,
52
+ tool_input: exec.arguments ?? {},
53
+ tool_response: result,
54
+ });
55
+ }
56
+
57
+ function promptPayload(agent) {
58
+ let prompt = '';
59
+ const events = agent.session?.events ?? {};
60
+ for (const event of Object.values(events)) {
61
+ const data = event?.data;
62
+ if (event?.type?.endsWith('user/message') && typeof data?.content === 'string') {
63
+ prompt = data.content;
64
+ }
65
+ }
66
+ return JSON.stringify({ prompt });
67
+ }
68
+
69
+ // ----- decision mapping (pure, exported for tests) -----------------------------
70
+
71
+ // PreToolUse: exit 0 → allow; exit 2 → deny (stdout reason); other codes →
72
+ // allow with warning. stdout JSON {"hookSpecificOutput": {...permissionDecision}}
73
+ // overrides: "deny" → deny, "ask" → ask (best effort).
74
+ export function mapPreHookOutput(exitCode, stdout, { warn = console.warn } = {}) {
75
+ if (exitCode === 0) {
76
+ const specific = tryHookJson(stdout)?.hookSpecificOutput;
77
+ if (specific?.permissionDecision === 'deny') {
78
+ return { kind: 'deny', reason: String(specific.permissionDecisionReason ?? fallbackReason(stdout)) };
79
+ }
80
+ if (specific?.permissionDecision === 'ask') {
81
+ return { kind: 'ask', reason: String(specific.permissionDecisionReason ?? 'hook requests approval') };
82
+ }
83
+ return { kind: 'allow' };
84
+ }
85
+ if (exitCode === 2) {
86
+ return { kind: 'deny', reason: fallbackReason(stdout) };
87
+ }
88
+ warn(`dsh-claude-compat: PreToolUse hook exited with code ${exitCode}, allowing`);
89
+ return { kind: 'allow' };
90
+ }
91
+
92
+ // PostToolUse: exit 2 → block (stdout feedback); exit 0 with JSON stdout
93
+ // carrying an "additionalContext" string → accept with that context as an
94
+ // additional user message; anything else → accept, stdout log-only.
95
+ export function mapPostHookOutput(exitCode, stdout, { warn = console.warn } = {}) {
96
+ if (exitCode === 2) {
97
+ return { kind: 'block', feedback: [{ type: 'text', text: fallbackReason(stdout) }] };
98
+ }
99
+ if (exitCode !== 0) {
100
+ warn(`dsh-claude-compat: PostToolUse hook exited with code ${exitCode}, accepting`);
101
+ return { kind: 'accept' };
102
+ }
103
+ const trimmed = stdout.trim();
104
+ if (trimmed === '') return { kind: 'accept' };
105
+ const additional = tryHookJson(stdout)?.additionalContext;
106
+ if (typeof additional === 'string' && additional.length > 0) {
107
+ return {
108
+ kind: 'accept',
109
+ additionalContexts: [createUserMessage({
110
+ content: [{ type: 'text', text: additional }],
111
+ source: { kind: 'claude-compat', form: 'hook-context' },
112
+ })],
113
+ };
114
+ }
115
+ console.log(`dsh-claude-compat: PostToolUse hook stdout (logged, not injected): ${trimmed}`);
116
+ return { kind: 'accept' };
117
+ }
118
+
119
+ function fallbackReason(stdout) {
120
+ const trimmed = stdout.trim();
121
+ return trimmed !== '' ? trimmed : 'blocked by hook';
122
+ }
123
+
124
+ function tryHookJson(stdout) {
125
+ const s = stdout.trim();
126
+ if (s === '') return undefined;
127
+ try { return JSON.parse(s); } catch { return undefined; }
128
+ }
129
+
130
+ // ----- runner ---------------------------------------------------------------
131
+
132
+ function runHookCommand(command, stdin, { timeoutMs = 60_000 } = {}) {
133
+ return new Promise((resolve) => {
134
+ let stdout = '';
135
+ let settled = false;
136
+ const child = execFile('/bin/sh', ['-c', command], {
137
+ maxBuffer: 4 * 1024 * 1024,
138
+ });
139
+ const timer = setTimeout(() => {
140
+ if (settled) return;
141
+ settled = true;
142
+ child.kill('SIGKILL');
143
+ console.warn(`dsh-claude-compat: hook timed out after ${timeoutMs}ms, killed: ${command}`);
144
+ resolve({ exitCode: 1, stdout }); // treat as non-2, allow-through
145
+ }, timeoutMs);
146
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
147
+ child.stderr.on('data', (chunk) => { stdout += chunk; }); // surface stderr in feedback
148
+ child.on('error', () => {
149
+ if (settled) return;
150
+ settled = true;
151
+ clearTimeout(timer);
152
+ console.warn(`dsh-claude-compat: hook command failed to start: ${command}`);
153
+ resolve({ exitCode: 1, stdout });
154
+ });
155
+ child.on('close', (code) => {
156
+ if (settled) return;
157
+ settled = true;
158
+ clearTimeout(timer);
159
+ resolve({ exitCode: code ?? 1, stdout });
160
+ });
161
+ child.stdin.on('error', () => {});
162
+ child.stdin.end(stdin);
163
+ });
164
+ }
165
+
166
+ // ----- settings loading ------------------------------------------------------
167
+
168
+ const EVENTS = { PreToolUse: 'pre-tool-use', PostToolUse: 'post-tool-use', UserPromptSubmit: 'user-prompt-submit' };
169
+
170
+ // Combined hooks map. Project .claude/settings.json is applied LAST so it wins
171
+ // same (event, matcher, command) keys against ~/.claude/settings.json.
172
+ export function loadHookSettings(config) {
173
+ const combined = {};
174
+ applySettingsFile(combined, join(resolveUserClaudeDir(config.userClaudeDir), 'settings.json'));
175
+ const projectRoot = findProjectRootSync(process.cwd(), config.projectRootMarkers);
176
+ if (projectRoot !== undefined) {
177
+ applySettingsFile(combined, join(projectRoot, '.claude', 'settings.json'));
178
+ }
179
+ return combined;
180
+ }
181
+
182
+ function applySettingsFile(combined, path) {
183
+ let raw;
184
+ try { raw = readFileSync(path, 'utf8'); } catch { return; }
185
+ let parsed;
186
+ try { parsed = JSON.parse(raw); } catch {
187
+ console.warn(`dsh-claude-compat: unparseable settings.json: ${path}`);
188
+ return;
189
+ }
190
+ const hooks = parsed?.hooks;
191
+ if (typeof hooks !== 'object' || hooks === null || Array.isArray(hooks)) return;
192
+ for (const [eventName, entries] of Object.entries(hooks)) {
193
+ const event = EVENTS[eventName];
194
+ if (event === undefined || !Array.isArray(entries)) continue;
195
+ for (const entry of entries) {
196
+ if (typeof entry !== 'object' || entry === null) continue;
197
+ const matcher = typeof entry.matcher === 'string' ? entry.matcher : '.*';
198
+ const commands = Array.isArray(entry.hooks)
199
+ ? entry.hooks
200
+ .filter((h) => typeof h === 'object' && h !== null && typeof h.command === 'string')
201
+ .map((h) => h.command)
202
+ : [];
203
+ for (const command of commands) {
204
+ combined[`${event}\u0000${matcher}\u0000${command}`] = { event, matcher, command };
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ // ----- listeners -------------------------------------------------------------
211
+
212
+ export function registerHooks(ctx, config) {
213
+ const settings = loadHookSettings(config);
214
+ const matching = (hooks, name) =>
215
+ hooks.filter((h) => compileMatcher(h.matcher)(name));
216
+
217
+ const pre = Object.values(settings).filter((h) => h.event === 'pre-tool-use');
218
+ if (pre.length > 0) {
219
+ ctx.on('tools/pre-execute', async (exec) => {
220
+ for (const hook of matching(pre, exec.name)) {
221
+ const payload = preToolPayload(exec);
222
+ const run = await runHookCommand(hook.command, payload, { timeoutMs: config.hooksTimeoutMs ?? 60_000 });
223
+ const decision = mapPreHookOutput(run.exitCode, run.stdout);
224
+ if (decision.kind !== 'allow') return decision;
225
+ }
226
+ return { kind: 'allow' };
227
+ });
228
+ }
229
+
230
+ const post = Object.values(settings).filter((h) => h.event === 'post-tool-use');
231
+ if (post.length > 0) {
232
+ ctx.on('tools/post-execute', async (exec, result) => {
233
+ let accept;
234
+ for (const hook of matching(post, exec.name)) {
235
+ const payload = postToolPayload(exec, result);
236
+ const run = await runHookCommand(hook.command, payload, { timeoutMs: config.hooksTimeoutMs ?? 60_000 });
237
+ const decision = mapPostHookOutput(run.exitCode, run.stdout);
238
+ if (decision.kind === 'block') return decision;
239
+ if (decision.additionalContexts !== undefined) accept = decision;
240
+ }
241
+ return accept ?? { kind: 'accept' };
242
+ });
243
+ }
244
+
245
+ const prompt = Object.values(settings).filter((h) => h.event === 'user-prompt-submit');
246
+ if (prompt.length > 0) {
247
+ ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
248
+ const decision = await next();
249
+ if (decision.kind !== 'enter') return decision;
250
+ // First enter of the turn: the DSH loop hands the step the user claims,
251
+ // which is where Claude Code would have fired UserPromptSubmit. Hook
252
+ // output becomes an extra user message appended after the claimed ones.
253
+ const extra = await runPromptHooks(prompt, agent, config);
254
+ if (extra === undefined) return decision;
255
+ return { kind: 'enter', messages: decision.messages.concat(extra) };
256
+ });
257
+ }
258
+ }
259
+
260
+ async function runPromptHooks(hooks, agent, config) {
261
+ const timeoutMs = Math.min(config.hooksTimeoutMs ?? 60_000, 10_000); // never block the loop longer than 10s
262
+ const payload = promptPayload(agent);
263
+ const extra = [];
264
+ for (const hook of hooks) {
265
+ const run = await runHookCommand(hook.command, payload, { timeoutMs });
266
+ if (run.exitCode !== 0) continue; // non-zero: skip, never block a prompt
267
+ const text = run.stdout.trim();
268
+ if (text === '') continue;
269
+ extra.push(createUserMessage({
270
+ content: [{ type: 'text', text }],
271
+ source: { kind: 'claude-compat', form: 'hook-context' },
272
+ }));
273
+ }
274
+ return extra.length > 0 ? extra : undefined;
275
+ }