dsh-plugin-subagent-bridge 0.15.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { CLAUDE_CANDIDATES } from '../constants.js'
6
+ import { expandHome } from '../settings.js'
7
+ import { ok, text } from '../utils.js'
8
+
9
+ export function resolveClaude(configured) {
10
+ const configuredValue = typeof configured === 'string' ? configured : ''
11
+ /* An explicit path is authoritative: never silently run a different binary. */
12
+ if (configuredValue.includes('/')) {
13
+ const expanded = expandHome(configuredValue)
14
+ return existsSync(expanded) ? expanded : undefined
15
+ }
16
+ for (const directory of String(process.env.PATH ?? '').split(':')) {
17
+ if (directory !== '' && existsSync(join(directory, 'claude'))) return 'claude'
18
+ }
19
+ for (const candidate of CLAUDE_CANDIDATES) {
20
+ const expanded = expandHome(candidate)
21
+ if (existsSync(expanded)) return expanded
22
+ }
23
+ return undefined
24
+ }
25
+
26
+ /**
27
+ * Run one `claude -p` turn and return its final answer.
28
+ *
29
+ * Claude Code prints a single JSON object in print mode, so the whole answer arrives at
30
+ * once — unlike Codex, which streams events. Anything on stderr is advisory: the CLI
31
+ * warns about an unrecognised model suffix while still succeeding, so only the JSON
32
+ * envelope decides the outcome.
33
+ * @param options - binary, prompt, cwd, resume id, model, timeout, signal, child registry.
34
+ * @returns `{ ok, text, sessionId?, durationMs, exitCode, error? }`.
35
+ */
36
+
37
+ export async function runClaude({
38
+ bin,
39
+ prompt,
40
+ cwd,
41
+ resume,
42
+ model,
43
+ timeoutMs,
44
+ signal,
45
+ children,
46
+ spawnImpl = spawn,
47
+ }) {
48
+ const started = Date.now()
49
+ if (bin === undefined) {
50
+ return { ok: false, text: '', exitCode: -1, durationMs: 0, error: 'claude executable not found; set config.claudeBin' }
51
+ }
52
+ const args = ['-p', prompt, '--output-format', 'json']
53
+ if (typeof resume === 'string' && resume !== '') args.unshift('--resume', resume)
54
+ if (typeof model === 'string' && model !== '') args.push('--model', model)
55
+
56
+ const outcome = await new Promise((resolve) => {
57
+ let child
58
+ try {
59
+ child = spawnImpl(bin, args, { cwd: cwd ?? homedir(), stdio: ['ignore', 'pipe', 'pipe'] })
60
+ } catch (error) {
61
+ resolve({ exitCode: -1, stdout: '', stderr: String(error), error: String(error) })
62
+ return
63
+ }
64
+ children?.add(child)
65
+ let stdout = ''
66
+ let stderr = ''
67
+ let settled = false
68
+ const finish = (value) => {
69
+ if (settled) return
70
+ settled = true
71
+ clearTimeout(timer)
72
+ signal?.removeEventListener?.('abort', abort)
73
+ children?.delete(child)
74
+ resolve(value)
75
+ }
76
+ const kill = (reason) => {
77
+ try {
78
+ child.kill('SIGTERM')
79
+ } catch {
80
+ /* Already gone. */
81
+ }
82
+ setTimeout(() => {
83
+ try {
84
+ child.kill('SIGKILL')
85
+ } catch {
86
+ /* Already gone. */
87
+ }
88
+ }, 5000).unref?.()
89
+ finish({ exitCode: -1, stdout, stderr, error: reason })
90
+ }
91
+ const timer = setTimeout(() => kill(`claude timed out after ${timeoutMs} ms`), Math.max(1000, timeoutMs ?? 300000))
92
+ const abort = () => kill('cancelled by the caller')
93
+ if (signal?.aborted === true) abort()
94
+ else signal?.addEventListener?.('abort', abort)
95
+ child.stdout?.on('data', (chunk) => {
96
+ stdout += chunk.toString('utf8')
97
+ })
98
+ child.stderr?.on('data', (chunk) => {
99
+ stderr += chunk.toString('utf8')
100
+ })
101
+ child.on('error', (error) => finish({ exitCode: -1, stdout, stderr, error: error.message }))
102
+ child.on('close', (code) => finish({ exitCode: code ?? -1, stdout, stderr }))
103
+ })
104
+
105
+ const parsed = parseClaudeOutput(outcome.stdout)
106
+ const durationMs = Date.now() - started
107
+ const build = (ok, error) => ({
108
+ ok,
109
+ text: parsed.text,
110
+ exitCode: ok ? 0 : outcome.exitCode,
111
+ durationMs,
112
+ ...(parsed.sessionId === undefined ? {} : { sessionId: parsed.sessionId }),
113
+ ...(error === undefined ? {} : { error }),
114
+ })
115
+ if (outcome.error !== undefined) return build(false, outcome.error)
116
+ if (parsed.error !== undefined) return build(false, parsed.error)
117
+ if (outcome.exitCode !== 0) {
118
+ const detail = outcome.stderr.trim().split('\n').slice(-3).join('\n')
119
+ return build(false, `claude exited with ${outcome.exitCode}${detail === '' ? '' : `: ${detail}`}`)
120
+ }
121
+ return build(true, undefined)
122
+ }
123
+
124
+ /**
125
+ * Read the print-mode JSON envelope.
126
+ * @param stdout - raw standard output.
127
+ * @returns `{ text, sessionId?, error? }`.
128
+ */
129
+
130
+ export function parseClaudeOutput(stdout) {
131
+ const raw = String(stdout ?? '').trim()
132
+ if (raw === '') return { text: '', error: 'claude printed nothing' }
133
+ /* A single object is the documented shape, but the CLI writes advisory lines to the
134
+ * same stream, and those lines can carry braces of their own. Parse whole output first,
135
+ * then scan lines backwards for the envelope that carries a result. */
136
+ const lines = raw.split('\n').map((line) => line.trim()).filter((line) => line !== '')
137
+ const candidates = [...lines].reverse()
138
+ let payload
139
+ let lastError
140
+ for (const candidate of candidates) {
141
+ if (!candidate.startsWith('{') || !candidate.endsWith('}')) continue
142
+ try {
143
+ const parsed = JSON.parse(candidate)
144
+ if (typeof parsed === 'object' && parsed !== null && typeof parsed.type === 'string') {
145
+ payload = parsed
146
+ break
147
+ }
148
+ } catch (error) {
149
+ lastError = error
150
+ }
151
+ }
152
+ if (payload === undefined) {
153
+ if (lastError !== undefined) {
154
+ return { text: '', error: `claude printed invalid JSON: ${String(lastError?.message ?? lastError)}` }
155
+ }
156
+ return { text: '', error: `claude printed no result envelope: ${raw.slice(0, 200)}` }
157
+ }
158
+ const text = typeof payload.result === 'string' ? payload.result.trim() : ''
159
+ const sessionId = typeof payload.session_id === 'string' && payload.session_id !== '' ? payload.session_id : undefined
160
+ const failed = payload.is_error === true || (typeof payload.subtype === 'string' && payload.subtype !== 'success')
161
+ const build = {
162
+ text,
163
+ ...(sessionId === undefined ? {} : { sessionId }),
164
+ }
165
+ if (failed !== true) return build
166
+ const detail =
167
+ typeof payload.result === 'string' && payload.result.trim() !== ''
168
+ ? payload.result.trim()
169
+ : `claude reported ${String(payload.subtype ?? payload.terminal_reason ?? 'an error')}`
170
+ return { ...build, error: detail }
171
+ }
172
+
173
+ /**
174
+ * Read a file, tolerating absence.
175
+ * @param path - file path.
176
+ * @returns the text, or undefined.
177
+ */
@@ -0,0 +1,185 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
4
+ import { homedir, tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { CODEX_CANDIDATES } from '../constants.js'
7
+ import { expandHome } from '../settings.js'
8
+ import { ok, text } from '../utils.js'
9
+
10
+ export function resolveCodex(configured) {
11
+ const configuredValue = typeof configured === 'string' ? configured : ''
12
+ /* An explicit path is authoritative: never silently run a different binary. */
13
+ if (configuredValue.includes('/')) {
14
+ const expanded = expandHome(configuredValue)
15
+ return existsSync(expanded) ? expanded : undefined
16
+ }
17
+ for (const directory of String(process.env.PATH ?? '').split(':')) {
18
+ if (directory !== '' && existsSync(join(directory, 'codex'))) return 'codex'
19
+ }
20
+ for (const candidate of CODEX_CANDIDATES) {
21
+ const expanded = expandHome(candidate)
22
+ if (existsSync(expanded)) return expanded
23
+ }
24
+ return undefined
25
+ }
26
+
27
+
28
+ /**
29
+ * Locate the Claude Code executable.
30
+ * @param configured - configured path or bare command name.
31
+ * @returns the command to spawn, or undefined.
32
+ */
33
+
34
+ export async function runCodex({
35
+ bin,
36
+ prompt,
37
+ cwd,
38
+ resume,
39
+ sandbox,
40
+ timeoutMs,
41
+ signal,
42
+ children,
43
+ spawnImpl = spawn,
44
+ }) {
45
+ const started = Date.now()
46
+ if (bin === undefined) {
47
+ return { ok: false, text: '', exitCode: -1, durationMs: 0, error: 'codex executable not found; set config.codexBin' }
48
+ }
49
+ const temp = await mkdtemp(join(tmpdir(), 'dsh-codex-'))
50
+ const outFile = join(temp, 'last-message.txt')
51
+ const resuming = resume !== undefined
52
+ const args = ['exec']
53
+ if (resuming) args.push('resume', resume)
54
+ args.push('--json', '--skip-git-repo-check', '-o', outFile)
55
+ /* `codex exec resume` takes neither --sandbox nor --cd: the thread keeps its own. */
56
+ if (!resuming) {
57
+ args.push('-s', sandbox === undefined ? 'workspace-write' : sandbox)
58
+ if (typeof cwd === 'string' && cwd !== '') args.push('-C', cwd)
59
+ }
60
+ args.push(prompt)
61
+
62
+ try {
63
+ const outcome = await new Promise((resolve) => {
64
+ let child
65
+ try {
66
+ child = spawnImpl(bin, args, { cwd: cwd ?? homedir(), stdio: ['ignore', 'pipe', 'pipe'] })
67
+ } catch (error) {
68
+ resolve({ exitCode: -1, stdout: '', stderr: String(error), error: String(error) })
69
+ return
70
+ }
71
+ children?.add(child)
72
+ let stdout = ''
73
+ let stderr = ''
74
+ let settled = false
75
+ const finish = (value) => {
76
+ if (settled) return
77
+ settled = true
78
+ clearTimeout(timer)
79
+ signal?.removeEventListener?.('abort', abort)
80
+ children?.delete(child)
81
+ resolve(value)
82
+ }
83
+ const kill = (reason) => {
84
+ try {
85
+ child.kill('SIGTERM')
86
+ } catch {
87
+ /* Already gone. */
88
+ }
89
+ setTimeout(() => {
90
+ try {
91
+ child.kill('SIGKILL')
92
+ } catch {
93
+ /* Already gone. */
94
+ }
95
+ }, 5000).unref?.()
96
+ finish({ exitCode: -1, stdout, stderr, error: reason })
97
+ }
98
+ const timer = setTimeout(() => kill(`codex timed out after ${timeoutMs} ms`), Math.max(1000, timeoutMs ?? 300000))
99
+ const abort = () => kill('cancelled by the caller')
100
+ if (signal?.aborted === true) abort()
101
+ else signal?.addEventListener?.('abort', abort)
102
+ child.stdout?.on('data', (chunk) => {
103
+ stdout += chunk.toString('utf8')
104
+ })
105
+ child.stderr?.on('data', (chunk) => {
106
+ stderr += chunk.toString('utf8')
107
+ })
108
+ child.on('error', (error) => finish({ exitCode: -1, stdout, stderr, error: error.message }))
109
+ child.on('close', (code) => finish({ exitCode: code ?? -1, stdout, stderr }))
110
+ })
111
+
112
+ const parsed = parseCodexOutput(outcome.stdout, await readIfExists(outFile))
113
+ const durationMs = Date.now() - started
114
+ /* A resumed run emits no thread.started, so every field stays optional: a tool
115
+ * result must be lossless JSON, and `undefined` is not. */
116
+ const build = (ok, error) => ({
117
+ ok,
118
+ text: parsed.text,
119
+ exitCode: ok ? 0 : outcome.exitCode,
120
+ durationMs,
121
+ ...(parsed.threadId === undefined ? {} : { threadId: parsed.threadId }),
122
+ ...(error === undefined ? {} : { error }),
123
+ })
124
+ if (outcome.error !== undefined) return build(false, outcome.error)
125
+ if (outcome.exitCode !== 0) {
126
+ const detail = parsed.error ?? outcome.stderr.trim().split('\n').slice(-3).join('\n')
127
+ return build(false, `codex exited with ${outcome.exitCode}${detail === '' ? '' : `: ${detail}`}`)
128
+ }
129
+ return build(true, undefined)
130
+ } finally {
131
+ await rm(temp, { recursive: true, force: true }).catch(() => {})
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Parse `codex exec --json` output.
137
+ * @param stdout - JSONL event stream.
138
+ * @param lastMessage - contents of the `-o` file, when written.
139
+ * @returns `{ text, threadId, error }`.
140
+ */
141
+
142
+ export function parseCodexOutput(stdout, lastMessage) {
143
+ let threadId
144
+ let error
145
+ const messages = []
146
+ for (const line of String(stdout ?? '').split('\n')) {
147
+ const trimmed = line.trim()
148
+ if (!trimmed.startsWith('{')) continue
149
+ let event
150
+ try {
151
+ event = JSON.parse(trimmed)
152
+ } catch {
153
+ continue
154
+ }
155
+ if (event.type === 'thread.started' && typeof event.thread_id === 'string') threadId = event.thread_id
156
+ if (event.type === 'item.completed' && event.item?.type === 'agent_message' && typeof event.item.text === 'string') {
157
+ messages.push(event.item.text)
158
+ }
159
+ if (event.type === 'error' && typeof event.message === 'string') error = event.message
160
+ if (event.type === 'turn.failed' && typeof event.error?.message === 'string') error = event.error.message
161
+ }
162
+ const text = typeof lastMessage === 'string' && lastMessage.trim() !== '' ? lastMessage.trim() : messages.join('\n\n').trim()
163
+ return { text, ...(threadId === undefined ? {} : { threadId }), ...(error === undefined ? {} : { error }) }
164
+ }
165
+
166
+ /**
167
+ * Find the `codex` executable.
168
+ * @param configured - configured binary name or path.
169
+ * @returns the path to use, or undefined when nothing resolves.
170
+ */
171
+
172
+ export async function readIfExists(path) {
173
+ try {
174
+ return await readFile(path, 'utf8')
175
+ } catch {
176
+ return undefined
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Read one session title without letting a missing service break the listing.
182
+ * @param service - optional title service.
183
+ * @param session - live session.
184
+ * @returns the title, when one exists.
185
+ */
@@ -0,0 +1,336 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import { ok, text } from './utils.js'
3
+
4
+ export function lastTurn(ctx, sessionId) {
5
+ const session = ctx.get('sessions')?.get?.(sessionId)
6
+ if (session === undefined || session === null) return 0
7
+ let turn = 0
8
+ for (const event of session.snapshotEvents()) {
9
+ if (event.type === 'turn/start' && Number(event.data?.turn) > turn) turn = Number(event.data.turn)
10
+ }
11
+ return turn
12
+ }
13
+
14
+ /**
15
+ * Resolve when the session finishes the turn that this dispatch starts.
16
+ * Subscribes before the prompt so a fast turn cannot be missed.
17
+ * @param ctx - Host context.
18
+ * @param sessionId - session to watch.
19
+ * @param afterTurn - ignore turns at or below this number.
20
+ * @param timeoutMs - bounded wait.
21
+ * @returns the `turn/end` payload, or undefined on timeout.
22
+ */
23
+
24
+ export function waitForTurnEnd(ctx, sessionId, afterTurn, timeoutMs) {
25
+ return new Promise((resolve) => {
26
+ let settled = false
27
+ const finish = (value) => {
28
+ if (settled) return
29
+ settled = true
30
+ clearTimeout(timer)
31
+ off()
32
+ resolve(value)
33
+ }
34
+ const off = ctx.on('session/event', (session, event) => {
35
+ if (session?.id !== sessionId) return
36
+ if (event?.type !== 'turn/end') return
37
+ if (Number(event.data?.turn) <= afterTurn) return
38
+ finish(event.data)
39
+ })
40
+ const timer = setTimeout(() => finish(undefined), Math.max(1000, timeoutMs))
41
+ })
42
+ }
43
+
44
+ /**
45
+ * Collect assistant text produced after a sequence number.
46
+ * @param ctx - Host context.
47
+ * @param sessionId - session to read.
48
+ * @param sinceSeq - only count messages after this sequence.
49
+ * @param settings - resolved settings.
50
+ * @returns the joined text and whether it was trimmed.
51
+ */
52
+
53
+ export function collectAssistant(ctx, sessionId, sinceSeq, settings) {
54
+ const session = ctx.get('sessions')?.get?.(sessionId)
55
+ if (session === undefined || session === null) return { text: '', truncated: false }
56
+ const texts = []
57
+ for (const event of session.snapshotEvents()) {
58
+ if (Number(event.seq) <= sinceSeq) continue
59
+ if (event.type !== 'assistant/message') continue
60
+ const blocks = Array.isArray(event.data?.message?.content) ? event.data.message.content : []
61
+ const value = blocks
62
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
63
+ .map((block) => block.text)
64
+ .join('\n')
65
+ .trim()
66
+ if (value !== '') texts.push(value)
67
+ }
68
+ const joined = texts.join('\n\n')
69
+ return {
70
+ text: joined.length > settings.maxResultChars ? joined.slice(-settings.maxResultChars) : joined,
71
+ truncated: joined.length > settings.maxResultChars,
72
+ }
73
+ }
74
+
75
+
76
+ /** Deepest cross-tool delegation currently in flight, or 0 when no chain is open. */
77
+
78
+ export function firstDeliveredMessageSeq(ctx, sessionId, sinceSeq) {
79
+ const session = ctx.get('sessions')?.get?.(sessionId)
80
+ if (session === undefined || session === null) return sinceSeq
81
+ for (const event of session.snapshotEvents()) {
82
+ if (Number(event.seq) <= sinceSeq) continue
83
+ if (event.type !== 'user/message') continue
84
+ return Number(event.seq)
85
+ }
86
+ return sinceSeq
87
+ }
88
+
89
+ /**
90
+ * Start the bridge.
91
+ * @param ctx - Host context.
92
+ * @param config - row configuration from the bundle patch.
93
+ */
94
+
95
+ export function parseModelSpec(ctx, spec) {
96
+ const raw = String(spec).trim()
97
+ if (raw === '') throw new Error('model must not be empty')
98
+ if (raw.startsWith('/') || raw.endsWith('/') || raw.includes('//')) {
99
+ throw new Error(`model must look like "provider/model", got ${JSON.stringify(spec)}`)
100
+ }
101
+ const slash = raw.indexOf('/')
102
+ if (slash > 0) {
103
+ const provider = raw.slice(0, slash).trim()
104
+ const model = raw.slice(slash + 1).trim()
105
+ if (provider === '' || model === '') throw new Error(`model must look like "provider/model", got ${JSON.stringify(spec)}`)
106
+ return { provider, model }
107
+ }
108
+ const current = ctx.get('agentDefaultModel')?.currentSelection?.()
109
+ const provider = typeof current?.provider === 'string' ? current.provider : ''
110
+ if (provider === '') throw new Error('model without a provider needs a default selection; pass "provider/model"')
111
+ return { provider, model: raw }
112
+ }
113
+
114
+ /**
115
+ * Install a model selection on one session before its next turn.
116
+ * `sessionController.selectModel` validates the pair and applies it to the next request.
117
+ * @param ctx - Host context.
118
+ * @param sessionId - session to switch.
119
+ * @param spec - `provider/model` or a bare model id.
120
+ * @param reasoningEffort - optional effort level.
121
+ * @returns {{ provider: string, model: string, reasoningEffort?: string } | undefined} applied selection.
122
+ */
123
+
124
+ export async function applyModelSpec(ctx, sessionId, spec, reasoningEffort) {
125
+ if (typeof spec !== 'string' || spec.trim() === '') return undefined
126
+ const service = ctx.get('sessionController')
127
+ if (service === undefined || service === null) throw new Error('session controller is unavailable')
128
+ if (typeof service.selectModel !== 'function') throw new Error('this Host does not expose selectModel; update DeepSeek Harness')
129
+ const { provider, model } = parseModelSpec(ctx, spec)
130
+ try {
131
+ const result = await service.selectModel({
132
+ sessionId,
133
+ provider,
134
+ model,
135
+ ...(typeof reasoningEffort === 'string' && reasoningEffort !== '' ? { reasoningEffort } : {}),
136
+ })
137
+ return result?.selected ?? { provider, model, ...(reasoningEffort ? { reasoningEffort } : {}) }
138
+ } catch (error) {
139
+ throw new Error(`cannot switch ${String(sessionId)} to ${provider}/${model}: ${String(error?.message ?? error)}`)
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Translate the `workspace` argument into attach options.
145
+ * - absent / `false` → never create, only reuse an existing project for the directory
146
+ * - `true` → create a project titled after the directory when none owns it
147
+ * - a string → same, with that title
148
+ * @param workspace - raw argument from the tool call.
149
+ * @returns options for {@link attachToWorkspace}.
150
+ */
151
+
152
+ export function workspaceOptions(workspace) {
153
+ if (workspace === true) return { create: true }
154
+ if (typeof workspace === 'string' && workspace.trim() !== '') return { create: true, title: workspace.trim() }
155
+ return {}
156
+ }
157
+
158
+ /**
159
+ * Place a freshly created session in the workspace its directory belongs to.
160
+ * `sessionController.create` only records a working directory; the sidebar groups
161
+ * live in a separate registry, so without this step a worker shows up as ungrouped.
162
+ * @param ctx - Host context.
163
+ * @param sessionId - session just created.
164
+ * @param cwd - directory the session was opened in.
165
+ */
166
+
167
+ export async function attachToWorkspace(ctx, sessionId, cwd, options = {}) {
168
+ if (typeof cwd !== 'string' || cwd === '') return undefined
169
+ try {
170
+ const registry = ctx.get('workspaceRegistry')
171
+ if (registry === undefined || registry === null) return undefined
172
+
173
+ /* Only create when the caller asked for it: an existing project is reused either way,
174
+ * because `create` returns the record that already owns the canonical path. */
175
+ let workspace
176
+ if (options.create === true && typeof registry.create === 'function') {
177
+ /* The registry rejects a directory that does not exist yet, and a brand-new project
178
+ * is exactly that, so the path is materialised first. */
179
+ await mkdir(cwd, { recursive: true }).catch(() => {})
180
+ const title = typeof options.title === 'string' && options.title.trim() !== '' ? options.title.trim() : undefined
181
+ workspace = await registry.create(cwd, title)
182
+ } else {
183
+ workspace = await registry.resolveByPath?.(cwd)
184
+ }
185
+
186
+ await workspace?.attachSession?.(sessionId)
187
+ return workspace === undefined || workspace === null
188
+ ? undefined
189
+ : { id: workspace.id, title: workspace.title, path: workspace.path }
190
+ } catch {
191
+ /* Grouping is cosmetic: a failure here must not fail the dispatch. */
192
+ return undefined
193
+ }
194
+ }
195
+
196
+
197
+ /**
198
+ * Restore the worker registry written by a previous process.
199
+ * @param settings - resolved settings.
200
+ * @param state - worker bookkeeping to fill.
201
+ */
202
+
203
+ export function finalAnswer(ctx, sessionId, sinceSeq, settings) {
204
+ const session = ctx.get('sessions')?.get?.(sessionId)
205
+ if (session === undefined || session === null) return { text: '', truncated: false }
206
+ let last = ''
207
+ for (const event of session.snapshotEvents()) {
208
+ if (Number(event.seq) <= sinceSeq) continue
209
+ if (event.type !== 'assistant/message') continue
210
+ const blocks = Array.isArray(event.data?.message?.content) ? event.data.message.content : []
211
+ const value = blocks
212
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
213
+ .map((block) => block.text)
214
+ .join('\n')
215
+ .trim()
216
+ if (value !== '') last = value
217
+ }
218
+ if (last.length <= settings.maxResultChars) return { text: last, truncated: false }
219
+ /* Keep the head: a conclusion states its verdict first. */
220
+ return { text: `${last.slice(0, settings.maxResultChars)}\n\n…`, truncated: true }
221
+ }
222
+
223
+ /**
224
+ * Describe one worker the way `wait_agent` does: a string state, or its final answer.
225
+ * @param ctx - Host context.
226
+ * @param sessionId - worker session.
227
+ * @param state - dispatch bookkeeping.
228
+ * @param settings - resolved settings.
229
+ * @returns 'not_found' | 'running' | 'pending' | { completed } | { errored }.
230
+ */
231
+
232
+ export function workerStatus(ctx, sessionId, state, settings) {
233
+ if (!state.dispatched.has(sessionId)) return 'not_found'
234
+ let record
235
+ for (const candidate of state.workers.values()) {
236
+ if (candidate.sessionId === sessionId) record = candidate
237
+ }
238
+ if (record?.status === 'closed') return 'closed'
239
+ const session = ctx.get('sessions')?.get?.(sessionId)
240
+ if (session === undefined || session === null) {
241
+ /* After a restart the session is cold; a cached terminal outcome is still valid. */
242
+ if (record?.outcome !== undefined) return record.outcome
243
+ return 'offline'
244
+ }
245
+ if (ctx.get('agents')?.get?.(sessionId)?.status === 'running') return 'running'
246
+ const since = Number(state.baseline.get(sessionId) ?? 0)
247
+ const end = lastTurnEnd(session, since)
248
+ if (end === undefined) return 'pending'
249
+ const reason = end.reason?.kind ?? 'unknown'
250
+ if (reason !== 'completed') return { errored: String(reason) }
251
+ const answer = finalAnswer(ctx, sessionId, since, settings)
252
+ return { completed: answer.text }
253
+ }
254
+
255
+ /**
256
+ * Latest `turn/end` after a sequence, with its reason.
257
+ * @param session - live session.
258
+ * @param sinceSeq - only consider turns that ended after this event.
259
+ * @returns the payload, or undefined when the turn has not ended yet.
260
+ */
261
+
262
+ export function lastTurnEnd(session, sinceSeq) {
263
+ let found
264
+ for (const event of session.snapshotEvents()) {
265
+ if (Number(event.seq) <= sinceSeq) continue
266
+ if (event.type === 'turn/end') found = event.data
267
+ }
268
+ return found
269
+ }
270
+
271
+ /**
272
+ * Read one session snapshot without waiting.
273
+ * @param ctx - Host context.
274
+ * @param sessionId - session to read.
275
+ * @param sinceSeq - only count assistant messages after this sequence.
276
+ * @param settings - resolved settings.
277
+ * @returns text, run state, and the session head sequence.
278
+ */
279
+
280
+ export function readOnce(ctx, sessionId, sinceSeq, settings) {
281
+ const session = ctx.get('sessions')?.get?.(sessionId)
282
+ if (session === undefined || session === null) throw new Error(`session ${sessionId} is not live`)
283
+ const collected = collectAssistant(ctx, sessionId, sinceSeq, settings)
284
+ return {
285
+ sessionId,
286
+ running: ctx.get('agents')?.get?.(sessionId)?.status === 'running',
287
+ lastSeq: Number(session.seq ?? 0),
288
+ truncated: collected.truncated,
289
+ text: collected.text,
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Resolve the directory Codex should run in.
295
+ * @param ctx - Host context.
296
+ * @param requested - explicit `cwd` argument.
297
+ * @param exec - tool run context.
298
+ * @returns the absolute directory.
299
+ */
300
+
301
+ export async function resolveCwd(ctx, requested, exec) {
302
+ if (typeof requested === 'string' && requested !== '') return requested
303
+ const session = exec?.agent === undefined ? undefined : ctx.get('sessions')?.get?.(exec.agent.id)
304
+ const working = ctx.get('workingDirectory')
305
+ if (session !== undefined && session !== null && working !== undefined) {
306
+ try {
307
+ return working.get(session) ?? process.cwd()
308
+ } catch {
309
+ /* Fall through to the process directory. */
310
+ }
311
+ }
312
+ return process.cwd()
313
+ }
314
+
315
+ /**
316
+ * Run one `codex exec` and collect its answer.
317
+ * @param options - binary, prompt, directory, sandbox, timeout, and cancellation.
318
+ * @returns `{ ok, text, threadId, exitCode, durationMs, error? }`.
319
+ */
320
+
321
+ export function safeTitle(service, session) {
322
+ try {
323
+ const snapshot = service?.get?.(session)
324
+ return typeof snapshot?.title === 'string' && snapshot.title !== '' ? snapshot.title : undefined
325
+ } catch {
326
+ return undefined
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Accept a request only when it carries the capability token in the path or as a bearer.
332
+ * @param request - node request.
333
+ * @param url - parsed request URL.
334
+ * @param token - expected token.
335
+ * @returns whether the request may proceed.
336
+ */