/** * @fileoverview The DeepSeek Harness -> Codeman status bridge. * * ## Why this exists * * Every external CLI mode before this one (opencode, codex, gemini, antigravity, * pi, grok) is READINESS-GUESSED: Codeman watches the PTY go quiet and infers a * turn ended. Claude is the exception, because Claude Code fires real hooks. The * DeepSeek Harness TUI gives us a third option, and a much better one than * guessing: the community terminal front door already reports its own lifecycle * to an owning supervisor, and it does so through a fully GENERIC, env-var-gated * contract it inherited from Herdr (herdr.dev). * * When all three of `HERDR_ENV=1`, `HERDR_BIN_PATH` and `HERDR_PANE_ID` are set, * the TUI shells out on every state change: * * "$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \ * --source custom:dsh-tui --agent dsh-tui \ * --state idle|working|blocked [--message ] --seq * * and treats exit code 0 as "delivered" (retrying with backoff otherwise). So * Codeman points `HERDR_BIN_PATH` at the script below and gets DEFINITIVE * idle/working/blocked signals for dsh sessions: real respawn triggers, real * `wait`/`wait-output` stop+blocked signals, and real Approvals Inbox items, * on par with Claude's hooks rather than with output stabilization. * * This is an interface implementation, not an impersonation: we implement the * one verb (`pane report-agent`) that the contract defines, and nothing on the * machine ever executes a real `herdr` binary — `HERDR_BIN_PATH` is our own * script, in our own data dir. `HERDR_ENV=1` is the flag the TUI checks to know * a supervisor is present; a supervisor IS present, it is Codeman. * * ## Why it is generated rather than committed * * The shim must be an executable file at a stable absolute path in every * install shape: a git clone (where `scripts/` exists), an `npm i -g aicodeman` * (where `files` ships only `dist` plus two named scripts), and any * `CODEMAN_INSTANCE`. Writing it into the data dir at session-create time makes * one code path cover all of them, single-sources the content here in TS, and * follows the precedent of `self-update-runner.sh`. It is rewritten whenever the * embedded version marker changes, so an upgraded Codeman refreshes a stale shim * without the user knowing it exists. * * @module deepseek-status-shim */ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import { dataPath } from './config/instance.js'; /** * Bumped whenever SHIM_SOURCE changes. The marker is embedded in the generated * file, so `ensureDeepSeekStatusShim()` can tell a current shim from one written * by an older Codeman and rewrite only when needed (rather than rewriting on * every session create, or — worse — leaving a stale one in place forever). */ const SHIM_VERSION = 3; const SHIM_MARKER = `codeman-dsh-status-shim v${SHIM_VERSION}`; /** * Mapping from the harness's three lifecycle states to Codeman hook events. * * - `blocked` -> `permission_prompt`: the TUI reports blocked when a tool * approval or an `ask_user_question` questionnaire is on screen, which is * exactly the red "needs you" alert and an answerable Approvals Inbox item. * - `idle` -> `stop`: the definitive end-of-turn signal, the one respawn and the * wait endpoints care about. * - `working` -> `agent_working`: a turn STARTED. Codeman infers "working" from * PTY output well enough on its own, but the event is what RESOLVES a pending * approval when the user answers a dialog in the terminal instead of in the * inbox. Without it a dsh session's red alert would survive until the next * `stop`, which is the exact stuck-alert bug the claude path already had to * fix once (and the pane-capture staleness sweep that fixed it there is * Claude-dialog-shaped, so it cannot help here). */ export const DEEPSEEK_STATE_TO_HOOK_EVENT: Readonly> = Object.freeze({ idle: 'stop', blocked: 'permission_prompt', working: 'agent_working', }); /** * The generated script. * * Constraints it must satisfy, each learned from an existing Codeman hook bug: * - **TLS**: `CODEMAN_API_URL` is loopback HTTPS with a self-signed cert on * `--https`/tailscale installs, so certificate verification is disabled for * the request. Without this the whole bridge dies silently, exactly as the * claude hook curls did before they grew `-k`. * - **Secret**: the hook-secret file is read AT EXECUTION TIME, never baked in, * so rotation needs no respawn and the value never lands on a command line. * - **Exit codes**: 0 means delivered. Anything else makes the TUI retry with * backoff, so transport failures self-heal, but an unknown verb or an * unmapped state exits 0 to avoid a pointless retry storm over something that * will never succeed. * - **Timeout**: bounded below the caller's own 2s budget, so we lose the race * deliberately rather than being killed mid-flight. */ const SHIM_SOURCE = `#!/usr/bin/env node // ${SHIM_MARKER} // GENERATED BY CODEMAN — do not edit. Rewritten from src/deepseek-status-shim.ts // whenever its version marker changes. // // Implements the one verb the DeepSeek Harness TUI's supervisor contract uses: // pane report-agent --state [--message ] ... // and forwards it to this Codeman instance as a hook event. import { readFileSync } from 'node:fs' import http from 'node:http' import https from 'node:https' const STATE_TO_EVENT = ${JSON.stringify(DEEPSEEK_STATE_TO_HOOK_EVENT)} const TIMEOUT_MS = 1500 const argv = process.argv.slice(2) const flag = (name) => { const i = argv.indexOf(name) return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined } // Unknown verb: succeed silently. Retrying could never make it succeed, and a // non-zero exit here would make the caller retry four times per state change. if (argv[0] !== 'pane' || argv[1] !== 'report-agent') process.exit(0) const event = STATE_TO_EVENT[String(flag('--state') ?? '')] if (!event) process.exit(0) // The pane id we hand the TUI IS the Codeman session id, but prefer the ambient // env: it is set by the same code that set HERDR_PANE_ID, so a TUI that mangles, // truncates or re-uses the pane argument still reports against the right session. // NOT a security boundary, and do not read it as one: the agent runs IN this pane // and can invoke the shim with CODEMAN_SESSION_ID unset and any argv it likes. // That buys it nothing it did not already have, since the hook-secret file is // readable from the same pane and any process there can POST /api/hook-event // directly. Attribution here is about accidents, not adversaries. const sessionId = process.env.CODEMAN_SESSION_ID || argv[2] const apiUrl = process.env.CODEMAN_API_URL if (!sessionId || !apiUrl) process.exit(1) let secret = '' try { secret = readFileSync(process.env.CODEMAN_HOOK_SECRET_FILE || '', 'utf-8').trim() } catch { // Missing file: the loopback bypass still applies when no tunnel is running. } // The contract's ordering token: the TUI retries failed deliveries with // backoff, so a stale report can land AFTER a newer one. Forwarded so the // server can drop out-of-order arrivals instead of, say, resolving an // approval with a retried 'working' while the harness sits blocked. const seq = Number(flag('--seq')) const body = JSON.stringify({ event, sessionId, data: { source: 'dsh-status-shim', agent: flag('--agent') || 'dsh', ...(Number.isFinite(seq) ? { seq } : {}), ...(flag('--message') ? { message: flag('--message') } : {}), }, }) let url try { url = new URL('/api/hook-event', apiUrl) } catch { process.exit(1) } const transport = url.protocol === 'https:' ? https : http const req = transport.request( { protocol: url.protocol, hostname: url.hostname, port: url.port, path: url.pathname, method: 'POST', timeout: TIMEOUT_MS, headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), 'X-Codeman-Hook-Secret': secret, }, // Loopback HTTPS with a self-signed cert (--https / tailscale installs). rejectUnauthorized: false, }, (res) => { res.resume() const status = res.statusCode ?? 0 // 2xx: delivered. 4xx: PERMANENT — a 401 (missing/rotated secret) or 429 // can never be fixed by retrying, and each retry feeds the auth-failure // rate-limit bucket, so a single misconfigured dsh session could 429 the // hook endpoint for the whole instance (killing every claude session's // real hooks). Exit 0 so the TUI does not retry; only transport errors // and 5xx stay retryable. process.exit(status >= 200 && status < 500 ? 0 : 1) } ) req.on('timeout', () => { req.destroy() process.exit(1) }) req.on('error', () => process.exit(1)) req.end(body) `; /** Absolute path of the generated shim for this instance. */ export function deepSeekStatusShimPath(): string { return dataPath('dsh-status-shim.mjs'); } let ensuredThisProcess = false; /** * Write the shim if it is missing or stale, and return its path. * * Idempotent and cheap: after the first call in a process it does nothing, and * even the first call only rewrites when the on-disk marker differs. Never * throws — a data dir that cannot be written is a degraded status bridge, not a * failed session start, so callers fall back to output-stabilization readiness * by receiving null. */ export function ensureDeepSeekStatusShim(): string | null { const path = deepSeekStatusShimPath(); if (ensuredThisProcess) return path; try { let current = ''; try { current = readFileSync(path, 'utf-8'); } catch { // Missing — fall through to the write. } if (!current.includes(SHIM_MARKER)) { mkdirSync(dirname(path), { recursive: true }); // Temp + rename, not a plain write: the TUI can be executing this exact // path at the moment an upgraded Codeman refreshes it (every state change // runs it, and session create is when the rewrite happens), and a reader // that catches a half-written file gets a syntax error, exits non-zero, // and is retried four times per state change for a file that will never // parse. rename(2) is atomic within the directory, so a concurrent exec // sees either the old shim or the new one, never a truncated one. // Same reasoning as the state-store writes; pid-suffixed so two instances // sharing a data dir cannot collide on the temp name. const tempPath = `${path}.${process.pid}.tmp`; try { writeFileSync(tempPath, SHIM_SOURCE, { mode: 0o700 }); // The mode argument only applies when writeFileSync CREATES the file, so // a leftover temp from a crashed run would keep its old permissions. chmodSync(tempPath, 0o700); renameSync(tempPath, path); } catch (err) { rmSync(tempPath, { force: true }); throw err; } } // Re-assert the mode even when the content matched: a shim that lost its // executable bit (a restored backup, a copied data dir) would make every // report fail, and the TUI would retry four times per state change forever. chmodSync(path, 0o700); ensuredThisProcess = true; return path; } catch (err) { console.warn(`[DeepSeek] Could not install the status shim at ${path}: ${(err as Error).message}`); return null; } } /** Test seam: forget the per-process memo so a fresh temp HOME is re-provisioned. */ export function resetDeepSeekStatusShimForTest(): void { ensuredThisProcess = false; }