Files
Codeman/src/web/response-viewer-transcript.ts
T
Codeman maintainer 4cda150493 feat(deepseek): add DeepSeek Harness (dsh) as a ninth CLI run mode
Adds `mode: 'deepseek'` alongside claude/shell/opencode/codex/gemini/
antigravity/pi/grok, plus a shortcut that opens the harness's own browser UI
as a Codeman web tab.

DeepSeek is wired unlike its siblings in three ways, each of which is the
reason for a design decision rather than an accident:

1. The agent is a PROFILE, not the binary. `dsh` is a launcher over
   $DSH_HOME/profiles/<name>, and DeepSeek ships only `web`, `headless` and
   `base` -- the interactive terminal front door is always a third-party
   plugin. So availability is two questions: `isDeepSeekAvailable()` (binary)
   and `isDeepSeekRunnable()` (binary AND a pane-capable profile). The Run
   button gates on the latter, because reporting only the binary would spawn a
   pane that dies on arrival. When the binary is present but no profile is,
   the run menu offers to install one (POST /api/deepseek/install-profile).

2. The permission switch is an env var, not a flag. The harness has no
   command-line permission option; its sandbox/approval rows read
   DSH_PERMISSION_MODE (read-only / workspace-write / danger-full-access).
   Exported via `tmux setenv`, never on the spawn line. Absent = the harness's
   own workspace-write, which still asks, so the multi-user clamp is the
   only-if-sent branch and clamps to workspace-write, never read-only.

3. It is the only non-claude mode that passes hooksAvailableForMode(), and it
   earned that. The terminal front door reports idle/working/blocked to a
   supervising process over a generic env-gated contract; a generated shim
   (deepseek-status-shim.ts) makes Codeman that supervisor and forwards each
   report to /api/hook-event as stop / agent_working / permission_prompt. So a
   dsh session gets definitive respawn triggers, real wait-endpoint signals and
   real Approvals Inbox items instead of output-stabilization guesswork.
   `agent_working` is new (157th SSE constant) and joins
   APPROVAL_RESOLVING_EVENTS so a dialog answered in the terminal clears its
   alert at once.

The resolver needs the strictest identity probe of the family: `dsh` is not
merely a squattable npm name, Debian ships an unrelated `dsh` (dancer's shell),
so `dsh --help` must print the harness's own banner before a candidate is
handed a spawn line.

Model is deliberately not a session field -- it is a composition entry in the
profile's config tree. Env allowlist gains DSH_* and DEEPSEEK_* only; provider
keys named by a settings-file `apiKeyEnv` stay out, which is pi's
34-provider-key problem in a new shape.

Verified live against dsh 0.1.1-rc.2 and @deepseek-harness-tui/dsh-tui: the
status endpoint's two-part answer, the no-profile refusal, the profile
bootstrap, a real session whose pane runs `dsh --profile dsh-tui` with the
permission mode injected via setenv, and the full status bridge -- a
send-and-wait returned signal "stop" from a real turn, and blocked/working
created and cleared an Approvals Inbox item.

Docs: docs/deepseek-integration.md (guide), docs/deepseek-integration-plan.md
(decisions + honest gaps). Tests: test/deepseek-mode.test.ts,
test/deepseek-cli-resolver.test.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 03:37:56 +02:00

362 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export type ResponseViewerTranscriptKind = 'prompt' | 'response' | 'status' | 'tool';
export interface ResponseViewerTranscriptBlock {
kind: ResponseViewerTranscriptKind;
label: 'Prompt' | 'Response' | 'Status' | 'Tool';
/** What the frontend renders by: 'user' gets the "You" badge, everything else the agent badge. */
role: 'user' | 'assistant';
text: string;
}
// Keep in lockstep with isExternalCliMode() in src/session.ts. Importing it here
// would drag node-pty and the whole session layer into this pure module, so the
// list is duplicated and test/response-viewer-transcript.test.ts pins the parity.
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek']);
function isPromptLine(line: string): boolean {
return /^\s*›\s*/.test(line);
}
function isDividerDashChar(ch: string): boolean {
return ch === '─' || ch === '-';
}
function isWhitespaceChar(ch: string): boolean {
return /\s/.test(ch);
}
// Linear-time equivalent of the old /^[─-]+\s*(.+?)\s*[─-]{3,}$/. The lazy
// middle of that pattern backtracked catastrophically on a long dash run that
// does NOT end in 3+ dashes (measured >2min at 8,000 chars) — and pane text is
// agent-controlled with buffers up to 32MB, so this ran on hostile input. Same
// accept set and same captured content, computed with counters; equivalence is
// pinned char-for-char against the old regex by the brute-force corpus test in
// test/response-viewer-transcript.test.ts.
function normalizeDividerStatusLine(line: string): string | null {
const s = line.trim();
const n = s.length;
// Minimum match: 1 leading dash + 1 content char + 3 trailing dashes.
if (n < 5) return null;
let lead = 0;
while (lead < n && isDividerDashChar(s.charAt(lead))) lead += 1;
if (lead === 0) return null;
let trail = 0;
while (trail < n && isDividerDashChar(s.charAt(n - 1 - trail))) trail += 1;
if (trail < 3) return null;
// The regex was greedy on the leading run but gave dashes back until at least
// one content char plus the 3-dash tail fit (an all-dash line matched with a
// single leftover dash as its "content"), so the content window starts at the
// end of the leading run, clamped to leave 4 chars.
const contentStart = Math.min(lead, n - 4);
let ws = 0;
while (contentStart + ws < n - 4 && isWhitespaceChar(s.charAt(contentStart + ws))) ws += 1;
const from = contentStart + ws;
// The lazy middle stopped at the first position from which "optional
// whitespace, then dashes to end-of-line" matches: the start of the
// whitespace padding in front of the trailing dash run (never before the
// first content char).
const tailStart = n - trail;
let padded = tailStart;
while (padded > 0 && isWhitespaceChar(s.charAt(padded - 1))) padded -= 1;
const end = Math.max(from + 1, padded);
return s.slice(from, end).trim() || null;
}
function isDividerOnlyLine(line: string): boolean {
return /^[\s─-]{8,}$/.test(line.trim());
}
// COD-227: unambiguous Codex tool-call markers. These only ever appear as internal
// activity, never as ordinary assistant prose, so they are always a Tool block.
function isToolActivityMarker(line: string): boolean {
return /^\s*[•*-]\s+(Calling|Called)\b/.test(line.trim());
}
// COD-227: action verbs that ALSO occur in ordinary assistant prose (e.g.
// "• Created COD-226: …"). These are a Tool header only when corroborated by a
// box-drawing result tree on the next non-blank line (see the caller); the verb
// alone is not sufficient.
function isToolVerbBullet(line: string): boolean {
return /^\s*[•*-]\s+(Explored|Viewed|Read|Edited|Updated|Created|Deleted|Ran|Searched|Opened|Listed|Found|Applied|Patched|Used|Wrote|Executed|Modified|Analyzed|Compared|Fetched|Installed)\b/.test(
line.trim()
);
}
// A genuine Codex tool block renders its result as a box-drawing tree (└ │ ├).
function isBoxDrawingLine(line: string): boolean {
return /^[│├└]/.test(line.trim());
}
// Look past blank lines from `fromIndex + 1` for the next non-blank line and report
// whether it is a box-drawing tool-result line — the signal that a verb bullet is a
// real tool block rather than assistant prose that happens to start with a verb.
function nextNonBlankIsBoxDrawing(lines: string[], fromIndex: number): boolean {
for (let j = fromIndex + 1; j < lines.length; j += 1) {
const trimmed = (lines[j] || '').trim();
if (!trimmed) continue;
return isBoxDrawingLine(trimmed);
}
return false;
}
function isToolContinuationLine(line: string, currentKind: ResponseViewerTranscriptKind | null): boolean {
if (currentKind !== 'tool') return false;
const trimmed = line.trimEnd();
if (!trimmed) return true;
return /^\s*[│├└]/.test(trimmed) || /^\s{2,}\S/.test(line);
}
function isStatusLine(line: string, mode: string): boolean {
if (!EXTERNAL_CLI_MODES.has(mode)) return false;
const trimmed = line.trim();
if (!trimmed) return false;
if (normalizeDividerStatusLine(trimmed)) return true;
if (/^(model|directory):\s+/i.test(trimmed)) return true;
if (/\bContext\b.*\bleft\b/i.test(trimmed)) return true;
if (/\b\/model to change\b/i.test(trimmed)) return true;
if (/\bReady\b/i.test(trimmed) && /·/.test(trimmed)) return true;
if (/^(gpt|o\d|claude|gemini)\b/i.test(trimmed) && /·/.test(trimmed)) return true;
if (/^Tip:/i.test(trimmed)) return true;
if (/^\s*[•*-]\s+(Working|Thinking|Loading|Starting\b|Waiting\b)/i.test(trimmed)) return true;
return false;
}
function isStandaloneMarkdownLine(line: string): boolean {
const trimmed = line.trim();
if (!trimmed) return false;
if (/^#{1,6}\s/.test(trimmed)) return true;
if (/^>\s/.test(trimmed)) return true;
if (/^(```|~~~)/.test(trimmed)) return true;
if (/^[-*+]\s/.test(trimmed)) return true;
if (/^\d+[.)]\s/.test(trimmed)) return true;
if (/^\|/.test(trimmed)) return true;
if (/^\s{4,}\S/.test(line)) return true;
return false;
}
function shouldJoinWrappedLine(previous: string, next: string): boolean {
const prev = previous.trimEnd();
const curr = next.trim();
if (!prev || !curr) return false;
if (/[.!?]$/.test(prev)) return false;
if (/[:;]$/.test(prev)) return false;
if (isStandaloneMarkdownLine(curr)) return false;
if (/^[a-z(]/.test(curr)) return true;
if (prev.length >= 72 && /^[A-Za-z0-9"'(]/.test(curr)) return true;
return false;
}
function normalizeWrappedText(lines: string[]): string {
const out: string[] = [];
let paragraph = '';
const flushParagraph = () => {
if (!paragraph) return;
out.push(paragraph);
paragraph = '';
};
for (const rawLine of lines) {
const line = rawLine.trimEnd();
const trimmed = line.trim();
if (!trimmed) {
flushParagraph();
if (out[out.length - 1] !== '') out.push('');
continue;
}
if (isStandaloneMarkdownLine(line)) {
flushParagraph();
out.push(trimmed);
continue;
}
if (!paragraph) {
paragraph = trimmed;
continue;
}
if (shouldJoinWrappedLine(paragraph, trimmed)) {
paragraph += ` ${trimmed}`;
continue;
}
flushParagraph();
paragraph = trimmed;
}
flushParagraph();
return out
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function cleanTerminalTranscript(buffer: string): string {
// Stripping ANSI/OSC/DCS escape sequences and stray C0/C1 control bytes
// legitimately requires control characters in these patterns.
/* eslint-disable no-control-regex */
return String(buffer || '')
.replace(/\x1b\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]/g, '')
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
.replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, '')
.replace(/\x1b[NO()][A-Z0-9]?/g, '')
.replace(/\x1b[>=<78cDEHM]/g, '')
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/[ \t]+$/gm, '')
.trim();
/* eslint-enable no-control-regex */
}
function trimLeadingStartup(lines: string[]): string[] {
let index = 0;
while (index < lines.length) {
const line = lines[index] || '';
const trimmed = line.trim();
if (!trimmed) {
index += 1;
continue;
}
if (/^[╭╰│─].*[╮╯│]?$/.test(trimmed)) {
index += 1;
continue;
}
if (/^>_\s*OpenAI Codex/i.test(trimmed)) {
index += 1;
continue;
}
if (/^(model|directory):\s+/i.test(trimmed)) {
index += 1;
continue;
}
break;
}
return lines.slice(index);
}
function pushBlock(
blocks: ResponseViewerTranscriptBlock[],
kind: ResponseViewerTranscriptKind | null,
lines: string[]
): void {
if (!kind || lines.length === 0) return;
const normalizedLines =
kind === 'status'
? lines.map((line) => normalizeDividerStatusLine(line) || line.trim()).filter((line) => line.length > 0)
: lines;
const text =
kind === 'tool' || kind === 'status'
? normalizedLines
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
: normalizeWrappedText(normalizedLines);
if (!text) return;
const label = (kind.charAt(0).toUpperCase() + kind.slice(1)) as ResponseViewerTranscriptBlock['label'];
// The frontend's loadFullContext() renders via msg.role — a block without it
// lost the "You" badge on prompts and rendered every block as the agent.
blocks.push({ kind, label, role: kind === 'prompt' ? 'user' : 'assistant', text });
}
export function isExternalCliTranscriptMode(mode: string | null | undefined): boolean {
return EXTERNAL_CLI_MODES.has(String(mode || ''));
}
export function parseExternalCliTranscript(
buffer: string,
mode: string | null | undefined
): ResponseViewerTranscriptBlock[] {
const resolvedMode = String(mode || '');
if (!isExternalCliTranscriptMode(resolvedMode)) return [];
const cleaned = cleanTerminalTranscript(buffer);
if (!cleaned) return [];
const lines = trimLeadingStartup(cleaned.split('\n'));
const blocks: ResponseViewerTranscriptBlock[] = [];
let currentKind: ResponseViewerTranscriptKind | null = null;
let currentLines: string[] = [];
const flush = () => {
pushBlock(blocks, currentKind, currentLines);
currentKind = null;
currentLines = [];
};
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] ?? '';
// COD-226: within a prompt, the 2-space Codex gutter is authoritative. Blank
// lines and gutter-indented (2+ leading spaces) continuation lines stay in the
// Prompt block ahead of every structural detector below, so multiline prompts —
// including bullets, indented dividers, and literal › lines — are not
// misclassified as responses. Only a non-blank, non-gutter line ends the prompt
// and falls through (a column-0 › then opens a NEW prompt).
if (currentKind === 'prompt') {
if (!line.trim()) {
currentLines.push('');
continue;
}
if (/^ {2,}\S/.test(line)) {
currentLines.push(line);
continue;
}
}
if (isDividerOnlyLine(line)) {
flush();
continue;
}
if (isPromptLine(line)) {
flush();
currentKind = 'prompt';
currentLines = [line.replace(/^\s*›\s*/, '').trim()];
continue;
}
// COD-227: • Calling / • Called are always tool markers; the other action verbs
// are a tool header only when a box-drawing result tree follows on the next
// non-blank line — otherwise the verb bullet is ordinary assistant prose.
if (isToolActivityMarker(line) || (isToolVerbBullet(line) && nextNonBlankIsBoxDrawing(lines, i))) {
if (currentKind !== 'tool') flush();
currentKind = 'tool';
currentLines.push(line.trimEnd());
continue;
}
if (isToolContinuationLine(line, currentKind)) {
currentLines.push(line.trimEnd());
continue;
}
if (isStatusLine(line, resolvedMode)) {
if (currentKind !== 'status') flush();
currentKind = 'status';
currentLines.push(line.trim());
continue;
}
if (!line.trim()) {
currentLines.push('');
continue;
}
if (currentKind !== 'response') flush();
currentKind = 'response';
currentLines.push(line);
}
flush();
return blocks.filter((block) => block.text.trim().length > 0);
}
export function getLastTranscriptResponse(blocks: ResponseViewerTranscriptBlock[]): string {
for (let i = blocks.length - 1; i >= 0; i -= 1) {
if (blocks[i]?.kind === 'response') return blocks[i].text;
}
return '';
}