@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.
Files changed (37) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +18 -0
  3. package/README.md +75 -2
  4. package/README.zh.md +78 -0
  5. package/cordis.patch.yml +7 -0
  6. package/lib/args.js +118 -0
  7. package/lib/codex-path.js +31 -0
  8. package/lib/commands.js +242 -0
  9. package/lib/companion.js +177 -0
  10. package/lib/index.js +105 -0
  11. package/lib/render.js +114 -0
  12. package/lib/tool.js +103 -0
  13. package/locale/en.json +6 -0
  14. package/locale/zh.json +6 -0
  15. package/package.json +49 -4
  16. package/vendor/codex/.claude-plugin/plugin.json +8 -0
  17. package/vendor/codex/prompts/adversarial-review.md +84 -0
  18. package/vendor/codex/prompts/stop-review-gate.md +36 -0
  19. package/vendor/codex/schemas/review-output.schema.json +87 -0
  20. package/vendor/codex/scripts/app-server-broker.mjs +252 -0
  21. package/vendor/codex/scripts/codex-companion.mjs +1073 -0
  22. package/vendor/codex/scripts/lib/app-server-protocol.d.ts +75 -0
  23. package/vendor/codex/scripts/lib/app-server.mjs +354 -0
  24. package/vendor/codex/scripts/lib/args.mjs +128 -0
  25. package/vendor/codex/scripts/lib/broker-endpoint.mjs +41 -0
  26. package/vendor/codex/scripts/lib/broker-lifecycle.mjs +209 -0
  27. package/vendor/codex/scripts/lib/claude-session-transfer.mjs +44 -0
  28. package/vendor/codex/scripts/lib/codex.mjs +1219 -0
  29. package/vendor/codex/scripts/lib/fs.mjs +40 -0
  30. package/vendor/codex/scripts/lib/git.mjs +347 -0
  31. package/vendor/codex/scripts/lib/job-control.mjs +308 -0
  32. package/vendor/codex/scripts/lib/process.mjs +135 -0
  33. package/vendor/codex/scripts/lib/prompts.mjs +13 -0
  34. package/vendor/codex/scripts/lib/render.mjs +465 -0
  35. package/vendor/codex/scripts/lib/state.mjs +191 -0
  36. package/vendor/codex/scripts/lib/tracked-jobs.mjs +204 -0
  37. package/vendor/codex/scripts/lib/workspace.mjs +9 -0
@@ -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
+ }
@@ -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
+ }