@sakki_chin/dsh-codex-orchestrate 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Atlas AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # @sakki_chin/dsh-codex-orchestrate
2
+
3
+ 把 **Codex** 任务按 YAML schema 编排成 DAG 工作流的 DSH 插件。每个节点是一个独立的 codex session,下游首轮会自动拿到直接依赖节点的终态结果;进度在 DSH **右侧边栏以原生 React tab** 呈现(DAG 连线 + 逐轮 conversation)。
4
+
5
+ - 派发方式:DSH agent 调用 `codex_orchestrate` tool(异步 + 可查进度),或直接调 HTTP API
6
+ - 每个节点独立 codex session,终态后可在工作台里继续对话(`resumeThread`,不重跑下游)
7
+ - 运行中也能改 DAG:`kind: nodePatch` 原子化新增 / 修改 / 删除**尚未执行**的节点
8
+ - 工作流状态 append-only 落盘,进程重启后历史仍可查
9
+
10
+ ## 环境要求
11
+
12
+ | 项 | 要求 |
13
+ |---|---|
14
+ | DSH | Desktop 或 `dsh` CLI |
15
+ | codex CLI | 已安装并 `codex login`(订阅登录)或设置 `OPENAI_API_KEY` |
16
+ | Node | ≥ 20 |
17
+
18
+ 插件**完全复用本机 codex 登录态**(`~/.codex/auth.json`):不代管、不刷新、不转发 token。派发前会做一次只读探测,没有登录态就直接报错,不会烧掉一个必然失败的 turn。
19
+
20
+ ## 安装
21
+
22
+ ```bash
23
+ dsh plugin add --profile web @sakki_chin/dsh-codex-orchestrate
24
+ ```
25
+
26
+ `--profile` 换成你的 profile 名(真源:`~/Library/Application Support/DSH Desktop/profile-selection/state.json`,一般是 `web`)。
27
+
28
+ 安装后**需要完全重启 DSH**:包元数据(含"是不是客户端包"的判定)按 Loader specifier 缓存到进程重启,刷新页面不够。
29
+
30
+ ### 从本地目录安装(开发用)
31
+
32
+ ```bash
33
+ cd <插件源码目录>
34
+ npm install
35
+ npm run build:client
36
+ dsh plugin add --profile web "$PWD"
37
+ ```
38
+
39
+ pnpm 会写成 `link:` 软链,所以之后改源码 → `npm run build:client` → 宿主 HMR(约 500ms 轮询)会原地重挂载,**无需重启或刷新**。
40
+
41
+ ## 打开工作台
42
+
43
+ 右侧边栏 → 点标签条的 **`+`(「新标签页」)**打开引导页 → 选「Codex Orchestrate」。
44
+
45
+ 也可以右键任意 tab,从 tab 菜单里进入。
46
+
47
+ ## 使用
48
+
49
+ ### 由 DSH agent 派发
50
+
51
+ ```
52
+ codex_orchestrate(yaml: "...")
53
+ ```
54
+
55
+ - `kind: workflow` 创建
56
+ - `kind: workflowQuery` 查询(省略 `workflowId` 返回清单)
57
+ - `kind: nodePatch` 对尚未执行的节点做原子化增删改
58
+
59
+ tool 立即返回 `{ workflowId, nodes, progress, progressUrl, instance }`。
60
+
61
+ > **用 `progressUrl`**(绝对地址,可直接 GET),不要用 `progress`(相对路径,需你自己知道 host:port——曾因此把跨实例查询误判成「workflow 不存在」)。`instance.instanceId` 用于确认「我查的是不是派发我的那个实例」。
62
+
63
+ YAML schema 见包内 `schema/workflow.schema.md`(规范说明 + 回传契约),校验真源是 `schema/workflow.schema.json`。
64
+
65
+ ### 调度语义
66
+
67
+ - 节点按 `dependsOn` 调度,无依赖(或依赖已满足)的节点并发执行,受 `concurrency` 上限约束(默认 3)
68
+ - 下游节点首轮 prompt 自动注入所有**直接**依赖节点的终态结果
69
+ - 节点未显式写 `cwd` 时,继承发起 DSH 会话的工作目录
70
+ - 单节点 `timeoutS` 默认 600;`onFailure: fail`(默认)阻塞下游,`continue` 照常调度
71
+
72
+ ### 直接 HTTP
73
+
74
+ ```
75
+ POST /codex-orchestrate/api/dispatch { "yaml": "..." }
76
+ GET /codex-orchestrate/api/workflows
77
+ GET /codex-orchestrate/api/state?workflowId=wf_xxx
78
+ POST /codex-orchestrate/api/message { workflowId, nodeId, text, clientMessageId }
79
+ POST /codex-orchestrate/api/cancel { workflowId, nodeId }
80
+ GET /codex-orchestrate/api/events?workflowId=wf_xxx # SSE
81
+ ```
82
+
83
+ ## 状态持久化
84
+
85
+ workflow 状态 append-only 落盘到 `$DSH_HOME/workflows.jsonl`。
86
+
87
+ - 进程重启后历史仍可查:`/api/state` 未命中内存时自动从磁盘回读
88
+ - 重启前处于 `running`/`queued` 的节点会被照实标成 `cancelled`,而不是永远停在 `running` 骗查询方
89
+ - 文件尾部半行 / 损坏行会被跳过,不影响其余历史
90
+ - 落盘失败(磁盘满 / 权限)只记一条警告,**不打断**正在跑的工作流
91
+
92
+ ## 配置
93
+
94
+ | 环境变量 | 作用 |
95
+ |---|---|
96
+ | `DSH_CODEX_MODEL` | 节点缺省模型(默认 `gpt-5.6-sol`) |
97
+ | `DSH_CODEX_CLI` | 指定 codex CLI 可执行文件路径 |
98
+ | `DSH_CODEX_MODULES` | 额外的 `node_modules` 目录(用于找 `@openai/codex-sdk`、YAML 解析器) |
99
+ | `DSH_CODEX_STATE_DIR` | 覆盖状态落盘目录 |
100
+ | `DSH_CODEX_DEBUG_EVENTS` | 打印原始 codex 事件流 |
101
+ | `CODEX_HOME` | codex 登录态目录(默认 `~/.codex`) |
102
+
103
+ 也可在节点上写 `model:` 覆盖全局缺省。**不要用 `gpt-5-codex`**——该 slug 在 ChatGPT 订阅登录下会被服务端拒绝(`not supported when using Codex with a ChatGPT account`);订阅可用 `gpt-5.6-sol` / `gpt-5.6-terra` / `gpt-5.6-luna` / `gpt-5.5`。
104
+
105
+ ## 排查
106
+
107
+ **`http://127.0.0.1:43120` 返回 403** —— 这是 DSH Desktop 的 Electron 门禁,对所有非渲染器请求一律拒绝,**不是插件故障**。无头脚本穿不透它。
108
+
109
+ **节点报「未找到 @openai/codex-sdk」** —— 设置 `DSH_CODEX_MODULES` 指向含该包的 `node_modules`;或在本插件目录执行 `npm install`。
110
+
111
+ **Tab 菜单里没有「Codex Orchestrate」** —— 依次确认:右侧边栏已展开;包已装进 profile(`dsh plugin ls --profile web`);DSH 已**完全重启**。若右栏当前是引导页且尚无扩展条目,右键不会弹菜单,点 `+` 打开引导页即可看到入口。
112
+
113
+ ## 开发
114
+
115
+ ```bash
116
+ npm install
117
+ npm run build:client # 产出 lib/client.js(lazy-CJS 包装,宿主冻结模块全部 external)
118
+ npm run verify:edges # DAG 连线几何自证:采样每条路径,逐点判定是否覆盖节点卡片
119
+ node server/orchestrator.test.cjs
120
+ node server/plugin.test.cjs
121
+ ```
122
+
123
+ `npm publish` 会通过 `prepublishOnly` 自动构建并跑一遍几何自证。
124
+
125
+ ## 边界
126
+
127
+ - 运行句柄在内存中:进程重启时未完成的节点/轮次标为 `cancelled`,已完成历史从 JSONL 恢复
128
+ - 只呈现**当前进程**派发的工作流
129
+ - `file_change` 事件只有路径与 add/delete/update,无 diff 正文(SDK 限制)
130
+
131
+ ## License
132
+
133
+ MIT
package/client/api.js ADDED
@@ -0,0 +1,50 @@
1
+ const API_ROOT = '/codex-orchestrate/api'
2
+
3
+ async function request(path, options = {}) {
4
+ const response = await fetch(`${API_ROOT}/${path}`, {
5
+ cache: 'no-store',
6
+ headers: {
7
+ accept: 'application/json',
8
+ ...(options.body ? { 'content-type': 'application/json' } : {}),
9
+ ...options.headers,
10
+ },
11
+ ...options,
12
+ })
13
+ const payload = await response.json().catch(() => null)
14
+ if (!response.ok) {
15
+ throw new Error(payload?.error || `请求失败(HTTP ${response.status})`)
16
+ }
17
+ return payload
18
+ }
19
+
20
+ export const orchestrateApi = {
21
+ listWorkflows(signal) {
22
+ return request('workflows', { signal })
23
+ },
24
+ getState(workflowId, signal) {
25
+ return request(`state?workflowId=${encodeURIComponent(workflowId)}`, { signal })
26
+ },
27
+ sendMessage({ workflowId, nodeId, text, clientMessageId }, signal) {
28
+ return request('message', {
29
+ method: 'POST',
30
+ signal,
31
+ body: JSON.stringify({ workflowId, nodeId, text, clientMessageId }),
32
+ })
33
+ },
34
+ cancel({ workflowId, nodeId }, signal) {
35
+ return request('cancel', {
36
+ method: 'POST',
37
+ signal,
38
+ body: JSON.stringify({ workflowId, nodeId }),
39
+ })
40
+ },
41
+ eventsUrl(workflowId) {
42
+ return `${API_ROOT}/events?workflowId=${encodeURIComponent(workflowId)}`
43
+ },
44
+ }
45
+
46
+ export function createClientMessageId() {
47
+ return globalThis.crypto?.randomUUID?.()
48
+ || `msg_${Date.now()}_${Math.random().toString(16).slice(2)}`
49
+ }
50
+
@@ -0,0 +1,179 @@
1
+ import React from 'react'
2
+ import { createClientMessageId, orchestrateApi } from '../api.js'
3
+ import { Icon, Spinner } from './Icons.jsx'
4
+ import { styles } from '../styles.js'
5
+
6
+ function modelLabel(model = '') {
7
+ return model.replace(/^gpt-/i, 'GPT-').replace(/-([a-z])/g, (_, letter) => ` ${letter.toUpperCase()}`)
8
+ }
9
+
10
+ function formatTokens(value) {
11
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`
12
+ if (value >= 1000) return `${(value / 1000).toFixed(value >= 100_000 ? 0 : 1)}K`
13
+ return String(value)
14
+ }
15
+
16
+ function usageFor(node) {
17
+ const turns = node?.turns || []
18
+ const completed = turns.filter(turn => turn.usage)
19
+ const latest = completed.at(-1)?.usage || null
20
+ return completed.reduce((result, turn) => ({
21
+ latest,
22
+ total: result.total + (turn.usage.input_tokens || 0) + (turn.usage.output_tokens || 0),
23
+ input: result.input + (turn.usage.input_tokens || 0),
24
+ cached: result.cached + (turn.usage.cached_input_tokens || 0),
25
+ }), { latest, total: 0, input: 0, cached: 0 })
26
+ }
27
+
28
+ function changedFileCount(node) {
29
+ const paths = new Set(
30
+ (node?.turns || [])
31
+ .flatMap(turn => turn.items || [])
32
+ .filter(item => item.type === 'file_change')
33
+ .flatMap(item => item.changes || [])
34
+ .map(change => change.path)
35
+ .filter(Boolean),
36
+ )
37
+ return paths.size
38
+ }
39
+
40
+ export function Composer({ workflow, node, refresh, toast }) {
41
+ const [draft, setDraft] = React.useState('')
42
+ const [submitting, setSubmitting] = React.useState(false)
43
+ const textareaRef = React.useRef(null)
44
+ const controllerRef = React.useRef(null)
45
+ const active = Boolean(node?.activeTurnId) || node?.status === 'running'
46
+ const ready = Boolean(node?.sessionId) && ['completed', 'failed', 'cancelled'].includes(node?.status)
47
+ const usage = usageFor(node)
48
+ const changeCount = changedFileCount(node)
49
+ const reasoning = ({ minimal: '最小', low: '低', medium: '中', high: '高' })[node?.reasoningEffort] || '中'
50
+ const model = modelLabel(node?.model || 'gpt-5.6-sol')
51
+ const access = ({ 'read-only': '只读', 'workspace-write': '工作区访问', 'danger-full-access': '完全访问' })[node?.sandboxMode] || '完全访问'
52
+ const context = usage.latest?.input_tokens || 0
53
+ const cacheRate = usage.input ? Math.round(usage.cached / usage.input * 100) : 0
54
+
55
+ React.useLayoutEffect(() => {
56
+ const textarea = textareaRef.current
57
+ if (!textarea) return
58
+ textarea.style.height = 'auto'
59
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 180)}px`
60
+ }, [draft])
61
+
62
+ React.useEffect(() => () => controllerRef.current?.abort(), [])
63
+
64
+ let placeholder = '随心输入'
65
+ if (!node) placeholder = '选择一个任务开始对话…'
66
+ else if (active) placeholder = 'Codex 正在回复…'
67
+ else if (!node.sessionId) placeholder = '等待任务创建 Codex session…'
68
+ else if (!ready) placeholder = '任务完成后可继续对话…'
69
+
70
+ const submit = async event => {
71
+ event?.preventDefault()
72
+ if (!workflow || !node) {
73
+ toast('等待工作流派发后才能追加指令')
74
+ return
75
+ }
76
+ controllerRef.current?.abort()
77
+ const controller = new AbortController()
78
+ controllerRef.current = controller
79
+ if (active) {
80
+ try {
81
+ setSubmitting(true)
82
+ await orchestrateApi.cancel({ workflowId: workflow.workflowId, nodeId: node.id }, controller.signal)
83
+ toast('正在停止本轮任务')
84
+ await refresh()
85
+ } catch (error) {
86
+ if (error?.name !== 'AbortError') toast(`停止失败:${error.message}`)
87
+ } finally {
88
+ setSubmitting(false)
89
+ }
90
+ return
91
+ }
92
+
93
+ const text = draft.trim()
94
+ if (!text) return
95
+ if (!node.sessionId) {
96
+ toast('该任务还没有可续接的 Codex session')
97
+ return
98
+ }
99
+ if (!ready) {
100
+ toast('任务完成后才能继续对话')
101
+ return
102
+ }
103
+ try {
104
+ setSubmitting(true)
105
+ await orchestrateApi.sendMessage({
106
+ workflowId: workflow.workflowId,
107
+ nodeId: node.id,
108
+ text,
109
+ clientMessageId: createClientMessageId(),
110
+ }, controller.signal)
111
+ setDraft('')
112
+ await refresh()
113
+ } catch (error) {
114
+ if (error?.name !== 'AbortError') toast(`发送失败:${error.message}`)
115
+ } finally {
116
+ setSubmitting(false)
117
+ }
118
+ }
119
+
120
+ return (
121
+ <footer style={styles.composerWrap}>
122
+ {changeCount > 0 && <div style={styles.changeSummary}>{changeCount} 个文件已更改</div>}
123
+ <form style={styles.composer} onSubmit={submit}>
124
+ <textarea
125
+ ref={textareaRef}
126
+ rows={1}
127
+ maxLength={4000}
128
+ value={draft}
129
+ disabled={submitting || active || !ready}
130
+ placeholder={placeholder}
131
+ aria-label="向当前任务节点发送指令"
132
+ onChange={event => setDraft(event.target.value)}
133
+ onKeyDown={event => {
134
+ if (event.key === 'Enter' && !event.shiftKey) {
135
+ event.preventDefault()
136
+ event.currentTarget.form?.requestSubmit()
137
+ }
138
+ }}
139
+ style={{ ...styles.textarea, opacity: submitting || active || !ready ? 0.68 : 1 }}
140
+ />
141
+ <div style={styles.composerBar}>
142
+ <div style={styles.tools}>
143
+ <button type="button" aria-label="添加上下文" title="添加上下文" onClick={() => toast('当前版本通过文字或任务依赖添加上下文')} style={{ ...styles.buttonReset, ...styles.toolButton, width: 28, padding: 0 }}>
144
+ <Icon kind="plus" size={16} />
145
+ </button>
146
+ <button type="button" aria-label="沙箱访问级别" title="访问级别由 Workflow 节点 sandboxMode 决定" onClick={() => toast('访问级别由 Workflow 节点 sandboxMode 决定')} style={{ ...styles.buttonReset, ...styles.toolButton, color: node?.sandboxMode === 'danger-full-access' ? '#e6732f' : 'var(--co-ink-muted)' }}>
147
+ <Icon kind="shield" size={16} />
148
+ <span>{access}</span>
149
+ </button>
150
+ </div>
151
+ <div style={styles.tools}>
152
+ {active && <Spinner size={16} />}
153
+ <button type="button" aria-label="模型和推理强度" title={`${model} · ${reasoning}`} onClick={() => toast('模型与推理强度由 Workflow 节点配置决定')} style={{ ...styles.buttonReset, ...styles.toolButton }}>
154
+ <span>{model}</span><span style={{ color: 'var(--co-ink-faint)' }}>{reasoning}</span>
155
+ </button>
156
+ <button
157
+ type="submit"
158
+ aria-label={active ? '停止生成' : '发送'}
159
+ disabled={submitting || (!active && (!ready || !draft.trim()))}
160
+ data-action={active ? 'stop' : 'send'}
161
+ style={{ ...styles.buttonReset, ...styles.send, opacity: submitting || (!active && (!ready || !draft.trim())) ? 0.35 : 1, cursor: submitting ? 'not-allowed' : 'pointer' }}
162
+ >
163
+ <Icon kind={active ? 'stop' : 'send'} size={15} style={active ? { fill: 'currentColor', stroke: 'none' } : undefined} />
164
+ </button>
165
+ </div>
166
+ </div>
167
+ </form>
168
+ <div style={styles.runtime} aria-label="当前任务运行信息">
169
+ <span style={{ color: 'var(--co-ink-secondary)', fontFamily: 'inherit' }}>{model} · {reasoning}</span>
170
+ <span title={context ? '最近一轮输入 token,包含该 Codex 会话的历史上下文' : '尚无上下文用量'}>{context ? `上下文 ${formatTokens(context)} tok` : '上下文 —'}</span>
171
+ <span title={usage.total ? `该节点全部轮次输入与输出合计 ${usage.total} token` : '尚无 token 用量'}>{usage.total ? `累计 ${formatTokens(usage.total)} tok` : '累计 —'}</span>
172
+ <span title={usage.input ? `缓存命中 ${usage.cached} / 输入 ${usage.input} token` : '尚无缓存命中数据'}>{usage.cached ? `缓存 ${formatTokens(usage.cached)} · ${cacheRate}%` : '缓存 —'}</span>
173
+ </div>
174
+ </footer>
175
+ )
176
+ }
177
+
178
+ export { changedFileCount, usageFor }
179
+