dsh-plugin-subagent-bridge 0.16.0 → 0.18.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.
- package/README.md +22 -7
- package/README.zh-CN.md +25 -6
- package/index.js +17 -2221
- package/package.json +2 -1
- package/src/api.js +398 -0
- package/src/constants.js +61 -0
- package/src/guards.js +97 -0
- package/src/http.js +358 -0
- package/src/policy.js +21 -0
- package/src/questions.js +130 -0
- package/src/registry.js +155 -0
- package/src/runners/claude.js +177 -0
- package/src/runners/codex.js +185 -0
- package/src/sessions.js +336 -0
- package/src/settings.js +121 -0
- package/src/tools.js +228 -0
- package/src/utils.js +55 -0
|
@@ -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
|
+
*/
|
package/src/sessions.js
ADDED
|
@@ -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
|
+
*/
|