@zhang6hk/dsh-codex 0.0.0-stage → 0.1.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/LICENSE +201 -0
- package/NOTICE +18 -0
- package/README.md +75 -2
- package/README.zh.md +78 -0
- package/cordis.patch.yml +7 -0
- package/lib/args.js +118 -0
- package/lib/codex-path.js +31 -0
- package/lib/commands.js +242 -0
- package/lib/companion.js +177 -0
- package/lib/index.js +105 -0
- package/lib/render.js +114 -0
- package/lib/tool.js +103 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +49 -4
- package/vendor/codex/.claude-plugin/plugin.json +8 -0
- package/vendor/codex/prompts/adversarial-review.md +84 -0
- package/vendor/codex/prompts/stop-review-gate.md +36 -0
- package/vendor/codex/schemas/review-output.schema.json +87 -0
- package/vendor/codex/scripts/app-server-broker.mjs +252 -0
- package/vendor/codex/scripts/codex-companion.mjs +1073 -0
- package/vendor/codex/scripts/lib/app-server-protocol.d.ts +75 -0
- package/vendor/codex/scripts/lib/app-server.mjs +354 -0
- package/vendor/codex/scripts/lib/args.mjs +128 -0
- package/vendor/codex/scripts/lib/broker-endpoint.mjs +41 -0
- package/vendor/codex/scripts/lib/broker-lifecycle.mjs +209 -0
- package/vendor/codex/scripts/lib/claude-session-transfer.mjs +44 -0
- package/vendor/codex/scripts/lib/codex.mjs +1219 -0
- package/vendor/codex/scripts/lib/fs.mjs +40 -0
- package/vendor/codex/scripts/lib/git.mjs +347 -0
- package/vendor/codex/scripts/lib/job-control.mjs +308 -0
- package/vendor/codex/scripts/lib/process.mjs +135 -0
- package/vendor/codex/scripts/lib/prompts.mjs +13 -0
- package/vendor/codex/scripts/lib/render.mjs +465 -0
- package/vendor/codex/scripts/lib/state.mjs +191 -0
- package/vendor/codex/scripts/lib/tracked-jobs.mjs +204 -0
- package/vendor/codex/scripts/lib/workspace.mjs +9 -0
package/lib/commands.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { buildReviewArgv, buildTaskArgv } from './args.js'
|
|
2
|
+
import { renderBackground, renderRun, renderSetup, tail } from './render.js'
|
|
3
|
+
|
|
4
|
+
const RESUME_FLAG = /(^|\s)--(?:resume|resume-last|fresh)(?=\s|$)/u
|
|
5
|
+
const FOLLOW_UP = /(继续|接着|再往下|接着改|深入|再查|keep going|continue|resume|dig deeper|apply the top fix)/iu
|
|
6
|
+
const DEFAULT_LAUNCH_TIMEOUT_MS = 120_000
|
|
7
|
+
const RESUME_LOOKUP_TIMEOUT_MS = 15_000
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pull `--wait` / `--background` out of the raw input: they choose the
|
|
11
|
+
* transport, so the engine must never see them.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} rawInput
|
|
14
|
+
* @returns {{wait: boolean, rest: string}}
|
|
15
|
+
*/
|
|
16
|
+
export function extractMode(rawInput) {
|
|
17
|
+
const text = String(rawInput ?? '')
|
|
18
|
+
const has = (flag) => new RegExp(`(^|\\s)--${flag}(?=\\s|$)`, 'u').test(text)
|
|
19
|
+
const wait = has('wait')
|
|
20
|
+
const rest = text
|
|
21
|
+
.replace(/(^|\s)--(?:wait|background)(?=\s|$)/gu, ' ')
|
|
22
|
+
.replace(/\s+/gu, ' ')
|
|
23
|
+
.trim()
|
|
24
|
+
return { wait: wait || false, rest }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {import('@deepseek-ai/dsh-commands').CommandInvocation} invocation
|
|
29
|
+
* @returns {string | undefined}
|
|
30
|
+
*/
|
|
31
|
+
function sessionIdOf(invocation) {
|
|
32
|
+
const agent = invocation?.agent
|
|
33
|
+
const id = agent?.session?.id ?? agent?.id
|
|
34
|
+
return id === undefined ? undefined : String(id)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the seven slash commands.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} deps
|
|
41
|
+
* @param {(argv: string[], options: {cwd: string, sessionId?: string, signal?: AbortSignal, timeoutMs?: number}) => Promise<import('./companion.js').CompanionResult>} deps.run
|
|
42
|
+
* @param {(invocation: unknown) => string} deps.resolveCwd
|
|
43
|
+
* @param {Record<string, any>} deps.settings
|
|
44
|
+
* @returns {import('@deepseek-ai/dsh-commands').CommandDefinition[]}
|
|
45
|
+
*/
|
|
46
|
+
export function createCommandDefinitions(deps) {
|
|
47
|
+
const { run, resolveCwd, settings } = deps
|
|
48
|
+
const maxConcurrent = settings.maxConcurrent
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {object} options
|
|
52
|
+
* @param {string[]} options.argv
|
|
53
|
+
* @param {string} options.label
|
|
54
|
+
* @param {string | undefined} options.sessionId
|
|
55
|
+
* @param {string} options.cwd
|
|
56
|
+
* @param {AbortSignal | undefined} options.signal
|
|
57
|
+
* @param {boolean} options.background
|
|
58
|
+
*/
|
|
59
|
+
async function launch({ argv, label, sessionId, cwd, signal, background }) {
|
|
60
|
+
if (!background) {
|
|
61
|
+
return renderRun(await run(argv, { cwd, sessionId, signal, timeoutMs: settings.timeoutMs }), label)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const running = await countRunning({ cwd, sessionId, signal })
|
|
65
|
+
if (running >= maxConcurrent) {
|
|
66
|
+
return {
|
|
67
|
+
kind: 'error',
|
|
68
|
+
text: `已有 ${running} 个 Codex 任务在运行(上限 ${maxConcurrent})。等它们结束,或用 \`/codex-cancel <id>\` 取消一个。`,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const result = await run([...argv, '--background', '--json'], {
|
|
73
|
+
cwd,
|
|
74
|
+
sessionId,
|
|
75
|
+
signal,
|
|
76
|
+
timeoutMs: DEFAULT_LAUNCH_TIMEOUT_MS,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
if (result.code !== 0) {
|
|
80
|
+
return renderRun(result, label)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const payload = JSON.parse(result.stdout)
|
|
85
|
+
if (typeof payload?.jobId === 'string' && payload.jobId.length > 0) {
|
|
86
|
+
return { kind: 'success', text: renderBackground({ jobId: payload.jobId, title: payload.title }, label) }
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// fall through to the raw output below
|
|
90
|
+
}
|
|
91
|
+
return { kind: 'success', text: tail(result.stdout) || `${label}已启动。` }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function countRunning({ cwd, sessionId, signal }) {
|
|
95
|
+
const result = await run(['status', '--json', '--timeout-ms', '1'], {
|
|
96
|
+
cwd,
|
|
97
|
+
sessionId,
|
|
98
|
+
signal,
|
|
99
|
+
timeoutMs: RESUME_LOOKUP_TIMEOUT_MS,
|
|
100
|
+
})
|
|
101
|
+
if (result.code !== 0) {
|
|
102
|
+
return 0
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const report = JSON.parse(result.stdout)
|
|
106
|
+
return Array.isArray(report?.running) ? report.running.length : 0
|
|
107
|
+
} catch {
|
|
108
|
+
return 0
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function resumeAvailable({ cwd, sessionId, signal }) {
|
|
113
|
+
const result = await run(['task-resume-candidate', '--json'], {
|
|
114
|
+
cwd,
|
|
115
|
+
sessionId,
|
|
116
|
+
signal,
|
|
117
|
+
timeoutMs: RESUME_LOOKUP_TIMEOUT_MS,
|
|
118
|
+
})
|
|
119
|
+
if (result.code !== 0) {
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(result.stdout)?.available === true
|
|
124
|
+
} catch {
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @type {import('@deepseek-ai/dsh-commands').CommandDefinition[]} */
|
|
130
|
+
return [
|
|
131
|
+
{
|
|
132
|
+
name: 'codex-review',
|
|
133
|
+
description: '用 Codex 审查当前 git 改动(原生 review,不修改代码)',
|
|
134
|
+
input: { hint: '[--wait] [--base <ref>] [--scope auto|working-tree|branch]' },
|
|
135
|
+
async handler(invocation) {
|
|
136
|
+
const { rest } = extractMode(invocation.rawInput)
|
|
137
|
+
const { argv, prompt } = buildReviewArgv('review', rest, settings)
|
|
138
|
+
if (prompt.length > 0) {
|
|
139
|
+
return {
|
|
140
|
+
kind: 'error',
|
|
141
|
+
text: '原生 review 不接受额外审查指令。需要自定义关注点或对抗式审查,请用 `/codex-adversarial-review <关注点>`。',
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// The engine's review path always runs in the foreground; `--background`
|
|
145
|
+
// only exists for task launches, so reviews never take the job branch.
|
|
146
|
+
return launch({ argv, label: 'Codex Review', cwd: resolveCwd(invocation), sessionId: sessionIdOf(invocation), signal: invocation.signal, background: false })
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: 'codex-adversarial-review',
|
|
151
|
+
description: '用 Codex 对抗式审查:质疑实现方案、设计取舍与隐含假设',
|
|
152
|
+
input: { hint: '[--wait] [--base <ref>] [--scope …] [关注点 …]' },
|
|
153
|
+
async handler(invocation) {
|
|
154
|
+
const { rest } = extractMode(invocation.rawInput)
|
|
155
|
+
const { argv, prompt } = buildReviewArgv('adversarial-review', rest, settings)
|
|
156
|
+
if (prompt.length > 0) {
|
|
157
|
+
argv.push(prompt)
|
|
158
|
+
}
|
|
159
|
+
return launch({ argv, label: 'Codex 对抗式审查', cwd: resolveCwd(invocation), sessionId: sessionIdOf(invocation), signal: invocation.signal, background: false })
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: 'codex-rescue',
|
|
164
|
+
description: '把调查、修复或续跑交给 Codex(会话内保持同一线程)',
|
|
165
|
+
input: { hint: '[--wait] [--resume|--fresh] [--model …] [--effort …] <任务描述>' },
|
|
166
|
+
async handler(invocation) {
|
|
167
|
+
const { wait, rest } = extractMode(invocation.rawInput)
|
|
168
|
+
const { argv, prompt } = buildTaskArgv(rest, settings)
|
|
169
|
+
if (prompt.length === 0) {
|
|
170
|
+
return { kind: 'error', text: '请描述要交给 Codex 的任务,例如 `/codex-rescue 修一下登录超时`。' }
|
|
171
|
+
}
|
|
172
|
+
argv.push(prompt)
|
|
173
|
+
|
|
174
|
+
const cwd = resolveCwd(invocation)
|
|
175
|
+
const sessionId = sessionIdOf(invocation)
|
|
176
|
+
let hint = ''
|
|
177
|
+
if (!RESUME_FLAG.test(rest)) {
|
|
178
|
+
const available = await resumeAvailable({ cwd, sessionId, signal: invocation.signal })
|
|
179
|
+
if (available) {
|
|
180
|
+
if (FOLLOW_UP.test(prompt)) {
|
|
181
|
+
argv.splice(1, 1, '--resume')
|
|
182
|
+
hint = '\n\n(检测到同一会话里可续跑的 Codex 线程,已自动续跑;要新开线程加 `--fresh`。)'
|
|
183
|
+
} else {
|
|
184
|
+
hint = '\n\n(同一会话里有可续跑的 Codex 线程,要接着上次的改动加 `--resume`。)'
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const result = await launch({ argv, label: 'Codex 任务', cwd, sessionId, signal: invocation.signal, background: !wait })
|
|
190
|
+
return hint && result.kind === 'success' ? { ...result, text: result.text + hint } : result
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: 'codex-status',
|
|
195
|
+
description: '查看本仓库的 Codex 任务进度',
|
|
196
|
+
input: { hint: '[job-id] [--all]' },
|
|
197
|
+
async handler(invocation) {
|
|
198
|
+
const { rest } = extractMode(invocation.rawInput)
|
|
199
|
+
const extra = rest.length > 0 ? rest.split(/\s+/u) : []
|
|
200
|
+
const argv = ['status', ...extra, '--timeout-ms', String(settings.statusTimeoutMs)]
|
|
201
|
+
return renderRun(await run(argv, { cwd: resolveCwd(invocation), sessionId: sessionIdOf(invocation), signal: invocation.signal, timeoutMs: settings.statusTimeoutMs + 10_000 }), '查询任务状态')
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
name: 'codex-result',
|
|
206
|
+
description: '取出某个 Codex 任务的完整结果',
|
|
207
|
+
input: { hint: '<job-id>' },
|
|
208
|
+
async handler(invocation) {
|
|
209
|
+
const jobId = extractMode(invocation.rawInput).rest.trim()
|
|
210
|
+
if (jobId.length === 0) {
|
|
211
|
+
return { kind: 'error', text: '需要任务 ID:`/codex-result <job-id>`。ID 来自 `/codex-status` 或启动任务时的提示。' }
|
|
212
|
+
}
|
|
213
|
+
return renderRun(await run(['result', jobId], { cwd: resolveCwd(invocation), sessionId: sessionIdOf(invocation), signal: invocation.signal, timeoutMs: 60_000 }), '读取结果')
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: 'codex-cancel',
|
|
218
|
+
description: '取消一个正在运行的 Codex 任务',
|
|
219
|
+
input: { hint: '<job-id>' },
|
|
220
|
+
async handler(invocation) {
|
|
221
|
+
const jobId = extractMode(invocation.rawInput).rest.trim()
|
|
222
|
+
const argv = jobId.length > 0 ? ['cancel', jobId] : ['cancel']
|
|
223
|
+
return renderRun(await run(argv, { cwd: resolveCwd(invocation), sessionId: sessionIdOf(invocation), signal: invocation.signal, timeoutMs: 60_000 }), '取消任务')
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: 'codex-setup',
|
|
228
|
+
description: '检查 Codex CLI、登录状态与运行时',
|
|
229
|
+
async handler(invocation) {
|
|
230
|
+
const result = await run(['setup', '--json'], { cwd: resolveCwd(invocation), sessionId: sessionIdOf(invocation), signal: invocation.signal, timeoutMs: 60_000 })
|
|
231
|
+
if (result.code !== 0) {
|
|
232
|
+
return renderRun(result, '环境检查')
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
return renderSetup(JSON.parse(result.stdout))
|
|
236
|
+
} catch {
|
|
237
|
+
return { kind: 'success', text: tail(result.stdout) }
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
]
|
|
242
|
+
}
|
package/lib/companion.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
/** The vendored upstream engine, shipped inside this package. */
|
|
6
|
+
export const COMPANION_PATH = fileURLToPath(new URL('../vendor/codex/scripts/codex-companion.mjs', import.meta.url))
|
|
7
|
+
|
|
8
|
+
const MAX_CAPTURE = 64 * 1024
|
|
9
|
+
const KILL_GRACE_MS = 2000
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GUI apps on macOS launch with a minimal PATH, which hides Homebrew and
|
|
13
|
+
* npm-global installs. Prepend the usual locations so `codex` resolves.
|
|
14
|
+
*
|
|
15
|
+
* @param {NodeJS.ProcessEnv} env
|
|
16
|
+
* @param {string} home
|
|
17
|
+
* @returns {NodeJS.ProcessEnv}
|
|
18
|
+
*/
|
|
19
|
+
export function augmentPath(env, home = process.env.HOME ?? '') {
|
|
20
|
+
const extra = ['/opt/homebrew/bin', '/usr/local/bin', home ? `${home}/.local/bin` : ''].filter(Boolean)
|
|
21
|
+
const existing = String(env.PATH ?? '').split(':').filter(Boolean)
|
|
22
|
+
/** @type {string[]} */
|
|
23
|
+
const merged = []
|
|
24
|
+
for (const entry of [...extra, ...existing]) {
|
|
25
|
+
if (!merged.includes(entry)) {
|
|
26
|
+
merged.push(entry)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { ...env, PATH: merged.join(':') }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {object} CompanionResult
|
|
34
|
+
* @property {number | 'TIMEOUT' | 'ABORTED' | 'SPAWN_ERROR'} code
|
|
35
|
+
* @property {string} stdout
|
|
36
|
+
* @property {string} stderr
|
|
37
|
+
* @property {string} [error] Present when the process could not be spawned.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {{env: NodeJS.ProcessEnv, dataDir: string, sessionId?: string, binDir?: string | null}} options
|
|
42
|
+
* @returns {NodeJS.ProcessEnv}
|
|
43
|
+
*/
|
|
44
|
+
export function childEnv({ env, dataDir, sessionId, binDir }) {
|
|
45
|
+
const base = augmentPath(env)
|
|
46
|
+
if (binDir) {
|
|
47
|
+
base.PATH = `${binDir}:${base.PATH}`
|
|
48
|
+
}
|
|
49
|
+
base.CLAUDE_PLUGIN_DATA = dataDir
|
|
50
|
+
if (sessionId) {
|
|
51
|
+
base.CODEX_COMPANION_SESSION_ID = sessionId
|
|
52
|
+
}
|
|
53
|
+
return base
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Run the vendored engine once and collect its output.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} options
|
|
60
|
+
* @param {string[]} options.argv Engine arguments, without the script path.
|
|
61
|
+
* @param {string} options.cwd Session working directory.
|
|
62
|
+
* @param {string} options.dataDir Plugin data directory, exported as `CLAUDE_PLUGIN_DATA`.
|
|
63
|
+
* @param {NodeJS.ProcessEnv} [options.env]
|
|
64
|
+
* @param {string} [options.companionPath]
|
|
65
|
+
* @param {string} [options.nodePath]
|
|
66
|
+
* @param {number} [options.timeoutMs]
|
|
67
|
+
* @param {AbortSignal} [options.signal]
|
|
68
|
+
* @param {string} [options.sessionId] Exported as `CODEX_COMPANION_SESSION_ID` so the engine scopes threads per session.
|
|
69
|
+
* @param {string | null} [options.binDir] Directory prepended to PATH (see `prepareCodexBinDir`).
|
|
70
|
+
* @returns {Promise<CompanionResult>}
|
|
71
|
+
*/
|
|
72
|
+
export function runCompanion(options) {
|
|
73
|
+
const {
|
|
74
|
+
argv,
|
|
75
|
+
cwd,
|
|
76
|
+
dataDir,
|
|
77
|
+
env = process.env,
|
|
78
|
+
companionPath = COMPANION_PATH,
|
|
79
|
+
nodePath = process.execPath,
|
|
80
|
+
timeoutMs = 30 * 60 * 1000,
|
|
81
|
+
signal,
|
|
82
|
+
sessionId,
|
|
83
|
+
binDir = null,
|
|
84
|
+
} = options
|
|
85
|
+
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
let stdout = ''
|
|
88
|
+
let stderr = ''
|
|
89
|
+
let settled = false
|
|
90
|
+
let killTimer
|
|
91
|
+
|
|
92
|
+
const child = spawn(nodePath, [companionPath, ...argv], {
|
|
93
|
+
cwd,
|
|
94
|
+
env: childEnv({ env, dataDir, sessionId, binDir }),
|
|
95
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
96
|
+
detached: true,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const killGroup = () => {
|
|
100
|
+
if (child.pid === undefined) {
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
process.kill(-child.pid, 'SIGTERM')
|
|
105
|
+
} catch {
|
|
106
|
+
try {
|
|
107
|
+
child.kill('SIGTERM')
|
|
108
|
+
} catch {
|
|
109
|
+
// already gone
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
killTimer = setTimeout(() => {
|
|
113
|
+
try {
|
|
114
|
+
process.kill(-child.pid, 'SIGKILL')
|
|
115
|
+
} catch {
|
|
116
|
+
// already gone
|
|
117
|
+
}
|
|
118
|
+
}, KILL_GRACE_MS)
|
|
119
|
+
killTimer.unref?.()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @param {number | 'TIMEOUT' | 'ABORTED' | 'SPAWN_ERROR'} code */
|
|
123
|
+
const settle = (code) => {
|
|
124
|
+
if (settled) {
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
settled = true
|
|
128
|
+
clearTimeout(timer)
|
|
129
|
+
if (killTimer !== undefined) {
|
|
130
|
+
clearTimeout(killTimer)
|
|
131
|
+
}
|
|
132
|
+
signal?.removeEventListener('abort', onAbort)
|
|
133
|
+
resolve({ code, stdout, stderr })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const onAbort = () => {
|
|
137
|
+
killGroup()
|
|
138
|
+
settle('ABORTED')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const timer = timeoutMs > 0
|
|
142
|
+
? setTimeout(() => {
|
|
143
|
+
killGroup()
|
|
144
|
+
settle('TIMEOUT')
|
|
145
|
+
}, timeoutMs)
|
|
146
|
+
: undefined
|
|
147
|
+
|
|
148
|
+
if (signal?.aborted) {
|
|
149
|
+
onAbort()
|
|
150
|
+
} else {
|
|
151
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
child.stdout?.setEncoding('utf8')
|
|
155
|
+
child.stdout?.on('data', (chunk) => {
|
|
156
|
+
stdout += chunk
|
|
157
|
+
if (stdout.length > MAX_CAPTURE) {
|
|
158
|
+
stdout = stdout.slice(-MAX_CAPTURE)
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
child.stderr?.setEncoding('utf8')
|
|
162
|
+
child.stderr?.on('data', (chunk) => {
|
|
163
|
+
stderr += chunk
|
|
164
|
+
if (stderr.length > MAX_CAPTURE) {
|
|
165
|
+
stderr = stderr.slice(-MAX_CAPTURE)
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
child.on('error', (error) => {
|
|
170
|
+
stderr += String(error?.message ?? error)
|
|
171
|
+
settle('SPAWN_ERROR')
|
|
172
|
+
})
|
|
173
|
+
child.on('close', (code) => {
|
|
174
|
+
settle(code ?? 0)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import process from 'node:process'
|
|
5
|
+
|
|
6
|
+
import { createCommandDefinitions } from './commands.js'
|
|
7
|
+
import { prepareCodexBinDir } from './codex-path.js'
|
|
8
|
+
import { runCompanion } from './companion.js'
|
|
9
|
+
import { createCodexTaskTool } from './tool.js'
|
|
10
|
+
|
|
11
|
+
export const name = 'dsh-codex'
|
|
12
|
+
export const inject = ['commands', 'tools', 'workingDirectory']
|
|
13
|
+
|
|
14
|
+
const DEFAULTS = {
|
|
15
|
+
codexPath: 'codex',
|
|
16
|
+
model: '',
|
|
17
|
+
effort: '',
|
|
18
|
+
defaultBase: '',
|
|
19
|
+
maxConcurrent: 2,
|
|
20
|
+
timeoutMs: 30 * 60 * 1000,
|
|
21
|
+
statusTimeoutMs: 15_000,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {Record<string, unknown>} [config]
|
|
26
|
+
* @returns {typeof DEFAULTS}
|
|
27
|
+
*/
|
|
28
|
+
export function normalizeSettings(config = {}) {
|
|
29
|
+
const text = (value, fallback) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : fallback)
|
|
30
|
+
const positive = (value, fallback) => (Number.isFinite(value) && value > 0 ? Math.floor(Number(value)) : fallback)
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
codexPath: text(config.codexPath, DEFAULTS.codexPath),
|
|
34
|
+
model: text(config.model, DEFAULTS.model),
|
|
35
|
+
effort: text(config.effort, DEFAULTS.effort),
|
|
36
|
+
defaultBase: text(config.defaultBase, DEFAULTS.defaultBase),
|
|
37
|
+
maxConcurrent: positive(config.maxConcurrent, DEFAULTS.maxConcurrent),
|
|
38
|
+
timeoutMs: positive(config.timeoutMs, DEFAULTS.timeoutMs),
|
|
39
|
+
statusTimeoutMs: positive(config.statusTimeoutMs, DEFAULTS.statusTimeoutMs),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @returns {string} Plugin-owned data directory (also the engine's state root).
|
|
45
|
+
*/
|
|
46
|
+
export function resolveDataDir() {
|
|
47
|
+
const home = process.env.DSH_HOME && process.env.DSH_HOME.trim().length > 0
|
|
48
|
+
? process.env.DSH_HOME.trim()
|
|
49
|
+
: path.join(os.homedir(), '.dsh')
|
|
50
|
+
const dir = path.join(home, 'plugin-data', 'dsh-codex')
|
|
51
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
52
|
+
return dir
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
57
|
+
* @param {Record<string, unknown>} [config]
|
|
58
|
+
*/
|
|
59
|
+
export function apply(ctx, config = {}) {
|
|
60
|
+
const settings = normalizeSettings(config)
|
|
61
|
+
const dataDir = resolveDataDir()
|
|
62
|
+
const binDir = prepareCodexBinDir({ codexPath: settings.codexPath, dataDir })
|
|
63
|
+
|
|
64
|
+
const run = (argv, options = {}) => runCompanion({
|
|
65
|
+
argv,
|
|
66
|
+
cwd: options.cwd,
|
|
67
|
+
dataDir,
|
|
68
|
+
sessionId: options.sessionId,
|
|
69
|
+
signal: options.signal,
|
|
70
|
+
timeoutMs: options.timeoutMs ?? settings.timeoutMs,
|
|
71
|
+
binDir,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const resolveCwd = (holder) => {
|
|
75
|
+
try {
|
|
76
|
+
const session = holder?.agent?.session
|
|
77
|
+
const directory = ctx.workingDirectory.get(session)
|
|
78
|
+
if (typeof directory === 'string' && directory.length > 0) {
|
|
79
|
+
return directory
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// fall through to the host's launch directory
|
|
83
|
+
}
|
|
84
|
+
return process.cwd()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
ctx.effect(function* registerCodexSurface() {
|
|
88
|
+
const disposers = []
|
|
89
|
+
|
|
90
|
+
for (const definition of createCommandDefinitions({ run, resolveCwd, settings })) {
|
|
91
|
+
disposers.push(ctx.commands.register(definition))
|
|
92
|
+
}
|
|
93
|
+
disposers.push(ctx.tools.register(createCodexTaskTool({ run, resolveCwd, settings })))
|
|
94
|
+
|
|
95
|
+
yield () => {
|
|
96
|
+
for (const dispose of disposers.reverse()) {
|
|
97
|
+
try {
|
|
98
|
+
dispose()
|
|
99
|
+
} catch {
|
|
100
|
+
// already gone
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
}
|
package/lib/render.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-facing text for command results. Kept apart from the command wiring so
|
|
3
|
+
* the wording can be reviewed (and translated) without touching behavior.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const TAIL_LIMIT = 4000
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} text
|
|
10
|
+
* @param {number} [limit]
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
export function tail(text, limit = TAIL_LIMIT) {
|
|
14
|
+
const value = String(text ?? '').trim()
|
|
15
|
+
if (value.length <= limit) {
|
|
16
|
+
return value
|
|
17
|
+
}
|
|
18
|
+
return `…${value.slice(-limit)}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {{jobId: string, title?: string}} payload
|
|
23
|
+
* @param {string} label Human name for the work, e.g. `Codex Review`.
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
export function renderBackground(payload, label) {
|
|
27
|
+
const title = payload.title ? `${label}(${payload.title})` : label
|
|
28
|
+
return [
|
|
29
|
+
`${title}已在后台启动,不会阻塞当前会话。`,
|
|
30
|
+
'',
|
|
31
|
+
`- 任务 ID:${payload.jobId}`,
|
|
32
|
+
`- 查看进度:/codex-status ${payload.jobId}`,
|
|
33
|
+
`- 查看结果:/codex-result ${payload.jobId}`,
|
|
34
|
+
`- 取消任务:/codex-cancel ${payload.jobId}`,
|
|
35
|
+
].join('\n')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} text
|
|
40
|
+
* @returns {string | null} A concrete next step when the engine failed.
|
|
41
|
+
*/
|
|
42
|
+
function hintFor(text) {
|
|
43
|
+
if (/not a git repository|fatal: not a git/iu.test(text)) {
|
|
44
|
+
return '当前目录不是 git 仓库。`/codex-review` 只能审查 git 工作区,请先 `git init` 或在仓库内调用。'
|
|
45
|
+
}
|
|
46
|
+
if (/codex(?:-cli)?[^\n]*(?:not found|ENOENT)|spawn codex ENOENT/iu.test(text)) {
|
|
47
|
+
return '找不到 `codex` 可执行文件。安装:`npm install -g @openai/codex`;若装在非标准位置,在 `cordis.patch.yml` 里把 `codexPath` 指向它的绝对路径。'
|
|
48
|
+
}
|
|
49
|
+
if (/not logged in|login|unauthorized|401/iu.test(text)) {
|
|
50
|
+
return 'Codex 未登录。先在终端运行 `codex login`,再执行 `/codex-setup` 确认状态。'
|
|
51
|
+
}
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Normalize one engine run into a command result.
|
|
57
|
+
*
|
|
58
|
+
* @param {{code: number | string, stdout: string, stderr: string}} result
|
|
59
|
+
* @param {string} what Human name of the failed operation.
|
|
60
|
+
* @returns {{kind: 'success' | 'error', text: string}}
|
|
61
|
+
*/
|
|
62
|
+
export function renderRun(result, what) {
|
|
63
|
+
if (result.code === 0) {
|
|
64
|
+
return { kind: 'success', text: result.stdout.trim() || `${what}已完成。` }
|
|
65
|
+
}
|
|
66
|
+
if (result.code === 'TIMEOUT') {
|
|
67
|
+
return { kind: 'error', text: `${what}超时。Codex 可能仍在运行,用 \`/codex-status\` 查看任务,或调大 \`timeoutMs\`。` }
|
|
68
|
+
}
|
|
69
|
+
if (result.code === 'ABORTED') {
|
|
70
|
+
return { kind: 'error', text: `${what}已取消。` }
|
|
71
|
+
}
|
|
72
|
+
if (result.code === 'SPAWN_ERROR') {
|
|
73
|
+
return { kind: 'error', text: `${what}无法启动:${tail(result.stderr) || '未能启动 Node 子进程。'}` }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const combined = [result.stderr, result.stdout].filter(Boolean).join('\n')
|
|
77
|
+
const hint = hintFor(combined)
|
|
78
|
+
const detail = tail(combined) || `退出码 ${result.code}`
|
|
79
|
+
return { kind: 'error', text: hint ? `${hint}\n\n${detail}` : `${what}失败:\n${detail}` }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {Record<string, any>} report Parsed `setup --json` payload.
|
|
84
|
+
* @returns {{kind: 'success' | 'error', text: string}}
|
|
85
|
+
*/
|
|
86
|
+
export function renderSetup(report) {
|
|
87
|
+
const line = (label, entry) => {
|
|
88
|
+
const ok = entry?.available !== false
|
|
89
|
+
const detail = entry?.detail ? `:${entry.detail}` : ''
|
|
90
|
+
return `- ${label}:${ok ? '正常' : '不可用'}${detail}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const auth = report?.auth
|
|
94
|
+
const authLine = auth
|
|
95
|
+
? `- 登录:${auth.loggedIn ? `已登录${auth.detail ? `(${auth.detail})` : ''}` : '未登录'}`
|
|
96
|
+
: '- 登录:未知'
|
|
97
|
+
|
|
98
|
+
const runtime = report?.sessionRuntime
|
|
99
|
+
const lines = [
|
|
100
|
+
`Codex 环境检查:${report?.ready ? '就绪' : '未就绪'}`,
|
|
101
|
+
'',
|
|
102
|
+
line('Node', report?.node),
|
|
103
|
+
line('npm', report?.npm),
|
|
104
|
+
line('Codex', report?.codex),
|
|
105
|
+
authLine,
|
|
106
|
+
runtime ? `- 会话运行时:${runtime.label ?? runtime.mode}${runtime.detail ? `(${runtime.detail})` : ''}` : '',
|
|
107
|
+
].filter(Boolean)
|
|
108
|
+
|
|
109
|
+
if (!report?.ready) {
|
|
110
|
+
lines.push('', '修复建议:安装 Codex(`npm install -g @openai/codex`)并运行 `codex login`。')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { kind: report?.ready ? 'success' : 'error', text: lines.join('\n') }
|
|
114
|
+
}
|