Skip to content

feat(dsh): DeepSeek Harness integration (standalone review copy) - #9

Closed
swear01 wants to merge 59 commits into
dsh-review-base-0.28.0from
feat/dsh-integration
Closed

feat(dsh): DeepSeek Harness integration (standalone review copy)#9
swear01 wants to merge 59 commits into
dsh-review-base-0.28.0from
feat/dsh-integration

Conversation

@swear01

@swear01 swear01 commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Standalone review copy of tiann#1574 for Swear Review (app is installed on swear01 only).

  • Full DSH runtime integration: runtime layer, projector, event bridge, RPC bridge, hub orchestration, standard web UI
  • 53 commits, base = upstream/main (merge-base 901f17d)
  • Local suites: cli dsh 51 passed / hub 1106 / shared 269 / web 31
  • P0/P1/P2 test plan implemented (TEST-PLAN.md)

This PR is for independent bot review only — the canonical PR remains tiann#1574.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request integrates DeepSeek Harness (DSH) as a first-class agent runtime in HAPI, spawning a local DSH host process and bridging its native events (such as queue, jobs, goals, subagents, and approvals) into HAPI's agent state and message streams. Feedback on the changes highlights a critical bug in runDsh.ts where a redeclared childCursorByChild map shadows the outer scope and prevents child cursors from being persisted. Additionally, a memory leak was identified in DshEventBridge.ts due to unremoved abort event listeners on a long-lived signal, and an improvement was suggested to use optional chaining when accessing metadata.dshChildCursors to avoid potential runtime crashes.

Comment thread cli/src/dsh/runDsh.ts Outdated
Comment on lines +332 to +334
// Latest child seq per child id, merged into metadata on cursor flush
// so subagent journals survive CLI restarts without replay.
const childCursorByChild = new Map<string, number>()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The block-scoped variable childCursorByChild is redeclared here, shadowing the outer childCursorByChild map declared at line 126. Because of this shadowing, the onChildCursor callback (which is defined in this inner scope) writes to this inner map, while the flushCursor function (defined in the outer scope) reads from the outer map. As a result, the outer map remains empty, and the child cursors are never persisted to metadata, breaking subagent journal durability across restarts.

Suggested change
// Latest child seq per child id, merged into metadata on cursor flush
// so subagent journals survive CLI restarts without replay.
const childCursorByChild = new Map<string, number>()
// Using the outer childCursorByChild map declared at line 126

Comment on lines +681 to +689
function waitForAbortableDelay(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms)
signal.addEventListener('abort', () => {
clearTimeout(timer)
resolve()
}, { once: true })
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This function registers an abort event listener on the long-lived signal passed to start(). If the timeout completes normally, resolve is called, but the event listener is never removed from signal. Since signal lives for the entire duration of the session, every successful delay leaks an event listener, eventually leading to a memory leak and MaxListenersExceededWarning warnings.

Suggested change
function waitForAbortableDelay(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms)
signal.addEventListener('abort', () => {
clearTimeout(timer)
resolve()
}, { once: true })
})
}
function waitForAbortableDelay(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
let timer: any
const onAbort = () => {
clearTimeout(timer)
resolve()
}
timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, ms)
signal.addEventListener('abort', onAbort, { once: true })
})
}

Comment thread cli/src/dsh/runDsh.ts
Comment on lines +139 to +144
...(childCursors ? {
dshChildCursors: {
...((metadata as { dshChildCursors?: Record<string, number> }).dshChildCursors ?? {}),
...childCursors
}
} : {})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If metadata is null or undefined, accessing .dshChildCursors will throw a TypeError. Using optional chaining (metadata?.dshChildCursors) prevents potential runtime crashes. Additionally, since metadata is typed as Metadata (where dshChildCursors is already optional), the type cast is redundant and can be safely removed.

Suggested change
...(childCursors ? {
dshChildCursors: {
...((metadata as { dshChildCursors?: Record<string, number> }).dshChildCursors ?? {}),
...childCursors
}
} : {})
...(childCursors ? {
dshChildCursors: {
...(metadata?.dshChildCursors ?? {}),
...childCursors
}
} : {})

@swear-review swear-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swear Review — Full PR review · batch 1/2 · OCR v1.9.0 · deepseek-v4-flash

Comment thread cli/src/agent/types.ts Outdated
Comment on lines +80 to +82
export function isDshMessage(message: AgentMessage): boolean {
return message.type === 'dsh_native' || message.type === 'dsh_state'
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing semicolon after a function declaration is unnecessary and is flagged by the no-extra-semi rule. Remove the ; after the closing brace.

Suggested change
export function isDshMessage(message: AgentMessage): boolean {
return message.type === 'dsh_native' || message.type === 'dsh_state'
};
export function isDshMessage(message: AgentMessage): boolean {
return message.type === 'dsh_native' || message.type === 'dsh_state'
}

low · style

Swear Review

Comment thread cli/src/agent/types.ts Outdated
Comment on lines +80 to +82
export function isDshMessage(message: AgentMessage): boolean {
return message.type === 'dsh_native' || message.type === 'dsh_state'
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isDshMessage is exported but never referenced anywhere in the codebase (no call sites in cli, hub, or web). If it isn't part of a planned public API yet, consider removing it to avoid dead code; otherwise wire up its usage.

low · maintainability

Swear Review

Comment thread cli/src/dsh/DshClient.ts Outdated
}
const value = response.result.value as { accepted?: boolean; command?: { kind: 'success'; text?: string } }
return {
accepted: value.accepted === true ? (true as const) : (true as const),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ternary value.accepted === true ? (true as const) : (true as const) always evaluates to true regardless of the host response. The condition is dead code: when the host returns accepted: false, the client still reports the prompt as accepted, so callers will treat a rejected/steered prompt as successfully queued. This silently loses the rejection signal and produces inconsistent session state. Fix by propagating the actual value and widening the DshPromptResult.accepted type from the literal true to boolean (the wire value is typed accepted?: boolean).

Suggested change
accepted: value.accepted === true ? (true as const) : (true as const),
accepted: value.accepted === true,

high · bug

Swear Review

Comment thread cli/src/dsh/DshClient.ts
Comment on lines +67 to +68
private readonly api: IApiClient,
private readonly baseUrl: string
) {
this.transport = api as unknown as DshNodeTransport

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constructor blindly casts any IApiClient to DshNodeTransport, yet promptDirect exists only on the Node transport. Today every construction site goes through connect(), so it works, but the public constructor accepts any IApiClient — passing a non-Node implementation would throw TypeError: this.transport.promptDirect is not a function at runtime. The subsequent as never casts on updateQueueAction/goalCall/subagentCall/respond payloads also bypass compile-time verification of the wire shapes, hiding contract mismatches. Consider typing the constructor parameter as DshNodeTransport (since only connect() is used) and replacing the as never payload casts with properly typed request types from the official API package.

medium · maintainability

Swear Review

Comment thread cli/src/dsh/DshClient.ts Outdated
* registered ahead of the HTTP round-trip (the host may emit the
* user/message event before the prompt response returns). */
reservePromptRpcId(): string {
return crypto.randomUUID()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

crypto.randomUUID() is used (here, in prompt() and gatewayCall()) without importing it, relying on the global Web Crypto API which is only available in Node >= 19. Every other CLI file imports randomUUID from node:crypto (e.g. cli/src/dsh/runDsh.ts, cli/src/agent/messageConverter.ts). Import from node:crypto for consistency and to keep the DSH feature working on older Node runtimes that lack the global.

Suggested change
return crypto.randomUUID()
return randomUUID()

low · maintainability

Swear Review

Comment thread web/src/components/DshSessionPanels.tsx Outdated
if (queued > 0) {
parts.push(`queue: ${queued}`)
}
const runningJobs = snapshot.jobs?.jobs.filter((job) => job.status === 'running' || job.status === 'stopping').length ?? 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same incomplete optional chaining as above: snapshot.jobs?.jobs guards only the outer jobs field, not the nested jobs array. If a partial/older snapshot omits the inner array, .filter() throws and crashes the component during render.

Suggested change
const runningJobs = snapshot.jobs?.jobs.filter((job) => job.status === 'running' || job.status === 'stopping').length ?? 0
const runningJobs = (snapshot.jobs?.jobs ?? []).filter((job) => job.status === 'running' || job.status === 'stopping').length

medium · bug

Swear Review

Comment on lines +77 to +82
</div>
) : null}
{questions ? (
<DshQuestionsDialog questions={questions} dispatch={dispatch} onClose={() => {}} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onClose={() => {}} is a no-op stub. DshQuestionsDialog renders as a fixed full-screen modal and provides no dismiss affordance (no close button/backdrop/ESC), so passing a no-op onClose means users with pending questions are trapped until they answer all of them. Either implement a real dismiss path (backdrop click / X / ESC) wired through this callback, or remove the prop so the blocking behavior is intentional and documented.

medium · bug

Swear Review

Comment thread web/src/components/DshSessionPanels.tsx Outdated
Comment on lines +37 to +38
console.error('[dsh] action failed', action.type, error)
throw error

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dispatch wrapper logs the error and rethrows, but its callers in DshGoalBar and DshQueueDock fire void dispatch(...) without attaching a .catch. Every failed action therefore becomes an unhandled promise rejection, and no user-facing error is ever surfaced (only the questions dialog handles its own rejection). Consider not rethrowing here and letting the mutation's onError handle feedback, or returning a structured result that children can surface.

medium · bug

Swear Review

Comment thread web/src/components/DshSessionPanels.tsx Outdated
function statusSummary(snapshot: DshStateSnapshot): string[] | null {
const parts: string[] = []
if (snapshot.goal?.objective) {
parts.push(`goal: ${snapshot.goal.status ?? 'active'}`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These summary pills are hardcoded English labels (goal:, queue:, jobs:) while the rest of the component uses t() keys (e.g. dsh.sessionLabel), and the locale files already define dsh.queue, dsh.jobs, and dsh.goalStatus.*. For consistency with the en/zh-CN i18n setup, these user-facing labels should be localized rather than hardcoded.

low · maintainability

Swear Review

Comment on lines +41 to +42
return run
}, [dshAction])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useMemo is used to memoize a plain function; useCallback is the idiomatic hook for this scenario and makes the intent (stable callback identity) clearer. Minor readability improvement.

Suggested change
return run
}, [dshAction])
}, [dshAction])

low · style

Swear Review

swear01 added a commit that referenced this pull request Aug 16, 2026
Critical: childCursorByChild was shadowed by a nested redeclaration —
onChildCursor wrote the inner map while flushCursor read the outer one,
so subagent journal cursors were never persisted. Removed the shadow.

High/medium fixes:
- Bridge: abort listener leak in waitForAbortableDelay; child-journal
  seal starts before pumps dispatch (queued frames before subscription);
  retryMs resets after a healthy generation; session-removed cleans
  childLastSeq/childBuffers; CURSOR_FLUSH_MS/lastCursorFlush dead code
  removed; asString evaluated once
- Transport: promptDirect checks HTTP status; readWebSocket handles
  socket 'error' (enqueues stream end); SESSION_PROMPT_PATH constant
- Runtime: install child stdout ignored (pipe-block risk); overlayDir
  cleaned on every failure path; readiness race timer cleared; stop()
  clears SIGKILL timer + exit listener; isDshRuntimeInstalled removed
- Projector: tool-result content optional-chained; end-seed persisted
  as dsh_native; stateSnapshot seq no longer overridable by folded seq;
  dead step state (currentTurn/streamed/finished/finalized*) removed
- Hub: rewind finalize moved outside the archive CAS try/catch
- Web: queue/jobs arrays optional-chained (unvalidated payloads);
  dispatch swallows failures (callers are fire-and-forget); status
  summary labels localized; dsh.goal i18n keys added
- Shared: dshChildCursors validated int().nonnegative()
- Doctor: imports cleaned, unreachable catch removed, ternary flattened

@swear-review swear-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swear Review — Full PR review · batch 1/2 · OCR v1.9.0 · deepseek-v4-flash

Comment thread cli/src/agent/types.ts Outdated

export function isDshMessage(message: AgentMessage): boolean {
return message.type === 'dsh_native' || message.type === 'dsh_state'
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stray extra semicolon after the function declaration (an empty statement). Remove it to avoid lint warnings (e.g. no-extra-semi) and to match the surrounding code style.

Suggested change
};
}

low · style

Swear Review

Comment thread cli/src/agent/types.ts Outdated
dshMessageId?: string
}

export function isDshMessage(message: AgentMessage): boolean {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isDshMessage is newly exported but has no callers anywhere in the codebase (all other DSH modules import DshProjectedMessage instead). If it isn't consumed yet, consider removing it or adding the intended usage to avoid dead code.

low · maintainability

Swear Review

Comment on lines +187 to +193
await runDsh({
existingSessionId: base.existingSessionId,
workingDirectory: base.workingDirectory,
resumeSessionId: base.resumeSessionId,
startedBy: base.startedBy,
startingMode: 'remote',
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

runDsh declares resumeSessionId in its options type, but the implementation never reads it (verified in cli/src/dsh/runDsh.ts — the only occurrence is the type declaration). DSH resume actually restores the native session via existingSessionId plus the persisted dshSessionId metadata. Passing resumeSessionId here is dead and misleads maintainers into thinking the native DSH session id is consumed. Consider dropping it from the call (and from runDsh's options) or wiring it into the resume path.

low · maintainability

Swear Review

Comment on lines +185 to +186
if (target.flavor === 'dsh') {
const { runDsh } = await import('@/dsh/runDsh')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new dsh resume branch has no corresponding test in cli/src/commands/resume.test.ts, while every other flavor (codex, agy, claude, grok, pi, gemini) has a dedicated case. Add a test asserting runDsh is invoked with the expected options (e.g., existingSessionId, workingDirectory, startingMode: 'remote') to guard this branch against regressions.

low · test

Swear Review

Comment thread cli/src/dsh/DshClient.ts Outdated
}
const value = response.result.value as { accepted?: boolean; command?: { kind: 'success'; text?: string } }
return {
accepted: value.accepted === true ? (true as const) : (true as const),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both branches of this ternary are identical, so accepted is always coerced to true even when the server returns accepted: false (e.g., a steer prompt rejected while a generation is already running). This is dead code and a latent correctness bug: runDsh.ts marks the HAPI message as consumed as soon as prompt() resolves, so a rejected prompt would be silently treated as accepted with no way for the caller to distinguish. Fix by reflecting the server value — accepted: value.accepted ?? true — and change DshPromptResult.accepted from the literal true type to boolean.

Suggested change
accepted: value.accepted === true ? (true as const) : (true as const),
accepted: value.accepted ?? true,

high · bug

Swear Review

Comment thread cli/src/dsh/runDsh.ts
Comment on lines +644 to +646
await bridgeRun;

lifecycle.setSessionEndReason('completed');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unexpected host exit is handled outside this try/catch: the host-exit listener calls lifecycle.markCrash(...) (which sets sessionEndReason='error' and exitCode) then cleanupAndExit(1). When that listener aborts bridgeAbort, await bridgeRun resolves normally and crashed is still false, so lifecycle.setSessionEndReason('completed') overwrites the crash state and the session is recorded as a normal completion in the death/audit record. Gate the 'completed' end reason on the host having shut down gracefully (e.g. check stoppingHost or a dedicated crash flag) rather than only on !crashed.

medium · bug

Swear Review

Comment thread cli/src/dsh/runDsh.ts Outdated
logger.debug(`[dsh] image attachment read failed: ${error instanceof Error ? error.message : String(error)}`)
}
} else {
body += `\n[Attached file: ${attachment.path}]`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-image attachments interpolate attachment.path verbatim into the prompt body without sanitization and, unlike image attachments, never get an isPathWithinUploadDir check. A crafted filename/path (newlines or instruction-like text) is fed directly to the model, and raw local filesystem paths leak into the conversation. At minimum sanitize/truncate the interpolation, and validate non-image paths against the upload dir for consistency with the image path.

medium · security

Swear Review

Comment thread cli/src/dsh/runDsh.ts
Comment on lines +630 to +631
}).catch((error) => {
logger.debug(`[dsh] prompt failed: ${error instanceof Error ? error.message : String(error)}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the prompt-failure path the localId→rpcId binding is deleted and an error message is sent, but session.emitMessagesConsumed is never called for the HAPI user message (runPi.ts consumes the row on preparation/prompt failure). The HAPI message therefore stays visually pending/queued in the web UI forever alongside the error. Consume the message on failure too, so the transcript doesn't leave a stuck pending row.

medium · bug

Swear Review

Comment thread cli/src/dsh/runDsh.ts Outdated
Comment on lines +504 to +507
const match = catalog.groups
.flatMap((group) => group.models.map((m) => ({ ...m, provider: group.id })))
.find((m) => m.id === modelId);
return match?.provider ?? null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Model resolution is inconsistent between the two paths: launch-time opts.model throws on ambiguity (matches.length !== 1), but resolveModelProvider used by SetSessionConfig for bare model ids silently picks the first provider with .find(). A model id that exists under multiple providers is therefore rejected at launch yet silently (and possibly wrongly) resolved in-session. Align both paths on the same ambiguity policy.

medium · bug

Swear Review

Comment thread cli/src/dsh/runDsh.ts
Comment on lines +81 to +82
const startingMode: 'local' | 'remote' | 'pty' = opts.startingMode
?? (startedBy === 'runner' ? 'remote' : 'remote');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(startedBy === 'runner' ? 'remote' : 'remote') has identical branches, so the ternary is dead logic and startedBy has no effect on the derived starting mode. Sibling launchers differentiate terminal vs runner launches; if DSH is intentionally always 'remote', simplify to avoid misleading maintainers.

Suggested change
const startingMode: 'local' | 'remote' | 'pty' = opts.startingMode
?? (startedBy === 'runner' ? 'remote' : 'remote');
const startingMode: 'local' | 'remote' | 'pty' = opts.startingMode
?? 'remote';

low · maintainability

Swear Review

@swear-review swear-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swear Review — Full PR review · batch 1/2 · OCR v1.9.0 · deepseek-v4-flash

Comment thread cli/src/agent/types.ts
Comment on lines +74 to +78
export type DshProjectedMessage = AgentMessage & {
dshSeq?: number
/** DSH native assistant-message id (message feedback addressing). */
dshMessageId?: string
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor style: the rest of this file terminates every statement with a semicolon (e.g. stopReason?: string;), but the new DshProjectedMessage block omits them, and there is a double blank line after the closing brace. For consistency and maintainability, add semicolons after dshSeq?: number and dshMessageId?: string and collapse the extra blank line.

low · style

Swear Review

Comment thread cli/src/dsh/DshClient.ts
Comment on lines +199 to +201
reservePromptRpcId(): string {
return crypto.randomUUID()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This uses the bare global crypto.randomUUID() without importing it, while the rest of the CLI consistently does import { randomUUID } from 'node:crypto' (see acpSessionTitle, sessionFactory, messageConverter, runDsh, etc.). The global Web Crypto object is only enabled by default on Node 19+ (on Node 18 it requires --experimental-global-webcrypto and in some sandboxed runtimes it's undefined), so on supported-but-older runtimes this throws on the prompt hot path. Import randomUUID from node:crypto and use it here and in prompt()/gatewayCall() for consistency and portability.

Suggested change
reservePromptRpcId(): string {
return crypto.randomUUID()
}
import { randomUUID } from 'node:crypto'
reservePromptRpcId(): string {
return randomUUID()
}

medium · bug

Swear Review

Comment thread cli/src/dsh/DshClient.ts
Comment on lines +369 to +373
await this.api.respond({
type: 'client-response',
rpcId: rpcId as never,
result: { ok: true, value }
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike every other unary method in this class, respond() never inspects response.result.ok before returning. If the host rejects the client-response (e.g. stale/unknown rpcId, session already closed, invalid approval/question payload), the failure is silently swallowed and the pending approval/question stays unresolved — the caller (DshRpcBridge / Permission handler in runDsh) then reports { accepted: true } back to the user as if it succeeded. Match the other methods and throw a DshRpcError when result.ok is false.

Suggested change
await this.api.respond({
type: 'client-response',
rpcId: rpcId as never,
result: { ok: true, value }
})
const response = await this.api.respond({
type: 'client-response',
rpcId: rpcId as never,
result: { ok: true, value }
})
if (!response.result.ok) {
throw new DshRpcError(response.result.error.code, response.result.error.message, response.result.error.details)
}

medium · bug

Swear Review

Comment thread cli/src/dsh/DshClient.ts Outdated
if (!response.ok) {
throw new DshRpcError('transport', `gateway ${endpoint}: HTTP ${response.status}`)
}
const parsed = await response.json() as { type: string; result?: { ok: boolean; value?: unknown; error?: { code: string; message: string; details?: unknown } } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

response.json() is awaited unconditionally. If the gateway returns a non-JSON error body (e.g. a 404/500 HTML or plain-text page), response.json() throws a raw SyntaxError that bypasses the class's uniform DshRpcError contract — the same gap DshNodeTransport.promptDirect already defends against with a try/catch. Wrap the parse so a non-JSON failure is normalized into a DshRpcError with the HTTP status instead.

Suggested change
const parsed = await response.json() as { type: string; result?: { ok: boolean; value?: unknown; error?: { code: string; message: string; details?: unknown } } }
let parsed: { type: string; result?: { ok: boolean; value?: unknown; error?: { code: string; message: string; details?: unknown } } }
try {
parsed = await response.json() as typeof parsed
} catch {
throw new DshRpcError('transport', `gateway ${endpoint}: non-JSON body (HTTP ${response.status})`)
}

medium · bug

Swear Review

Comment thread cli/src/dsh/DshClient.ts
Comment on lines +66 to +68
) {
this.transport = api as unknown as DshNodeTransport
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prompt() calls this.transport.promptDirect(...), but promptDirect only exists on DshNodeTransport — the public constructor accepts any IApiClient and hides the mismatch behind as unknown as DshNodeTransport. Any caller passing a plain IApiClient (tests, future reuse, or a transport that doesn't extend AbstractApiClient) would hit a silent TypeError: this.transport.promptDirect is not a function at runtime instead of a compile-time error. Make the constructor require DshNodeTransport (or a narrow interface declaring promptDirect/doFetch) so the dependency is explicit and type-checked.

Suggested change
) {
this.transport = api as unknown as DshNodeTransport
}
) {
this.transport = api
}

medium · maintainability

Swear Review

const running = entries?.filter((entry) => entry.activity === 'running').length ?? 0

return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm" onClick={onClose}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The modal is rendered as plain divs without role="dialog", aria-modal, aria-label, focus trapping, or Escape-key handling, and the close button uses a bare '✕' without an aria-label. This is both an accessibility regression and a functional gap: SessionChat's global hotkey blocker (isScratchlistHotkeyBlockedTarget) only suppresses shortcuts for targets inside [role="dialog"], so scratchlist/mode-toggle hotkeys can fire behind this open modal. Other dialogs in this codebase (ScheduleTimePicker, Fue, ImagePreview) all set role="dialog" + aria-label; align this one with them.

Suggested change
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm" onClick={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label={t('dsh.subagents')}
className="fixed inset-0 z-40 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
onClick={onClose}
>

medium · bug

Swear Review

Comment on lines +60 to +66
{entries ? (
<span className="text-xs text-[var(--app-hint)]">
{running > 0
? t('dsh.subagentRunningCount', { count: running })
: t('dsh.noSubagents')}
</span>
) : null}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When entries is an empty array, running is 0 so the header renders t('dsh.noSubagents') at the same time the body's empty-state branch renders t('dsh.noSubagents') again — the same 'no subagents' message appears twice in the modal. Consider showing a count (e.g. '{0} running') or suppressing the header text when the list is empty.

Suggested change
{entries ? (
<span className="text-xs text-[var(--app-hint)]">
{running > 0
? t('dsh.subagentRunningCount', { count: running })
: t('dsh.noSubagents')}
</span>
) : null}
{entries && entries.length > 0 ? (
<span className="text-xs text-[var(--app-hint)]">
{running > 0
? t('dsh.subagentRunningCount', { count: running })
: t('dsh.subagentIdle')}
</span>
) : null}

low · style

Swear Review

Comment on lines +35 to +37
).then((response) => {
setEntries(response.result.entries.filter((entry) => entry.kind === 'child'))
}).catch((e: unknown) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

response.result.entries is dereferenced without a runtime shape check. Although the API type contract and the server-side zod validation (DshSubagentCatalogSchema) guarantee entries is present, a network/middleware error that still resolves would throw a TypeError here; the adjacent .catch() would then surface a confusing generic message instead of falling back to an empty list. Optional chaining with a default is a cheap defensive guard.

Suggested change
).then((response) => {
setEntries(response.result.entries.filter((entry) => entry.kind === 'child'))
}).catch((e: unknown) => {
).then((response) => {
setEntries((response.result.entries ?? []).filter((entry) => entry.kind === 'child'))
}).catch((e: unknown) => {

low · maintainability

Swear Review

Comment on lines +529 to +530
title={props.dshSubagentsActive ? 'DeepSeek Harness subagents: close' : 'DeepSeek Harness subagents'}
aria-label={props.dshSubagentsActive ? 'DeepSeek Harness subagents: close' : 'DeepSeek Harness subagents'}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These title/aria-label strings are hardcoded in English, unlike every other header toggle button in this file (files/outline/terminal) which uses t(...) via useTranslation. This leaves the header button unlocalized for non-English users. Please use t('dsh.subagents') for the label and add a corresponding close-state key (e.g. dsh.subagentsClose) to both en.ts and zh-CN.ts locale files.

Suggested change
title={props.dshSubagentsActive ? 'DeepSeek Harness subagents: close' : 'DeepSeek Harness subagents'}
aria-label={props.dshSubagentsActive ? 'DeepSeek Harness subagents: close' : 'DeepSeek Harness subagents'}
title={props.dshSubagentsActive ? t('dsh.subagentsClose') : t('dsh.subagents')}
aria-label={props.dshSubagentsActive ? t('dsh.subagentsClose') : t('dsh.subagents')}

medium · maintainability

Swear Review

Comment thread web/src/lib/locales/en.ts
Comment on lines +1159 to +1166
'dsh.sessionLabel': 'DeepSeek Harness',
'dsh.running': 'running',
'dsh.idle': 'idle',
'dsh.pendingApprovals': '{count} pending approval{s}',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This string relies on a hand-rolled {s} pluralization placeholder, but the project's i18n interpolate only substitutes provided params and returns the literal {key} when a param is missing. There are currently no call sites for this key, yet the established precedent (misc.newMessage) is called as t('misc.newMessage', { n: count }) without passing s, which would render a literal {s} in English. To avoid this footgun, prefer a placeholder-free phrasing such as '{count} pending approval(s)', or ensure callers always pass s: count === 1 ? '' : 's'. Note the zh-CN value '{count} 个待批准请求' does not use {s} at all, so the placeholder is English-specific and would display literally in English if omitted.

Suggested change
'dsh.pendingApprovals': '{count} pending approval{s}',
'dsh.pendingApprovals': '{count} pending approval(s)',

low · maintainability

Swear Review

@swear-review swear-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swear Review — Full PR review · batch 1/2 · OCR v1.9.0 · deepseek-v4-flash

Comment thread cli/src/commands/dsh.ts
try {
// DSH permission presets are runtime-discovered from the host; pass
// an empty allow-list so --permission-mode is rejected.
const options = parseRemoteAgentCommandOptions(commandArgs, [])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing an empty allow-list rejects --permission-mode (the parser throws), but --yolo is only silently swallowed: since the allow-list is empty, yoloEquivalent resolves to undefined and the flag is dropped with no error. A user running hapi dsh --yolo would believe auto-approval is enabled when nothing happens. To match the stated intent of rejecting permission-related flags, explicitly reject --yolo here.

Suggested change
const options = parseRemoteAgentCommandOptions(commandArgs, [])
const options = parseRemoteAgentCommandOptions(commandArgs, [])
if (commandArgs.includes('--yolo')) {
throw new Error('--yolo is not supported for dsh; permission presets are runtime-discovered from the host')
}

low · other

Swear Review

Comment thread cli/src/dsh/DshClient.ts
private readonly api: IApiClient,
private readonly baseUrl: string
) {
this.transport = api as unknown as DshNodeTransport

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DshClient is typed to accept any IApiClient, but prompt() calls this.transport.promptDirect(...) and gatewayCall() calls (this.api as unknown as { doFetch }).doFetch(...) — both are DshNodeTransport-specific members that do not exist on the IApiClient interface. The api as unknown as DshNodeTransport cast hides this mismatch, so any caller passing a different IApiClient (a mock, the official browser client, etc.) will get a hard TypeError at runtime in these methods, which is hard to diagnose. All current call sites use connect() and are safe, but the public constructor makes this a latent hazard. Consider typing the constructor parameter as DshNodeTransport directly, or add a runtime capability check so the concrete dependency is explicit and misuse fails at compile time.

medium · bug

Swear Review

Comment thread cli/src/dsh/DshClient.ts
Comment on lines +250 to +251
itemId: options.itemId as never,
action: options.action as never

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These as never casts (here on itemId/action, and likewise on rpcId in respond() and payload in goalCall()/subagentCall()) fully suppress the compiler's contract checking against the official apiproxy types. Any drift between the locally defined payload shapes and the actual wire contract (field renames, enum changes, new required fields) will compile cleanly and only surface later as a confusing runtime failure. Prefer a small typed mapping layer or a targeted as unknown as <official type> on just the mismatching field instead of a blanket as never.

low · maintainability

Swear Review

Comment thread cli/src/dsh/DshClient.ts
Comment on lines +389 to +390
*/
async gatewayCall<T = unknown>(endpoint: string, payload: unknown): Promise<T> {
const transport = this.api as unknown as { doFetch(input: URL, init?: RequestInit): Promise<Response> }
const response = await transport.doFetch(new URL(`/api/${endpoint}`, this.baseUrl), {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gatewayCall issues a raw doFetch without any timeout or abort signal, so a hung/missing host leaves this promise pending forever. This is inconsistent with promptDirect, which composes a 30s AbortSignal.timeout (plus the caller's signal) on the same fetch leg. Add a timeout here (and optionally accept a caller AbortSignal) so gateway calls can't block teardown/cancellation indefinitely.

low · bug

Swear Review

Comment thread cli/src/dsh/DshClient.ts
Comment on lines +269 to +270
return response.result.value
}

async listSkills(sessionId: ReturnType<typeof SessionId>): Promise<ResponseValue<'skill.list'>> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Session-id typing is inconsistent across the client: most methods take a plain string and validate via SessionId() internally, but listSkills, selectAgentPreset, goals.* and subagents.* require the branded ReturnType<typeof SessionId> directly. This forces callers to track which form each method expects and increases the chance of type friction at call sites. Consider standardizing on plain string + internal SessionId() conversion for all public methods for a uniform API.

low · maintainability

Swear Review

{/* DSH questions are blocking by design (official semantics): no
dismiss affordance until the agent's question is answered. */}
{questions ? (
<DshQuestionsDialog questions={questions} dispatch={dispatch} onClose={() => {}} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DshQuestionsDialog accepts onClose?: () => void but never invokes it internally (verified in DshPanels.tsx). Passing a no-op here is inert and misleading — it suggests a close affordance exists when the dialog is intentionally blocking by design. Either drop the prop (it's optional) or remove it from the component's interface to avoid a dead contract.

low · maintainability

Swear Review

Comment on lines +57 to +59
<button
type="button"
onClick={() => setOpen((v) => !v)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This toggle button controls the collapse/expand of the DSH panel but lacks aria-expanded and aria-controls, so assistive technology cannot announce the panel's state. Add aria-expanded={open} and reference the collapsible region with aria-controls/id for an accessible disclosure pattern.

low · other

Swear Review

Comment on lines +207 to +212
}).then(() => {
setSubmitting(false)
}).catch((e: unknown) => {
setSubmitting(false)
setError(e instanceof Error ? e.message : String(e))
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .catch/setError branch here is effectively unreachable: the Dispatch wired by the caller (DshSessionPanels) swallows rejections — dshAction.mutateAsync(action).catch(... return undefined) — so this promise always resolves. A failed question submission therefore gives the user no error feedback and the dialog silently stays open (its own comment even says the dialog 'observes errors through its own submit catch', which contradicts the actual wiring). Prefer async/await + try/catch, and ensure failures are propagated to the dialog (let dispatch reject, or pass an onError callback) instead of relying on this unreachable catch.

medium · bug

Swear Review

Comment on lines +31 to +34
const action = (kind: 'pause' | 'resume' | 'complete' | 'clear') => {
if (!ref) return
void dispatch({ type: 'goal', action: kind, refId: ref.refId, revision: ref.revision })
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

id/revision are optional in DshGoalState, so ref can be null even when goal.objective exists. In that case the Pause/Resume/Complete/Clear buttons still render as enabled but every click silently no-ops because of the if (!ref) return guard. Disable (or hide) the action buttons when ref is null so the UI reflects the actual capability.

low · bug

Swear Review

export function DshQuestionsDialog({ questions, dispatch }: {
questions: DshPendingQuestions
dispatch: Dispatch
onClose?: () => void

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onClose is declared in the props type but never used inside the component, and the caller passes a no-op (onClose={() => {}}). It's a misleading dead prop — either wire it up (e.g., invoke it after a successful submit, or add a close affordance) or remove it from both the props type and the caller.

low · maintainability

Swear Review

@swear-review swear-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swear Review — Full PR review · batch 1/2 · OCR v1.9.0 · deepseek-v4-flash

Comment thread cli/src/commands/dsh.ts
try {
// DSH permission presets are runtime-discovered from the host; pass
// an empty allow-list so --permission-mode is rejected.
const options = parseRemoteAgentCommandOptions(commandArgs, [])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseRemoteAgentCommandOptions will accept --effort and --model-reasoning-effort, but runDsh does not consume them (its opts type only covers startedBy/startingMode/model/resumeSessionId/existingSessionId/workingDirectory/agentPreset). Unlike --permission-mode (rejected via the empty allow-list), these flags would be silently parsed and ignored, so hapi dsh --effort high produces a confusing no-op. Consider stripping the unused fields before passing to runDsh, or explicitly rejecting these flags so the mismatch is surfaced to the user.

low · bug

Swear Review

Comment thread cli/src/dsh/DshClient.ts
Comment on lines +370 to +379
async respond(rpcId: string, value: unknown): Promise<void> {
// client-response receipts carry no business result on the success
// path; transport errors (non-2xx, malformed envelope) reject via the
// official client's fetch leg.
await this.api.respond({
type: 'client-response',
rpcId: rpcId as never,
result: { ok: true, value }
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The response from api.respond() is discarded entirely. DshRpcBridge answers { accepted: true } to the web right after this call (approval.respond / question.respond), so if the host returns a server-response envelope with result.ok === false (e.g. stale/unknown rpcId because the approval already timed out), that business failure is silently swallowed and the client wrongly reports the response as accepted — a state divergence where the web believes the approval/question was answered while the host never processed it. The comment only guarantees transport-level errors surface; a 2xx envelope with a non-ok result will not. Please capture the returned response and at least validate result.ok, throwing DshRpcError (or logging) on business failure.

medium · bug

Swear Review

Comment thread cli/src/dsh/DshClient.ts
private readonly api: IApiClient,
private readonly baseUrl: string
) {
this.transport = api as unknown as DshNodeTransport

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The public constructor accepts api: IApiClient but immediately casts it to DshNodeTransport, and prompt() later calls this.transport.promptDirect(...) which only exists on the concrete transport. Today every call site goes through the static connect(), so it happens to work — but the constructor's declared type lies: any caller passing a plain IApiClient will hit a TypeError at runtime on the prompt path. Type the constructor parameter as DshNodeTransport (the real requirement) so the misuse is impossible at compile time instead of being hidden behind the double cast.

low · maintainability

Swear Review

Comment thread cli/src/dsh/DshClient.ts
Comment on lines +251 to +252
itemId: options.itemId as never,
action: options.action as never

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as never is used to bypass the type checker at several RPC boundaries (itemId/action here, plus payload as never in goalCall/subagentCall and rpcId as never in respond). This hides mismatches between the local payload types and the official HAPI signatures: if the wire contract changes (queue item id format, goal payload shape, …) it won't be caught at compile time and will only surface as an obscure runtime failure. Prefer aligning the local parameter types with the official API types (or defining explicit mapped types) instead of casting through never.

low · maintainability

Swear Review

Comment on lines +80 to +82
case 'step/start':
case 'step/end': {
this.stepState(event.data.turn, event.data.step)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

event.data is dereferenced without any null/shape guard in step/start, step/end, assistant/chunk, assistant/message, tool/call, tool/result and request/context. These events are persisted as dsh_native and re-fed to this projector during hub replay/backfill — if a session is resumed with a different DSH runtime version or a legacy event whose payload shape differs (note the turn/end branch already defensively guards event.data), a single event.data.turn access throws, the exception propagates out of onEvent, and the whole event's projections (including its dsh_native record) are dropped. Guard event.data consistently across all branches, as turn/end does.

medium · bug

Swear Review

{/* DSH questions are blocking by design (official semantics): no
dismiss affordance until the agent's question is answered. */}
{questions ? (
<DshQuestionsDialog questions={questions} dispatch={dispatch} onClose={() => {}} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DshQuestionsDialog never invokes onClose (no close button or backdrop dismissal in its implementation), and the design comment states questions are blocking with no dismiss affordance. Passing a no-op onClose={() => {}} is therefore misleading dead prop plumbing; either remove the onClose prop from the dialog contract or drop the no-op here.

low · maintainability

Swear Review

Comment on lines +57 to +59
<button
type="button"
onClick={() => setOpen((v) => !v)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The collapse toggle button exposes no expanded state to assistive technology. Add aria-expanded={open} and aria-controls pointing at the collapsible panel's id so screen readers can announce the state and associate the button with the controlled content.

low · other

Swear Review

Comment on lines +29 to +35
const load = () => {
setLoading(true)
setError(null)
void api.dshAction<{ entries: SubagentEntry[]; parentAvailable: boolean }>(
sessionId,
{ type: 'subagent', action: 'list' }
).then((response) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

load() has no guard against out-of-order resolution or component unmount. If the user clicks Refresh multiple times (or sessionId changes while a request is pending), an earlier response can resolve last and overwrite newer entries; if the modal closes while a request is in flight, setEntries/setError/setLoading run on an unmounted component. Since api.dshAction offers no cancellation, guard with a request sequence counter (only apply the result if it matches the latest request) or a mounted flag reset in the useEffect cleanup. Prefer async/await here for clearer control flow.

medium · bug

Swear Review

Comment on lines +44 to +47
useEffect(() => {
load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The effect depends only on [sessionId] but invokes load, which closes over api and sessionId; the eslint-disable hides the missing-dependency warning. If the api prop identity changes while the modal stays open, the subagent list won't refresh. Define load with useCallback (with explicit deps) or inline the fetch into the effect so dependencies are accurate.

low · maintainability

Swear Review

const running = entries?.filter((entry) => entry.activity === 'running').length ?? 0

return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm" onClick={onClose}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This modal overlay lacks role="dialog"/aria-modal="true"/aria-labelledby, an Escape-to-close handler, and focus management. role="dialog" is load-bearing here: isScratchlistHotkeyBlockedTarget in SessionChat uses target.closest('[role="dialog"]') to stop the global scratchlist hotkey from firing behind open dialogs (PR tiann#798). Without it, pressing that hotkey while this modal is open toggles scratchlist mode behind the overlay. Add the dialog ARIA attributes, a useEffect keydown listener for Escape, and focus the panel on open.

medium · bug

Swear Review

@swear-review swear-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swear Review — Full PR review · batch 2/2 · OCR v1.9.0 · deepseek-v4-flash

Comment on lines +83 to +85
{entries === null && !error ? (
<div className="py-6 text-center text-xs text-[var(--app-hint)]">{t('dsh.loading')}</div>
) : entries && entries.length === 0 ? (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The body uses a nested ternary chain (entries === null && !error ? … : entries && entries.length === 0 ? … : …), which the project guidelines forbid and which hurts readability. Extract the loading / empty / list states into variables or early returns.

low · style

Swear Review

Comment on lines +174 to +175
onClose?: () => void
}) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The onClose prop is declared in the props type but never destructured or invoked anywhere in the component, and the parent passes a no-op (onClose={() => {}}). It is dead API surface that misleads callers into thinking the dialog supports a close/dismiss path. Either implement it (e.g., call after successful submission or add a dismiss affordance) or remove it from the props type.

low · maintainability

Swear Review

Comment on lines +31 to +34
const action = (kind: 'pause' | 'resume' | 'complete' | 'clear') => {
if (!ref) return
void dispatch({ type: 'goal', action: kind, refId: ref.refId, revision: ref.revision })
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When goal.id or goal.revision is missing, ref becomes null and action() returns early, but all four buttons (Pause/Resume/Complete/Clear) are still rendered and look interactive while doing nothing on click. Hide or disable the action buttons when ref is null so users aren't presented with inert controls.

low · bug

Swear Review

Comment on lines +529 to +530
title={props.dshSubagentsActive ? 'DeepSeek Harness subagents: close' : 'DeepSeek Harness subagents'}
aria-label={props.dshSubagentsActive ? 'DeepSeek Harness subagents: close' : 'DeepSeek Harness subagents'}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These user-facing strings are hardcoded, while all sibling toggle buttons in this header use the translation hook (e.g. t('session.outline.open')). Since locale keys for this feature already exist (dsh.subagents in both en.ts and zh-CN.ts), these tooltips will not be localized and will always render in English for Chinese UI users. Use t() (e.g. t('dsh.subagents')) for both title and aria-label so the toggle follows the existing i18n convention.

low · maintainability

Swear Review

Comment thread web/src/lib/locales/en.ts
Comment on lines +1170 to +1177
'dsh.send': 'Send',
'dsh.sending': 'Sending…',
'dsh.composerPlaceholder': 'Message the agent… (Enter to send)',
'dsh.goal': 'Goal',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing two-space indentation — this line is the only key in the block without the leading indent that all surrounding entries use, which can trip formatter/lint checks (e.g., Prettier) and reduces readability.

Suggested change
'dsh.goal': 'Goal',
'dsh.goal': 'Goal',

low · style

Swear Review

return undefined
}

export { asString }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

asString is imported from @hapi/protocol but never used anywhere in this module — it is only re-exported at the bottom. No other file imports asString from this module (only useDshSessionState is consumed), so this is an accidental/dead re-export. Remove the unused import and the export { asString } line.

low · maintainability

Swear Review

}

/** Extract the first text block of a raw user/agent message. */
export function messageText(message: DecryptedMessage): string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

messageText only extracts text when message.content is an object with a string content.text or data.text/data.message. It returns '' for other common content shapes used in this codebase: plain-string content (e.g. a role-wrapped user message { role: 'user', content: 'hello' }) and array-style content blocks ([{ type: 'text', text }, ...]). The hub's extractUserMessageText (hub/src/sync/syncEngine.ts) demonstrates these shapes are real. Since this helper is exported as a general-purpose text extractor for raw user/agent messages, it should also handle plain strings and text-block arrays, or its scope should be narrowed/documented accordingly.

medium · bug

Swear Review

Comment thread web/src/lib/query-keys.ts
sessionCursorChatStore: (sessionId: string) => ['session-cursor-chat-store', sessionId] as const,
sessionPiModels: (sessionId: string) => ['session-pi-models', sessionId] as const,
sessionDshModels: (sessionId: string) => ['session-dsh-models', sessionId] as const,
sessionDshSkills: (sessionId: string) => ['session-dsh-skills', sessionId] as const,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This query key is declared but never referenced anywhere in the codebase (searched both queryKeys.sessionDshSkills and the literal 'session-dsh-skills'). Per the dead-code checklist, unused declarations should be removed until the consumer is added, to avoid accumulating stale registry entries.

low · maintainability

Swear Review

Comment on lines +486 to +491
/** DSH state rows are retained for the panels but are invisible; only the
* latest snapshot is needed (higher-seq wins on the client fold), so they
* neither consume the visible window budget nor grow unboundedly. */
function isInvisibleJournalRow(message: DecryptedMessage): boolean {
return isDshNativePayloadMessage(message)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isInvisibleJournalRow is dead code: it is defined but never referenced anywhere in the codebase (the only match is its own definition). The actual trim/retention logic uses isDshNativePayloadMessage and isDshStateRow directly. Please remove it to avoid confusion, or use it if it was intended for shouldRetainWindowMessage/trimPreservingQueued.

low · maintainability

Swear Review

Comment on lines +493 to 502
/** Only the newest dsh_state snapshot matters to the client fold. */
function isDshStateRow(message: DecryptedMessage): boolean {
const record = message.content && typeof message.content === 'object'
? (message.content as { content?: unknown }).content
: null
const data = record && typeof record === 'object'
? (record as { data?: unknown }).data
: null
return typeof data === 'object' && data !== null && (data as { type?: unknown }).type === 'dsh_state'
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isDshStateRow duplicates the exact same record/data extraction logic already written in isDshNativePayloadMessage. If the two checks ever need to adapt to a new message envelope, they must be kept in sync. Consider extracting a shared helper (e.g. getDshPayloadData(message)) or deriving isDshStateRow from isDshNativePayloadMessage plus an extra type === 'dsh_state' check to eliminate the duplication.

low · maintainability

Swear Review

@swear-review

swear-review Bot commented Aug 16, 2026

Copy link
Copy Markdown

Swear Review

Mode: Full PR
Model: deepseek-v4-flash
OCR: v1.9.0
Commit: 39bb9f9

Findings:

  • none

0 findings total
0 inline comments

Status: ✅ Completed

@swear-review swear-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swear Review — Full PR review · batch 1/1 · OCR v1.9.0 · deepseek-v4-flash

Comment thread cli/src/dsh/DshClient.ts
Comment on lines +229 to +232
const value = response.result.value as { accepted?: boolean; command?: { kind: 'success'; text?: string } }
return {
accepted: value.accepted === true,
...(value.command ? { command: value.command } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

response.result.value is typed unknown on the ok: true branch of promptDirect, so at runtime it could be null/undefined (e.g., a soft-rejected prompt). Dereferencing value.accepted/value.command here would throw a TypeError inside the prompt dispatch path — the caller in runDsh.ts would then surface a misleading "Failed to send prompt" error even though the host may have processed the prompt. Add a null guard before reading the fields.

Suggested change
const value = response.result.value as { accepted?: boolean; command?: { kind: 'success'; text?: string } }
return {
accepted: value.accepted === true,
...(value.command ? { command: value.command } : {}),
const value = response.result.value as { accepted?: boolean; command?: { kind: 'success'; text?: string } } | null | undefined
return {
accepted: value?.accepted === true,
...(value?.command ? { command: value.command } : {}),

medium · bug

Swear Review

Comment thread cli/src/dsh/DshClient.ts
method: M,
payload: unknown
): Promise<unknown> {
const response = await this.api.goals[method](payload as never)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

payload as never (and the equivalent casts in subagentCall and updateQueueAction's itemId/action) completely disables structural type-checking against the official apiproxy method signatures. The locally declared wrapper payload types can silently drift from the real wire contract (misspelled fields, wrong shapes) and only fail at runtime. Where possible, type the payloads against the official request types instead of forcing as never.

low · maintainability

Swear Review

Comment on lines +177 to +180
signal.addEventListener('abort', handleAbort, { once: true })
if (signal.aborted) {
handleAbort()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the caller's AbortSignal is already aborted before the generator body first runs, the only thing that ends the stream is handleAbort() relying on socket.close() on a CONNECTING socket to either throw or eventually fire a close/error event. The code itself acknowledges runtime variance here (the catch comment notes "CONNECTING close() throws in some runtimes"). If close() silently no-ops without a terminal event, no end item is ever enqueued: the generator parks forever at await new Promise(...), the finally cleanup never runs, and the socket + listeners leak — which can also stall the bridge reconnect path that awaits the stream's termination. End the stream deterministically in the pre-aborted case instead of depending on close-event semantics.

Suggested change
signal.addEventListener('abort', handleAbort, { once: true })
if (signal.aborted) {
handleAbort()
}
signal.addEventListener('abort', handleAbort, { once: true })
if (signal.aborted) {
handleAbort()
enqueue({ kind: 'end' })
}

medium · bug

Swear Review

Comment thread cli/src/dsh/DshNodeTransport.ts Outdated
Comment on lines +55 to +57
const signal = externalSignal
? AbortSignal.any([AbortSignal.timeout(30_000), externalSignal])
: AbortSignal.timeout(30_000)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 30s transport timeout is a hardcoded magic number. The rest of the DSH runtime code centralizes such values as named constants (e.g. STOP_TIMEOUT_MS, INSTALL_TIMEOUT_MS, READY_POLL_INITIAL_MS in DshRuntime.ts). Extract it to a module-level constant so it is documented, reusable, and consistent with the existing convention — and so a long-running prompt that needs a different budget can be tuned in one place.

Suggested change
const signal = externalSignal
? AbortSignal.any([AbortSignal.timeout(30_000), externalSignal])
: AbortSignal.timeout(30_000)
const PROMPT_TIMEOUT_MS = 30_000
const signal = externalSignal
? AbortSignal.any([AbortSignal.timeout(PROMPT_TIMEOUT_MS), externalSignal])
: AbortSignal.timeout(PROMPT_TIMEOUT_MS)

low · maintainability

Swear Review

Comment on lines +658 to +675
this.childRecoveryReleased.add(childSessionId)
buffered
.sort((a, b) => a.seq - b.seq)
.forEach((event) => this.handleSessionEvent(childSessionId, event, 'live', true))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backfilled child events never reach onMessage with source='backfill'. In backfillChildJournals() the replay passes source 'backfill', but while the child's buffer is sealed (journalRecoveryInFlight is true and the buffer was created at the top of the loop) handleSessionEvent re-buffers the event and returns. The drain below then forwards everything with source 'live' + forceForward, so the 'backfill' label is silently lost and any consumer distinguishing recovered vs live history (suppressing notifications/scroll for recovered subagent journals) would treat all replayed child events as live. Either drain with the original source or forward the replayed events directly (forceForward=true, source='backfill') before draining only the truly live frames.

medium · bug

Swear Review

Comment on lines +1919 to 1921
: agentFlavor === 'dsh'
? dshModelOptions
// Pi uses its own provider-qualified picker (piModels prop).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deeply nested ternary chains for availableModelOptions / availableEffortOptions (and availableModelReasoningEffortOptions): after threading the dsh branch in, the model-options expression is now 4–6 levels deep, and the reasoning-effort expression similarly. This violates the no-nested-ternary guideline and makes later flavor additions error-prone. Suggest extracting a small computeModelOptionsForFlavor(...) / flavor-keyed lookup so each agent flavor is a flat case.

low · maintainability

Swear Review

return {
models: query.data ?? null,
isLoading: query.isLoading,
error: query.error instanceof Error ? query.error.message : (query.error ?? null),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error mapping here returns the raw error message (e.g. HTTP 502 Bad Request: {"error":"..."} from the hub route), which is user-facing in the model picker. Sibling hooks (useCursorModels/usePiModels/useCodexModels) map errors to a friendly localized fallback like 'Failed to load X models'. Consider applying the same pattern (e.g. Failed to load DSH models) for consistency and better UX.

low · maintainability

Swear Review

Comment on lines 344 to +346
'newSession.type.worktree.placeholder': 'feature-x (默认 1228-xxxx)',
'newSession.agent': '代理',
'newSession.model': '模型',
'newSession.dshModelHint': 'DeepSeek Harness 的模型會在 session 建立後動態載入 —— 建立後在 session 設定中選擇。',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Simplified Chinese (zh-CN) locale string is written in Traditional Chinese (會/在/動態/載入/設定 instead of 会/在/动态/加载/设置), and it's the only Traditional Chinese line in the file. Since this is a zh-CN locale file, it should be converted to Simplified Chinese for consistency, e.g. 'DeepSeek Harness 的模型会在 session 建立后动态加载 —— 建立后在 session 设置中选择。'

Suggested change
'newSession.dshModelHint': 'DeepSeek Harness 的模型會在 session 建立後動態載入 —— 建立後在 session 設定中選擇。',
'newSession.dshModelHint': 'DeepSeek Harness 的模型会在 session 建立后动态加载 —— 建立后在 session 设置中选择。',

medium · bug

Swear Review

Comment on lines +1202 to +1209
'dsh.model': '模型',
'dsh.mode': '模式',
'dsh.mode.standard': '标准模式',
'dsh.mode.code': 'PTC 模式',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'dsh.mode.code' (English source: 'Code mode') is translated as 'PTC 模式'. 'PTC' appears nowhere else in the codebase and doesn't correspond to the key value 'code'; it looks like a copy-paste/typo error. Users will see an unexplained 'PTC 模式' in the mode selector. It should be '代码模式' to match the surrounding pattern (标准模式/极简模式/创造模式).

Suggested change
'dsh.mode.code': 'PTC 模式',
'dsh.mode.code': '代码模式',

medium · bug

Swear Review

Comment on lines +1211 to +1218
'dsh.loadPresets': '加载预设',
'dsh.skills': 'Skills',
'dsh.subagents': '子代理',
'dsh.loadSubagents': '列出子代理',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'dsh.loadSubagents' is translated as '列出子代理' (list subagents), while the sibling key 'dsh.loadPresets' is '加载预设' (load presets). Both keys describe the same 'load' action, so the translation is inconsistent. It should be '加载子代理' for consistency.

Suggested change
'dsh.loadSubagents': '列出子代理',
'dsh.loadSubagents': '加载子代理',

low · style

Swear Review

swear01 added a commit that referenced this pull request Aug 16, 2026
Critical: childCursorByChild was shadowed by a nested redeclaration —
onChildCursor wrote the inner map while flushCursor read the outer one,
so subagent journal cursors were never persisted. Removed the shadow.

High/medium fixes:
- Bridge: abort listener leak in waitForAbortableDelay; child-journal
  seal starts before pumps dispatch (queued frames before subscription);
  retryMs resets after a healthy generation; session-removed cleans
  childLastSeq/childBuffers; CURSOR_FLUSH_MS/lastCursorFlush dead code
  removed; asString evaluated once
- Transport: promptDirect checks HTTP status; readWebSocket handles
  socket 'error' (enqueues stream end); SESSION_PROMPT_PATH constant
- Runtime: install child stdout ignored (pipe-block risk); overlayDir
  cleaned on every failure path; readiness race timer cleared; stop()
  clears SIGKILL timer + exit listener; isDshRuntimeInstalled removed
- Projector: tool-result content optional-chained; end-seed persisted
  as dsh_native; stateSnapshot seq no longer overridable by folded seq;
  dead step state (currentTurn/streamed/finished/finalized*) removed
- Hub: rewind finalize moved outside the archive CAS try/catch
- Web: queue/jobs arrays optional-chained (unvalidated payloads);
  dispatch swallows failures (callers are fire-and-forget); status
  summary labels localized; dsh.goal i18n keys added
- Shared: dshChildCursors validated int().nonnegative()
- Doctor: imports cleaned, unreachable catch removed, ternary flattened
@swear01
swear01 force-pushed the feat/dsh-integration branch from 6bc1fe0 to 0bdebd1 Compare August 16, 2026 17:15
swear01 added 18 commits August 18, 2026 06:46
Shared:
- Add 'dsh' to AGENT_FLAVORS / AgentFlavorSchema / CREATABLE_AGENT_FLAVORS
- DSH permission presets are runtime-discovered: getPermissionModesForFlavor('dsh') returns []
- Metadata gains dshSessionId / dshRuntimeVersion / dshEventCursor (Zod-validated)
- sessionSummary maps flavor dsh -> dshSessionId
- Web: NewSession MODEL_OPTIONS.dsh = [] (runtime-discovered, no static presets);
  AgentFlavorIcon renders the official DeepSeek Harness wave mark (currentColor)

CLI (cli/src/dsh/):
- DshRuntime: spawns the official pinned dsh runtime (--profile web) with a
  host-only overlay (no web UI: GET / -> 404, verified), loopback port probe,
  readiness handshake via host.describe, graceful SIGTERM stop, crash capture,
  auto-install under HAPI_HOME/dsh-runtime (bun, npm fallback)
- DshNodeTransport: AbstractApiClient subclass speaking the exact official
  apiproxy wire (HTTP POST /api/<endpoint> + WebSocket event streams) the
  official web UI uses, without any browser bundle
- DshClient: typed unwrap of unary calls (DshRpcError on business failure),
  create-as-resume with preallocated session ids, mux/host streams
- Tests: fixture host speaking official schemas (6), fake-runtime spawn tests
  (4), real-host integration suite gated by HAPI_DSH_INTEGRATION=1 (4, verified
  locally against dsh 0.1.0-rc.6: no frontend served, idempotent resume,
  session-conflict on cwd mismatch, mux subscribed frames)
…idge

Shared protocol (shared/src/dsh.ts): HAPI's own DSH-shaped, DSH-package-free
allowlisted vocabulary — DshActionSchema (prompt/interrupt/approval/question/
queue/model/goal/subagent/presets/history/fork/feedback), durable native event
+ state snapshot views (queue/jobs/goal/questions/approvals), and web-facing
model/subagent/preset/skill/history response schemas. New RPC methods
dsh-action / dsh-models / dsh-skills.

CLI session runner:
- DshProjector: native SessionEvent → HAPI messages. assistant/chunk deltas
  stream as live snapshots with stable turn/step/block ids (reconnect-safe),
  assistant/message/tool events emit final forms; every non-chunk event is
  persisted as dsh_native (tool trees, subagent, workflow, plan, goal semantics
  survive replay); dshSeq + dshMessageId anchors for fork/feedback.
- DshEventBridge: pumps official mux+host streams; approval frames → HAPI
  agentState permission lifecycle; question/queue/jobs/projection frames →
  dsh_state snapshots; subagent child events persisted natively; cursor
  callback for at-most-once resume.
- DshRpcBridge: Zod-validated allowlisted dispatch, no arbitrary method
  proxy; legacy Permission RPC maps to the official two-outcome approval
  response; host-global surfaces (settings/credentials/preset authoring)
  deliberately absent.
- runDsh: bootstrap → host-only DSH runtime spawn → create-as-resume
  (HAPI id = DSH id) → bridge → queue-mode prompts; kill handler + lifecycle
  stop the host gracefully.
- hapi dsh command + runner buildCliArgs dispatch.

Tests: projector 10, event bridge 5 (fixture host), full cli suite green.
The DSH host loads NAPI modules (node-pty) that crash under Bun's libuv
shim (oven-sh/bun#18546: unsupported uv function uv_version_string). The
HAPI CLI itself often runs under Bun, so process.execPath is wrong.

- Spawn .js runtime bins with a resolved node binary
  (HAPI_DSH_NODE_PATH override, else 'node' from PATH).
- installDshRuntime now requires node to be present (execution needs it
  even when bun performs the install) with a clear error message.

Verified end-to-end against a real host: spawn -> create-as-resume ->
prompt (queue accepted) -> mux frames (queue/turn/step/user-message/
projection) -> cancel -> graceful stop.
- rpcGateway: dshAction (allowlisted DshActionSchema payload), dshModels,
  dshSkills session-scoped RPC wrappers
- SyncEngine public passthroughs (private rpcGateway stays encapsulated)
- REST: POST /api/sessions/:id/dsh/{action,models,skills} with namespace/
  ownership gating via requireSessionFromParam, flavor gate (dsh only),
  Zod validation before any RPC, 502 on CLI failures
- Tests: routing, flavor gate, malformed payload rejection, namespace
  denial, model/skill catalog passthrough, RPC failure surfacing (7 tests)
- DshSessionView (flavor-gated in the session route): conversation with
  streaming text/reasoning/tool cards + fork-at-message, session header with
  model/effort/running/pending-approval state, goal bar (pause/resume/
  complete/clear), queue dock (steer/remove), background jobs dock, pending
  user-question dialog (single/multi/free-form), approval panel driven by
  agentState (allow-once/deny through the legacy permission RPC), runtime-
  discovered model picker (provider groups + reasoning efforts), agent
  preset picker, skills palette (leading-/ insertion), subagent list
- DshComposer: queue-mode sends through the standard message pipeline
- normalizeAgent: DSH projection branches (text/reasoning/tool_call/
  tool_result/usage/token-count); dsh_native/dsh_state are folded from raw
  messages by useDshSessionState (higher-seq-wins), never rendered as blocks
- API client + hooks: dshAction (allowlisted), dshModels, dshSkills
- i18n en + zh-CN

Web suite green (2451 tests); web build passes.
- docs/guide/deepseek-harness.md: architecture, security boundary, feature
  parity matrix, troubleshooting, version pin
- agents.md support matrix row; root + cli README mentions
- NewSession: YoloToggle hidden for flavors without permission modes (dsh)
Spawns a fixture DSH host in a child process (real loopback HTTP + WS wire,
official envelope schemas) and drives the production paths: create-as-resume,
queue prompt, chunk streaming (live snapshots -> final settle), tool call,
approval frame with rpcId mapping, tool result, turn_complete, usage, native
journal persistence, dshSeq fork anchors, interrupt (cancel), graceful stop.
- runDsh keeps the hub session alive (session-alive every 2s, thinking flag
  follows host running status) — without it the hub marks the session
  inactive and drops the RPC target
- DshRuntime kills the spawned host on readiness timeout so failed starts
  never leak child processes

Verified end-to-end against a real host + hub: session active, dshSessionId
= HAPI id, runtime version + conversationHistory capabilities persisted,
queue-mode prompt streams dsh_native (27) / dsh_state (5) / turn_complete /
error messages into the hub DB, interrupt + runtime model catalog (deepseek-
official groups) served through the allowlisted routes.
- Fork: CLI registers ForkConversation handler — user-message localIds are
  mapped to native event seqs (conversationHistoryPoints/Indexes, flushed to
  metadata) so fork-at-message anchors the exact native log position;
  session.fork returns the new native id. Hub forkConversation creates the
  child row with dshSessionId + conversationHistory locators, hydrates the
  transcript prefix, spawns the child CLI (create-as-resume), and waits for
  exact native bind.
- Rewind: no native DSH rewind exists — official semantics are fork. CLI
  acknowledges; hub forks a child at the anchor, archives the source with
  supersededBySessionId (web followSupersedingSession navigates), and stops
  the old CLI.
- DshActionSchema hardened: conditional requirements via superRefine
  (goal.create needs objective; subagent prompts need child+text; feedback
  put/delete field requirements) with allowlist tests.
- buildCliArgs: dsh never receives --permission-mode/--yolo (tested);
  hapi doctor reports DSH runtime install/version/node status.
- Docs: DSH upgrade procedure (pin + overlay validation).
…ests

- Web queue dock gains inline edit (official queue.action edit)
- runDsh cursor throttle now persists the LATEST forwarded seq
- hapi doctor: DSH runtime installed/version/node availability
- Hub tests: DSH fork child metadata + exact-native bind; rewind = fork +
  archive with supersededBySessionId
The CLI now records each user message's localId when a native user/message
event claims it (FIFO), persisting conversationHistoryPoints +
conversationHistoryIndexes so the web shows fork-at-message affordances and
the ForkConversation handler can anchor the exact native event seq. Verified
live: points {localId: true} + indexes {localId: 108} after a queue prompt.

Also verified live: runner-spawned dsh session (spawn → prompt → native
events → fork current → child active with dshSessionId + 20 hydrated
transcript messages); DSH runtime auto-install under HAPI_HOME/dsh-runtime.
Full suites green (cli 2490 / hub 1089 / web 2452 / shared 269).
…ess pump

The E2E pump is long-running by design; awaiting it after abort left the
test hanging on runners where the host-stream socket close races. The
fixture host now accepts /api/events.host so both pumps settle cleanly.
DSH sessions now render through the standard SessionChat / HappyThread /
HappyComposer interface like every other agent — the custom DshSessionView
is removed. DSH keeps its native semantics only as side panels:

- Router gate removed: dsh -> standard SessionChat (conversation streaming,
  tool cards, permission cards, composer, queue bar, fork/rewind buttons all
  standard)
- DshSessionPanels embedded above the composer: goal bar, queue dock
  (steer/remove/edit), jobs dock, runtime-discovered model picker, agent
  presets, skills (click to invoke /name), subagents, pending user-question
  dialog — folded from dsh_state snapshots (higher-seq-wins)
- CLI: registerSessionConfigRpc (model endpoint -> DSH selectModel with
  provider resolved from the live catalog; permission/effort rejected —
  DSH presets are runtime-discovered); prompts emit messages-consumed so the
  standard queued bar stays empty (DSH queue owns its queue)
- Skill palette: onInvoke sends the leading-/ reference directly

Verified live on the 43006 test env: slash-commands/skills endpoints serve
DSH catalogs, hub model endpoint switches to deepseek-v4-pro via
SetSessionConfig -> selectModel. Full suites green (cli 2490 / hub 1089 /
web 2455 / shared 269).
…ckers, subagents in header

Per parity with Codex/Claude sessions, DSH now uses the standard HAPI
surfaces end to end:

- Model + reasoning effort: runtime-discovered DSH catalog feeds the standard
  composer picker (availableModelOptions / availableModelReasoningEffortOptions
  like Codex); changes go through the standard model / model-reasoning-effort
  endpoints -> SetSessionConfig -> DSH selectModel (provider resolved from the
  live catalog; hub effort route now allows dsh). The custom model panel is
  gone.
- Skills: standard leading-/ composer invocation (HAPI slash commands); the
  custom skills palette is gone.
- Agent presets panel removed (out of scope for now).
- Subagents: session-header button with activity badge opens a status modal
  (catalog + running count); the side panel is gone.
- DSH-native state (goal/queue/jobs) collapses into a slim status strip above
  the composer so the thread stays clean; pending user questions still pop a
  modal.

Verified live on the 43006 test env: standard /model -> deepseek-v4-pro and
/model-reasoning-effort -> high both apply through the CLI into the DSH host.
Suites green (cli 2521 / hub 1102 / web 2481 / shared 269).
…der icon

- HappyComposer generated reasoning-effort options only for codex/opencode;
  dsh now flows through the same dynamic-options path (catalog efforts:
  off/high/max with default high) and SessionChat passes
  modelReasoningEffort through so the current value renders
- Header subagent toggle now uses the Lucide 'users' glyph at 18x18,
  matching Files/Outline/Terminal toggle style and size

Verified live: dsh/models catalog exposes efforts for both deepseek models;
environment restarted with the new bundle.
swear01 added 21 commits August 18, 2026 06:46
- Startup attach race includes hostDone: if the host stream closes
  before the root subscription, the generation aborts and reconnects
  instead of hanging
- session-ready and user-message dispatch are gated on bridge readiness
  (root subscribed + initial backfill released): a prompt committed
  before the mux subscription is no longer replayed as backfill, which
  could consume its pending localId and corrupt fork/rewind anchors
- DshActionSchema drops prompt / model.select / fork: those mutations
  are owned by HAPI orchestration (message persistence, localId
  mapping, config metadata, child-session creation) and would corrupt
  state if called directly; web already routes them through the
  standard HAPI paths
- Prompt identity via the host-echoed rpcId: DshClient.prompt returns the
  dispatch rpcId; user/message events' MessageSource.rpcId binds the HAPI
  localId to the exact native seq. Acceptance-order FIFO removed — it
  could not represent commands emitting no user/message, removed queue
  items, steered ordering, or edited prompt text (wrong fork/rewind
  anchors, diverging fork-child transcripts)
- Subagent journals gap-fill on reconnect: per-child lastSeq tracked with
  seq guard, subagent.history paged (continuable with one-shot fallback)
  after every successful root backfill — child activity during a mux
  outage is no longer permanently missing from dsh_native
- Hub dsh route test uses the remaining allowlisted actions (prompt/
  model.select/fork are orchestration-owned and now rejected)
… (round 16)

- Prompt rpcId is reserved and the localId binding registered BEFORE the
  HTTP round-trip (DshNodeTransport.mintRpcId drains a reserved queue):
  a user/message event that beats the prompt response back still
  correlates to its HAPI row; failed prompts drop the stale binding
- Subagent recovery: children created entirely during an outage are
  discovered via subagent.list (carries each child's mode); live child
  frames are buffered while that child's history replay is in flight so
  the cursor can never advance past an unfetched gap
- Prompt dispatch bypasses the shared mint queue: DshClient.prompt posts
  session.prompt directly under a caller-owned rpcId (unrelated unary
  calls can no longer consume a queued prompt's reservation)
- Child journal replay failure keeps the live buffer sealed and the
  cursor untouched so the next reconnect retries the gap; per-child
  cursors persist in metadata (dshChildCursors) and restore on restart,
  so subagent journals never replay into a fresh HAPI row
- dsh_state snapshots never regress their overall seq (emitState clamps
  to the highest emitted seq); late bootstrap projections with older
  asOfSeq can no longer make the web discard valid fields
- Fork cursor probe falls back to the fork point (atSeq) or the latest
  persisted root cursor, so a transient probe failure cannot replay the
  already-copied native prefix into the child row
- sessionFactory preserves dshChildCursors across reopens — child replay
  cursors are durable, not process-local
- Failed child-history replay releases the live buffer (frames forward as
  live, advancing the durable cursor); the next reconnect's history
  fetch closes whatever gap the outage left — a child can never stay
  permanently buffered
- Fork-current cursor fallback uses the bridge's live-forwarded seq (not
  just the throttle window's persisted metadata), so fork-current probe
  failure inside the flush window still seeds a correct cursor
- DSH rewind re-reads the source row after KillSession and retries the
  metadata CAS once (the source CLI's final cursor flush can bump
  metadataVersion mid-kill) before reporting archive failure
- onCursor's throttle timer now routes through flushCursor, which writes
  BOTH the root cursor and per-child journal cursors in one metadata
  update — child cursors are no longer process-local
- Failed child backfill keeps the buffer sealed and the cursor untouched
  (next reconnect retries the full gap) with a bounded 5k-frame overflow
  fallback; releasing the buffer would advance the durable cursor past
  the missing gap
- Every KNOWN child journal is sealed before the root backfill starts —
  child events forwarded during root history fetch can no longer advance
  their cursors past the outage gap
- latestForwardedSeq seeds from the persisted dshEventCursor, so a
  resumed idle session's fork-current fallback can never use the 0
  sentinel
- Successful rewind archival finalizes cache refresh + session-end
  broadcast BEFORE returning success (the previous return made the
  finalization dead code)
- Child-only activity arms the shared cursor-flush throttle (onChildCursor
  schedules the same timer), so per-child cursors persist in metadata
  even when the root stream is idle — restart replay of subagent
  journals is gone
- Failed child-history replay aborts the generation and reconnects with
  backoff (buffers stay sealed, cursors untouched); the bounded overflow
  release that could advance a cursor over a missing range is removed
- rootBackfillInFlight seals EVERY child journal during root history
  fetch — children first seen mid-backfill buffer their events too, so
  no child cursor can advance past outage events
- flushCursor now actually writes dshChildCursors (merged with stored
  values) — the round-18 patch had silently missed the replace target;
  child-only activity persists its cursors via the shared throttle
- Failed child replay resets initialBackfillDone so the next generation
  re-runs the WHOLE initial recovery (root re-seals journals); root
  events stay buffered until recovery succeeds
- journalRecoveryInFlight covers root history fetch AND child replays,
  so children first seen at any point during recovery are sealed; child
  replay buffers merge instead of overwriting previous generations'
  sealed buffers
11 fixture-host tests exercising the paths the review bots kept flagging:
- A1-A3: first-generation backfill, live-events-during-backfill buffering,
  failed-backfill abort/retry
- A4-A6: host-close-before-subscription, reconnect gap-fill (no dupes),
  live-events-before-reconnect-backfill
- A7-A10: per-child cursors, unknown-child sealing + discovery replay,
  failed child backfill retry, live-child-during-replay buffering
- A13: question/resolved emits null (no empty dialog)

Also fixes a real bug the tests exposed: releasing a child's sealed buffer
re-buffered the frames because journalRecoveryInFlight was still active —
forceForward now bypasses the seal so released frames always forward.
Fixture gains disconnectMux/disconnectHost/muxSocketCount + subagent
handlers + closeAllConnections.
- C1-C3 (DshClient): promptDirect posts under the caller-owned rpcId,
  response rpcId mismatch throws, and an intervening unary call cannot
  consume a prompt reservation
- E1-E4 (fixture): rpcId binds the HAPI localId to the native
  user/message seq; the mux event beating the prompt HTTP response still
  binds (pre-registered); rejected prompts leave no stale binding;
  events without a rpcId never consume a pending binding
- E5: readiness contract (history probed before prompt dispatch)
- F1/F2 (hub): fork child seeds dshEventCursor from nativeCursor; absent
  cursor leaves it unset (child bridge defaults to its own fallback)
- F3: rewind archive CAS failure cleans up the live fork child
- G1: resolveAgentResumeId resolves dshSessionId
- H1/H3 (cli): pickExistingSessionMetadata (now exported) preserves the
  full DSH resume identity — dshSessionId/runtimeVersion/eventCursor/
  childCursors/selectedModel — and invents nothing for undefined fields
The setup() history wrapper compared the raw return of an async handler
against undefined — a Promise<undefined> passed the check and reached the
server as an undefined result, producing unhandled ZodErrors in CI.
Await the custom handler before the fallback; recovery + prompt-identity
suites now run clean.
- readDshRuntimeVersion resolves the dsh package manifest two parents
  above bin.js (the bot-flagged wrong-manifest bug is pinned by a test)
- Unreadable manifests return null (upgrade path triggers)
- Explicit overrides with a missing runtime fail with kind spawn and
  never auto-install
Critical: childCursorByChild was shadowed by a nested redeclaration —
onChildCursor wrote the inner map while flushCursor read the outer one,
so subagent journal cursors were never persisted. Removed the shadow.

High/medium fixes:
- Bridge: abort listener leak in waitForAbortableDelay; child-journal
  seal starts before pumps dispatch (queued frames before subscription);
  retryMs resets after a healthy generation; session-removed cleans
  childLastSeq/childBuffers; CURSOR_FLUSH_MS/lastCursorFlush dead code
  removed; asString evaluated once
- Transport: promptDirect checks HTTP status; readWebSocket handles
  socket 'error' (enqueues stream end); SESSION_PROMPT_PATH constant
- Runtime: install child stdout ignored (pipe-block risk); overlayDir
  cleaned on every failure path; readiness race timer cleared; stop()
  clears SIGKILL timer + exit listener; isDshRuntimeInstalled removed
- Projector: tool-result content optional-chained; end-seed persisted
  as dsh_native; stateSnapshot seq no longer overridable by folded seq;
  dead step state (currentTurn/streamed/finished/finalized*) removed
- Hub: rewind finalize moved outside the archive CAS try/catch
- Web: queue/jobs arrays optional-chained (unvalidated payloads);
  dispatch swallows failures (callers are fire-and-forget); status
  summary labels localized; dsh.goal i18n keys added
- Shared: dshChildCursors validated int().nonnegative()
- Doctor: imports cleaned, unreachable catch removed, ternary flattened
Real bugs fixed:
- DshClient.prompt accepted was coerced to true even when the host
  rejected (latent correctness bug) — now boolean
- Projector: turn/end without reason guarded; tool-call-delta without
  argumentsDelta no longer appends 'undefined'; tool/result finds the
  result block instead of assuming content[0]
- Bridge: children whose replay finished stay released (their live
  events forward while siblings replay — previously re-sealed forever);
  bootstrap retries after later re-subscription when all attempts fail;
  consumer throws contained; handler errors logged at warn
- Transport: promptDirect composes the caller's abort signal, checks
  HTTP status + envelope shape, guards json() parse; WebSocket error
  listener registered/removed; CONNECTING close() guarded
- Runtime: exit-before-readiness cleans overlayDir; install watchdog
  (5min SIGKILL); JSDoc placement fixed
- Fixture: catch-all HTTP responses (never hang the client), OPEN-only
  sends, deterministic rpcIds, pendingServerRequests populated
- resume.test.ts gains a DSH branch test; isDshMessage + unused import
  + dead code removed
- Bridge: empty-page hasMore=true bails (no infinite pagination); sealed
  children missing from childLastSeq are still replayed (their buffers
  release); session-added keeps an existing projector fold; child
  onMessage contained like root
- Transport: abort during CONNECTING ends the stream; console.error
  replaced with structured logger (TUI-safe)
- Runtime: readiness-timeout SIGTERM gets a 5s SIGKILL fallback; install
  failures wrap in DshRuntimeStartErrorImpl; stdout captured in the
  failure tail
- DshClient: respond receipt documented; gatewayCall guards HTTP status,
  json parse, envelope shape; crypto.randomUUID -> node:crypto
- runDsh: one catalog fetch per model switch (provider resolution +
  default effort share it)
- hub: dshModels uses MODEL_LIST_RPC_TIMEOUT_MS; models/skills routes
  requireActive (consistent 409 instead of opaque 502); sessionCache
  redundant cast removed
- DshRpcBridge workingDirectory documented as reserved
- Bridge: stream-pump rejections cannot escape the reconnect loop (race
  guarded); child-journal seal now starts BEFORE the pumps dispatch
  (covers frames queued before the subscription); subagent discovery
  failure retries the whole recovery instead of silently skipping
  outage-created children
- Projector: turn/end tolerates missing reason/turn payloads
- Runtime: install node probe honors HAPI_DSH_NODE_PATH; stop() guards
  kill races
- DshClient: subagentList tolerates a missing entries array
- runDsh: dead resolveModelProvider removed
- DshRpcBridge: feedback.put/delete return the recorded row (not an
  empty list shape)
- Integration fixture serves subagent.list/history
- Projector: assistant/message final ids now map through the recorded
  block-start chunk order — content array position can diverge when tool
  blocks interleave, which made the reducer duplicate instead of merge
- Bridge: stream-attach race guarded like the pump race; children sealed
  dynamically during recovery but absent from the discovery snapshot get
  their buffers released; goal status omits undefined instead of
  spreading it
- Runtime: sync spawn throw cleans the overlay dir and fails loud;
  install watchdog timeout constant; install verifies the installed
  version matches the pin
- Transport: wss:// never downgraded; promptDirect error surfaces the
  response body; unused import removed
- runDsh: attachment paths escaped in the marker line; effort options
  documented as intentionally not applied at launch
- doctor: node availability checked against the actual binary, not the
  always-present install dir
- Bridge: question/resolved validates the pending question rpcId (stale
  or duplicated resolves ignored); subagentIds re-aligned against the
  host's durable catalog after reconnect (missed session-removed frames
  no longer keep subagentCount stale); waitForAbortableDelay resolves
  immediately on an already-aborted signal; onReady doc corrected
- runDsh: startingMode ternary simplified (DSH is always remote);
  bridgeReady gains a 60s stall timeout that fails the session loudly
  instead of hanging in 'connecting'
- DshRpcBridge: reasoning efforts array guarded before map
- DshRuntime: options.port 0 now probes an ephemeral port
- DshNodeTransport: prompt timeout constant
- doctor: node probe honors HAPI_DSH_NODE_PATH
- Test: A13 uses the real pending question rpcId
@swear01
swear01 force-pushed the feat/dsh-integration branch from 0bdebd1 to 39bb9f9 Compare August 18, 2026 06:57
@swear01

swear01 commented Aug 18, 2026

Copy link
Copy Markdown
Owner Author

/swear-review incremental

@swear-review

swear-review Bot commented Aug 18, 2026

Copy link
Copy Markdown

Swear Review: incremental review queued (6bc1fe039bb9f9, job tiann#323).

@swear01
swear01 changed the base branch from main to dsh-review-base-0.28.0 August 18, 2026 09:25
@swear01

swear01 commented Aug 18, 2026

Copy link
Copy Markdown
Owner Author

/swear-review full

@swear-review

swear-review Bot commented Aug 18, 2026

Copy link
Copy Markdown

Swear Review: full review queued (job tiann#331).

@swear01

swear01 commented Aug 18, 2026

Copy link
Copy Markdown
Owner Author

/swear-review full

@swear-review

swear-review Bot commented Aug 18, 2026

Copy link
Copy Markdown

Swear Review: full review queued (job tiann#339).

@swear01 swear01 closed this Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant