odai-dsh-agent 0.2.56 → 0.2.57

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 (70) hide show
  1. package/README.md +11 -9
  2. package/client/client.js +12 -5
  3. package/package.json +1 -1
  4. package/preset/odai/package.json +2 -1
  5. package/preset/odai/runtime/context-activation.mjs +1 -1
  6. package/preset/odai/runtime/context-activation.mjs.map +1 -1
  7. package/preset/odai/runtime/index.d.mts.map +1 -1
  8. package/preset/odai/runtime/index.mjs +51 -2
  9. package/preset/odai/runtime/index.mjs.map +1 -1
  10. package/preset/odai/runtime/lead-mode.d.mts +23 -0
  11. package/preset/odai/runtime/lead-mode.d.mts.map +1 -0
  12. package/preset/odai/runtime/lead-mode.mjs +17 -0
  13. package/preset/odai/runtime/lead-mode.mjs.map +1 -0
  14. package/preset/odai/runtime/lifecycle-runtime.d.mts.map +1 -1
  15. package/preset/odai/runtime/lifecycle-runtime.mjs +196 -13
  16. package/preset/odai/runtime/lifecycle-runtime.mjs.map +1 -1
  17. package/preset/odai/runtime/responsibility-gap.d.mts +4 -1
  18. package/preset/odai/runtime/responsibility-gap.d.mts.map +1 -1
  19. package/preset/odai/runtime/responsibility-gap.mjs +1 -0
  20. package/preset/odai/runtime/responsibility-gap.mjs.map +1 -1
  21. package/preset/odai/runtime/role-overlays.d.mts +1 -1
  22. package/preset/odai/runtime/role-overlays.d.mts.map +1 -1
  23. package/preset/odai/runtime/role-overlays.mjs +4 -1
  24. package/preset/odai/runtime/role-overlays.mjs.map +1 -1
  25. package/preset/odai/runtime/route-session.d.mts +20 -0
  26. package/preset/odai/runtime/route-session.d.mts.map +1 -0
  27. package/preset/odai/runtime/route-session.mjs +44 -0
  28. package/preset/odai/runtime/route-session.mjs.map +1 -0
  29. package/preset/odai/runtime/router.d.mts +2 -1
  30. package/preset/odai/runtime/router.d.mts.map +1 -1
  31. package/preset/odai/runtime/router.mjs +52 -1
  32. package/preset/odai/runtime/router.mjs.map +1 -1
  33. package/preset/odai/runtime/routing-config.d.mts +5 -2
  34. package/preset/odai/runtime/routing-config.d.mts.map +1 -1
  35. package/preset/odai/runtime/routing-config.mjs +25 -14
  36. package/preset/odai/runtime/routing-config.mjs.map +1 -1
  37. package/preset/odai/runtime/runtime-config.d.mts +1 -1
  38. package/preset/odai/runtime/runtime-config.d.mts.map +1 -1
  39. package/preset/odai/runtime/runtime-support.d.mts +6 -1
  40. package/preset/odai/runtime/runtime-support.d.mts.map +1 -1
  41. package/preset/odai/runtime/runtime-support.mjs +13 -7
  42. package/preset/odai/runtime/runtime-support.mjs.map +1 -1
  43. package/preset/odai/runtime/runtime-types.d.mts +2 -0
  44. package/preset/odai/runtime/runtime-types.d.mts.map +1 -1
  45. package/preset/odai/runtime/runtime-types.mjs.map +1 -1
  46. package/preset/odai/runtime/session-evidence.d.mts.map +1 -1
  47. package/preset/odai/runtime/session-evidence.mjs +6 -0
  48. package/preset/odai/runtime/session-evidence.mjs.map +1 -1
  49. package/preset/odai/runtime/skill-bundle.d.mts +1 -1
  50. package/preset/odai/runtime/skill-bundle.d.mts.map +1 -1
  51. package/preset/odai/runtime/tool-runtime.d.mts.map +1 -1
  52. package/preset/odai/runtime/tool-runtime.mjs +28 -0
  53. package/preset/odai/runtime/tool-runtime.mjs.map +1 -1
  54. package/preset/odai/skills/odai-orchestration/SKILL.md +1 -1
  55. package/preset/odai/skills/odai-orchestration/assets/routing-roles/controller.md +1 -1
  56. package/preset/odai/skills/odai-orchestration/assets/routing-roles/writer.md +9 -0
  57. package/preset/odai/skills/odai-orchestration/manifest.json +10 -4
  58. package/preset/odai/skills/odai-orchestration/references/install.md +66 -4
  59. package/preset/odai/skills/odai-orchestration/references/orchestration.md +2 -1
  60. package/preset/odai/skills/odai-orchestration/scripts/build-routing.mjs +78 -24
  61. package/preset/odai/skills/odai-orchestration/scripts/compose-contracts.d.mts +1 -1
  62. package/preset/odai/skills/odai-orchestration/scripts/compose-contracts.mjs +6 -5
  63. package/preset/odai/skills/odai-orchestration/scripts/install-routing.mjs +66 -15
  64. package/preset/odai/skills/odai-orchestration/scripts/local-cli.d.mts +8 -0
  65. package/preset/odai/skills/odai-orchestration/scripts/local-cli.mjs +25 -9
  66. package/preset/odai/skills/odai-orchestration/scripts/recognize-routing.d.mts +6 -0
  67. package/preset/odai/skills/odai-orchestration/scripts/recognize-routing.mjs +93 -0
  68. package/preset/odai/skills/odai-orchestration/scripts/route-hook.mjs +243 -0
  69. package/preset/odai/skills/odai-orchestration/scripts/run-role.mjs +71 -16
  70. package/preset/odai/skills/odai-orchestration/scripts/verify-routing.mjs +2 -2
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, existsSync } from 'node:fs';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { recognizeNamedRequests, recognizeLeadSwitch, parseLeadVerdict } from './recognize-routing.mjs';
7
+
8
+ const root = path.dirname(fileURLToPath(import.meta.url));
9
+ const manifest = JSON.parse(readFileSync(path.join(root, 'odai-named-dispatch.json'), 'utf8'));
10
+ const input = JSON.parse(readFileSync(0, 'utf8'));
11
+ const event = input.hook_event_name;
12
+ for (const field of ['tool_input', 'tool_response']) {
13
+ if (typeof input[field] === 'string') { try { input[field] = JSON.parse(input[field]); } catch { /* shell output may be plain text */ } }
14
+ }
15
+ // Subagent hooks must never consume the controller's pending requests.
16
+ if (!input.session_id || input.agent_id) process.exit(0);
17
+ const stateRoot = manifest.host === 'codex' ? path.join(input.cwd || process.cwd(), '.odai', 'named-dispatch') : path.join(root, 'named-dispatch');
18
+ mkdirSync(stateRoot, { recursive: true, mode: 0o700 });
19
+ const file = path.join(stateRoot, createHash('sha256').update(input.session_id).digest('hex') + '.json');
20
+ // Serialize host callbacks; fail visibly instead of racing the pending queue.
21
+ const lock = file + '.lock';
22
+ const deadline = Date.now() + 5000;
23
+ while (true) {
24
+ try { mkdirSync(lock); break; }
25
+ catch (error) {
26
+ if (error.code !== 'EEXIST' || Date.now() >= deadline) throw error;
27
+ await new Promise(resolve => setTimeout(resolve, 20));
28
+ }
29
+ }
30
+ let state;
31
+ try {
32
+ state = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { requests: [], started: Date.now() };
33
+ if (event === 'UserPromptSubmit') {
34
+ const text = String(input.prompt || '');
35
+ const notification = hostNotification(text);
36
+ if (notification) completeNotification(text);
37
+ const requests = notification || manifest.namedDispatch === false ? [] : recognizeNamedRequests(text, manifest.mapping);
38
+ if (!notification && !/^\s*(继续|接着|continue|go on)[。.!!\s]*$/iu.test(text) && !requests.some(r => r.canceled)) state = { requests: [], started: Date.now(), ...(state.lead ? { lead: state.lead } : {}) };
39
+ if (!notification && manifest.leadMode) {
40
+ const change = recognizeLeadSwitch(text, Object.fromEntries(['planner', 'reviewer'].map(role => [role, manifest.mapping[role]])));
41
+ if (change) {
42
+ state.lead = change;
43
+ context(`主导模式${change.enabled ? '已开启' : '未开启或已关闭'};规划与审查:${change.roles.map(role => `${role}=${manifest.mapping[role]?.model}`).join(',') || '无'}。${change.issue || ''} 缺少或未匹配的插槽不调用;配置请更新宿主路由映射。说“关闭主导模式”退出。`);
44
+ }
45
+ }
46
+ for (const request of requests) {
47
+ if (request.canceled) state.requests = state.requests.filter(r => r.responsibility !== request.responsibility || r.done);
48
+ else state.requests.push({ ...request });
49
+ }
50
+ if (!notification && leadEnabled()) context(leadFlow());
51
+ if (state.requests.length) context(state.requests.map(describe).join('\n'));
52
+ } else if (event === 'PreToolUse') {
53
+ if (leadEnabled() && !readOnly(input.tool_name) && !state.requests.some(r => !r.issue && invocation(r))) ensureLead('planner');
54
+ const pending = state.requests.find(r => !r.done && !r.issue && r.timing === 'start');
55
+ if (pending && !invocation(pending) && !(pending.agentId && input.tool_name === 'wait' && input.tool_input?.ids?.includes(pending.agentId)) && !readOnly(input.tool_name)) {
56
+ console.error(describe(pending)); process.exitCode = 2;
57
+ }
58
+ } else if (event === 'PostToolUse') {
59
+ if (leadEnabled()) {
60
+ for (const role of state.lead.roles) {
61
+ if (manifest.mapping[role] && invocation({ responsibility: role }) && (role === 'planner' || state.writes)) {
62
+ const last = state.requests.findLast(r => r.leadReview);
63
+ ensureLead(role, role === 'reviewer' && last?.verdict === 'changes-requested' && last.repairNudged && last.round < 2 && state.writes > last.reviewWrites ? last.round + 1 : 0);
64
+ }
65
+ }
66
+ }
67
+ for (const r of state.requests.filter(r => r.agentId && !r.done)) {
68
+ const response = input.tool_response;
69
+ const status = response?.status?.[r.agentId];
70
+ if (input.tool_name === 'wait' && status && typeof status.completed === 'string') { r.done = true; r.result = 'satisfied'; r.handback = status.completed; }
71
+ }
72
+ const roleCall = state.requests.some(r => !r.issue && (invocation(r) || (input.tool_name === 'wait' && r.agentId && input.tool_response?.status?.[r.agentId])));
73
+ const request = state.requests.find(r => !r.done && !r.issue && invocation(r));
74
+ if (request) {
75
+ const response = input.tool_response;
76
+ const failed = (leadEnabled() && ['failed', 'killed', 'cancelled', 'canceled'].includes(response?.status)) || response?.is_error || response?.error || (Number.isInteger(response?.exit_code) && response.exit_code !== 0);
77
+ if (failed) { request.done = true; request.result = 'failed'; context('点名调用失败,不重试、不替换模型;最终答复说明原因。'); }
78
+ else if (manifest.mapping[request.responsibility].backend === 'local-cli') {
79
+ // Accept only the evidence explicitly returned by this executor invocation.
80
+ const match = /odai evidence: ([^\r\n]+)/u.exec(typeof response === 'string' ? response : JSON.stringify(response).replaceAll('\\n', '\n'));
81
+ let evidence;
82
+ try { if (match) evidence = JSON.parse(readFileSync(match[1].trim(), 'utf8')); } catch { /* missing evidence is not success */ }
83
+ const observed = evidence?.observed;
84
+ if (evidence?.role === request.responsibility && Date.parse(observed?.started_at) >= state.started && observed?.model_verified && observed?.model_verification === 'verified' && observed?.exit_code === 0 && !observed?.error) {
85
+ request.done = true; request.result = 'satisfied';
86
+ if (request.leadReview) request.handback = evidence.output && existsSync(evidence.output) ? readHandback(evidence.output) : responseText(response).replace(/(?:^|\n)odai evidence:[^\n]*/gu, '').trim();
87
+ } else { request.done = true; request.result = 'unverified'; context('点名职责的执行器证据缺失或未核实;最终答复说明,不重试。'); }
88
+ } else if (input.tool_name === 'spawn_agent') {
89
+ if (response?.agent_id) request.agentId = response.agent_id;
90
+ } else if (response) {
91
+ const text = responseText(response);
92
+ if (typeof response.resolvedModel === 'string') {
93
+ request.resolvedModel = response.resolvedModel;
94
+ context(modelReport(request));
95
+ }
96
+ // Claude may launch asynchronously even when run_in_background was omitted.
97
+ if (response.isAsync === true || response.status === 'async_launched' || /^Async agent launched successfully\./u.test(text) || input.tool_input?.run_in_background || input.tool_input?.background) {
98
+ request.inProgress = true;
99
+ request.toolUseId = input.tool_use_id;
100
+ request.outputFile = response.outputFile;
101
+ request.agentId = response.agentId || /\bagentId:\s*([^\s(]+)/u.exec(text)?.[1];
102
+ } else if (response.status === 'completed') {
103
+ request.done = true; request.result = 'satisfied'; request.handback = text;
104
+ }
105
+ }
106
+ }
107
+ if (leadEnabled() && !roleCall && !readOnly(input.tool_name) && !input.tool_response?.is_error && !input.tool_response?.error && !(Number.isInteger(input.tool_response?.exit_code) && input.tool_response.exit_code !== 0)) state.writes = (state.writes || 0) + 1;
108
+ } else if (event === 'Stop' && !input.stop_hook_active) {
109
+ updateReviews();
110
+ const invalid = state.requests.find(r => r.leadReview && r.verdict === 'invalid' && !r.invalidNudged);
111
+ if (invalid) {
112
+ invalid.invalidNudged = true;
113
+ console.log(JSON.stringify({ decision: 'block', reason: '审查回交没有可识别的 VERDICT 行,请在最终答复里如实说明审查结论未按格式给出' }));
114
+ }
115
+ const repair = state.requests.find(r => r.leadReview && r.verdict === 'changes-requested' && !r.repairNudged && r.round < 2);
116
+ if (repair) {
117
+ repair.repairNudged = true;
118
+ console.log(JSON.stringify({ decision: 'block', reason: `主导审查要求修正:${repair.handback}。按意见修正;有成功写入后再审查。` }));
119
+ }
120
+ if (leadEnabled() && state.writes && !repair) {
121
+ const last = state.requests.findLast(r => r.leadReview);
122
+ if (!last || (last.verdict === 'changes-requested' && last.repairNudged && last.round < 2 && state.writes > last.reviewWrites)) ensureLead('reviewer', last ? last.round + 1 : 0);
123
+ }
124
+ const waiting = state.requests.some(r => !r.done && !r.issue && (r.timing === 'start' || r.inProgress || r.agentId));
125
+ const pending = waiting || repair ? [] : state.requests.filter(r => !r.done && !r.issue && !r.nudged && r.timing === 'completion');
126
+ if (pending.length) {
127
+ for (const request of pending) request.nudged = true;
128
+ console.log(JSON.stringify({ decision: 'block', reason: pending.map(describe).join('\n') }));
129
+ }
130
+ }
131
+ updateReviews();
132
+ const temp = file + '.' + randomUUID() + '.tmp';
133
+ writeFileSync(temp, JSON.stringify(state), { mode: 0o600 }); renameSync(temp, file);
134
+ } finally {
135
+ const { rmdirSync } = await import('node:fs'); rmdirSync(lock);
136
+ }
137
+ function responseText(response) {
138
+ if (typeof response === 'string') return response;
139
+ if (Array.isArray(response)) return response.map(responseText).join('\n');
140
+ if (typeof response?.text === 'string') return response.text;
141
+ if (typeof response?.stdout === 'string') return response.stdout;
142
+ if (typeof response?.output === 'string') return response.output;
143
+ if (response?.content) return responseText(response.content);
144
+ return JSON.stringify(response);
145
+ }
146
+ function hostNotification(text) {
147
+ // Host envelopes are continuation input, never a new direct user task.
148
+ return /^\s*<(task-notification|system-reminder|subagent_notification)(?:\s|>)/u.test(text) || input.origin?.kind === 'task-notification';
149
+ }
150
+ function completeNotification(text) {
151
+ const body = /^\s*(?:<system-reminder>\s*)?(<task-notification>[\s\S]*?<\/task-notification>)/u.exec(text)?.[1];
152
+ if (!body) return;
153
+ // Read only header fields, never status-like text embedded in an agent result.
154
+ const header = body.split(/<(?:summary|result|usage|note)>/u)[0];
155
+ const field = name => new RegExp(`<${name}>([^<]*)</${name}>`, 'u').exec(header)?.[1].trim();
156
+ const toolUseId = field('tool-use-id');
157
+ const status = field('status');
158
+ const pending = state.requests.filter(r => r.inProgress && !r.done);
159
+ const byTool = toolUseId ? pending.filter(r => r.toolUseId === toolUseId) : [];
160
+ const taskId = field('task-id');
161
+ const matches = byTool.length ? byTool : pending.filter(r => taskId && r.agentId === taskId);
162
+ for (const request of matches) {
163
+ if (status === 'completed') {
164
+ request.done = true; request.result = 'satisfied';
165
+ if (request.leadReview) {
166
+ try { request.handback = readHandback(request.outputFile); } catch { request.handback = ''; }
167
+ }
168
+ }
169
+ else if (['failed', 'killed', 'cancelled', 'canceled'].includes(status)) {
170
+ request.done = true; request.result = 'failed';
171
+ context(`点名 ${request.responsibility} 后台调用${status};最终答复说明原因,不重试、不替换模型。`);
172
+ }
173
+ }
174
+ }
175
+ function modelReport(r) {
176
+ const configured = manifest.mapping[r.responsibility]?.model;
177
+ const expected = String(configured || '').toLowerCase();
178
+ const actual = r.resolvedModel.toLowerCase();
179
+ const mismatch = expected && !actual.includes(expected) && !expected.includes(actual);
180
+ return `点名 ${r.responsibility} 的宿主 resolvedModel:${r.resolvedModel}。最终答复按此说明实际模型,不采用子代理自报。${mismatch ? `与配置模型 ${configured} 明显不符,请如实说明;不拦截、不重试。` : ''}`;
181
+ }
182
+ function describe(r) {
183
+ const route = manifest.mapping[r.responsibility];
184
+ if (r.issue) return `点名 ${r.responsibility} 未执行:${r.issue};当前映射 ${JSON.stringify(route || null)}。如实说明,不替换模型或修改配置。`;
185
+ const command = route.backend === 'local-cli' ? `node ${JSON.stringify(path.join(root, 'odai-run-role.mjs'))} --role ${r.responsibility} --input <任务包文件>` : `${manifest.host === 'codex' ? 'odai_' : 'odai-'}${r.responsibility}`;
186
+ return `${r.source === 'lead-mode' ? '主导模式' : '用户点名'} ${r.responsibility}(${r.timing === 'start' ? '动手前' : '完成时'}):${r.excerpt}。调用 ${command},回交后继续;最终答复说明哪个模型做了什么或未完成原因。${(leadEnabled() && r.responsibility === 'planner' && state.lead.roles.includes('reviewer')) || r.leadReview || (leadEnabled() && state.lead.roles.includes('reviewer') && r.responsibility === 'reviewer' && r.timing === 'completion') ? '审查任务包必须要求:回交最后单独一行写 VERDICT: pass 或 VERDICT: changes-requested。' : ''}${r.resolvedModel ? modelReport(r) : ''}`;
187
+ }
188
+ function invocation(r) {
189
+ const args = input.tool_input || {};
190
+ const route = manifest.mapping[r.responsibility];
191
+ if (route.backend === 'local-cli') {
192
+ const command = args.command || args.cmd;
193
+ // Require an actual standalone executor command, not an echo or quoted mention.
194
+ return typeof command === 'string' && /^\s*node\s+/u.test(command) && new RegExp(`(?:[/'\"]|^)odai-run-role\\.mjs['\"]?\\s+--role\\s+${r.responsibility}(?:\\s|$)`, 'u').test(command) && !/[;&|`\n]|\$\(/u.test(command);
195
+ }
196
+ return /^(Agent|Task|spawn_agent)$/u.test(input.tool_name) && [args.subagent_type, args.agent_type].includes(`${manifest.host === 'codex' ? 'odai_' : 'odai-'}${r.responsibility}`);
197
+ }
198
+ function readOnly(name) { return /^(Read|Glob|Grep|read_file|list_dir|glob|grep|web_search|view_image)$/u.test(name || ''); }
199
+ function context(text) { console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } })); }
200
+
201
+ function leadFlow() {
202
+ const roles = state.lead.roles;
203
+ return '主导模式流程:' + (roles.includes('planner') ? '第一次改动前先派规划插槽。' : '') + (roles.includes('reviewer') ? '有改动时做完派审查插槽;派审查时要求回交最后单独一行写 VERDICT: pass 或 VERDICT: changes-requested。' : '') + '最终答复按宿主记录说明实际模型。';
204
+ }
205
+ function leadEnabled() { return manifest.leadMode && state.lead?.enabled; }
206
+ function ensureLead(role, round = 0) {
207
+ if (!state.lead.roles.includes(role) || !manifest.mapping[role]) return;
208
+ const timing = role === 'planner' ? 'start' : 'completion';
209
+ let request = state.requests.find(r => r.responsibility === role && r.timing === timing && !r.issue && (role === 'planner' || (r.round || 0) === round));
210
+ if (!request) {
211
+ request = { responsibility: role, timing, source: 'lead-mode', excerpt: role === 'planner' ? '第一次写入前规划' : '验收本任务写入' };
212
+ state.requests.push(request);
213
+ }
214
+ if (role === 'reviewer') {
215
+ request.leadReview = true; request.round = round; request.reviewWrites ??= state.writes || 0;
216
+ }
217
+ return request;
218
+ }
219
+ function updateReviews() {
220
+ if (!leadEnabled()) return;
221
+ for (const r of state.requests.filter(r => r.leadReview && r.done && !r.verdict)) {
222
+ r.verdict = r.result === 'satisfied' && !modelMismatch(r) ? parseLeadVerdict(r.handback || '') : 'failed';
223
+ if (r.verdict === 'invalid' || r.verdict === 'failed' || (r.verdict === 'changes-requested' && r.round >= 2)) context(`主导审查 ${r.verdict};不再追加轮次,最终答复如实说明未解决的问题。`);
224
+ }
225
+ }
226
+
227
+ function modelMismatch(r) {
228
+ const expected = String(manifest.mapping[r.responsibility]?.model || '').toLowerCase();
229
+ const actual = String(r.resolvedModel || '').toLowerCase();
230
+ return expected && actual && !actual.includes(expected) && !expected.includes(actual);
231
+ }
232
+ function readHandback(file) {
233
+ const text = readFileSync(file, 'utf8');
234
+ // Claude background output may be a transcript rather than plain text.
235
+ const messages = [];
236
+ for (const line of text.split(/\r?\n/u)) {
237
+ try {
238
+ const entry = JSON.parse(line);
239
+ if (entry.type === 'assistant' && entry.message?.content) messages.push(responseText(entry.message.content));
240
+ } catch { /* plain text handback */ }
241
+ }
242
+ return messages.length ? messages.join('\n') : text;
243
+ }
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import { homedir, tmpdir } from "node:os";
5
5
  import path from "node:path";
6
6
  import process from "node:process";
7
7
  import { spawnSync } from "node:child_process";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
- import { randomUUID } from "node:crypto";
9
+ import { createHash, randomUUID } from "node:crypto";
10
10
 
11
11
  process.on("uncaughtException", (error) => {
12
12
  console.error(error?.message || String(error));
@@ -15,17 +15,18 @@ process.on("uncaughtException", (error) => {
15
15
 
16
16
  const scriptDir = path.dirname(fileURLToPath(import.meta.url));
17
17
  const args = parseArgs(process.argv.slice(2));
18
- const supportedRoles = new Set(["controller", "researcher", "planner", "reviewer", "frontend", "creative"]);
18
+ const supportedRoles = new Set(["controller", "researcher", "planner", "reviewer", "frontend", "creative", "writer"]);
19
19
 
20
20
  if (args.help) {
21
21
  console.log(`Usage:
22
- node .codex/odai-run-role.mjs --role <controller|researcher|planner|reviewer|frontend|creative> \\
22
+ node .codex/odai-run-role.mjs --role <controller|researcher|planner|reviewer|frontend|creative|writer> \\
23
23
  [--input FILE] [--output FILE] [--evidence FILE] [--cwd PATH] [--manifest FILE] \\
24
- [--codex-bin FILE] [--sandbox MODE] [--session-id ID] [--closeout-ids A1,A2]
24
+ [--session-key KEY] [--codex-bin FILE] [--sandbox MODE] [--session-id ID] [--closeout-ids A1,A2]
25
25
 
26
26
  从 odai 托管清单取得角色、模型与推理档,运行一次实际责任并记录可核对证据。
27
- Claude 宿主使用 .claude/odai-run-role.mjs,仅支持非 controller 的 codex/antigravity/grok 本机 CLI 映射;
28
- 未传 --evidence 时保存到执行器同目录 role-evidence/。--timeout 可覆盖本机 CLI 超时(1–7200 整数秒)。
27
+ Claude 宿主使用 .claude/odai-run-role.mjs;Codex 宿主支持 claude/antigravity/grok 本机 CLI。
28
+ --session-key 为缺少宿主环境变量时显式提供的宿主会话唯一键;
29
+ 未传 --evidence 时,Codex 本机 CLI 保存到当前项目 .odai/role-evidence/,Claude 保存到执行器同目录 role-evidence/。--timeout 可覆盖本机 CLI 超时(1–7200 整数秒)。
29
30
  --codex-bin、--sandbox、--session-id、--closeout-ids 仅用于 Codex 宿主。`);
30
31
  process.exit(0);
31
32
  }
@@ -36,13 +37,14 @@ const manifest = readJson(manifestFile, "路由清单");
36
37
  if (manifest.id !== "odai-routing-installation" || !["codex", "claude"].includes(manifest.host)) fail("路由清单不是 Codex 或 Claude odai 托管安装");
37
38
  const mapping = manifest.mapping?.[args.role];
38
39
  if (manifest.host === "codex" && !mapping?.model) fail(`路由清单缺少 ${args.role} 映射`);
39
- if (manifest.host === "codex" && mapping.backend === "local-cli") fail("Codex 宿主尚不支持 local-cli 映射");
40
+
40
41
 
41
42
  const workdir = path.resolve(args.cwd || process.cwd());
42
- if (manifest.host === "claude") await runLocalRole();
43
+ if (manifest.host === "claude" || mapping?.backend === "local-cli") await runLocalRole();
43
44
  else runCodexRole();
44
45
 
45
46
  function runCodexRole() {
47
+ if (mapping.session === "continue") fail("Codex 原生角色暂不支持会话续接:证据不足");
46
48
  const input = args.input ? readFileSync(path.resolve(args.input), "utf8").trim() : readFileSync(0, "utf8").trim();
47
49
  if (!input) fail("角色输入为空");
48
50
  const contractFile = path.join(scriptDir, "role-contracts", `odai-${args.role}.md`);
@@ -56,7 +58,7 @@ function runCodexRole() {
56
58
  mkdirSync(path.dirname(outputFile), { recursive: true });
57
59
 
58
60
  try {
59
- const readOnly = ["researcher", "planner", "reviewer", "creative"].includes(args.role);
61
+ const readOnly = ["researcher", "planner", "reviewer", "creative", "writer"].includes(args.role);
60
62
  const closeoutIds = parseCloseoutIds(args.closeoutIds);
61
63
  const schemaFile = closeoutIds.length > 0 ? writeCloseoutSchema(temporary, closeoutIds) : "";
62
64
  const hostOwnsRouting = process.env.ODAI_ROUTING_ACTIVE === "1";
@@ -143,20 +145,23 @@ function runCodexRole() {
143
145
  }
144
146
 
145
147
  async function runLocalRole() {
146
- const evidenceFile = path.resolve(args.evidence || path.join(scriptDir, "role-evidence", `${args.role}-${Date.now()}-${randomUUID()}.json`));
148
+ const stateBase = manifest.host === "codex" ? path.join(workdir, ".odai") : scriptDir;
149
+ const evidenceFile = path.resolve(args.evidence || path.join(stateBase, "role-evidence", `${args.role}-${Date.now()}-${randomUUID()}.json`));
147
150
  const outputFile = args.output ? path.resolve(args.output) : null;
148
151
  const startedAt = new Date().toISOString();
149
152
  const started = Date.now();
150
153
  let result;
151
154
  let error;
155
+ let sessionEvidence;
156
+ let unlock;
152
157
  try {
153
- if (args.role === "controller") fail("Claude 宿主的 controller 不支持 local-cli 映射");
154
- if (mapping?.backend !== "local-cli" || !["codex", "antigravity", "grok"].includes(mapping.cli)) {
158
+ if (args.role === "controller") fail("controller 不支持 local-cli 映射");
159
+ if (mapping?.backend !== "local-cli" || !(manifest.host === "codex" ? ["claude", "antigravity", "grok"] : ["codex", "antigravity", "grok"]).includes(mapping.cli)) {
155
160
  fail("Claude 宿主执行器仅接受 codex、antigravity 或 grok 的 local-cli 映射");
156
161
  }
157
162
  const unsupported = ["--codex-bin", "--sandbox", "--session-id", "--closeout-ids"].filter((option) => args.supplied.has(option));
158
163
  if (unsupported.length) fail(`Claude 本机 CLI 分支不支持 Codex 专有选项:${unsupported.join("、")}`);
159
- const allowedFields = new Set(["backend", "cli", "model", "reasoning_effort", "timeoutSeconds"]);
164
+ const allowedFields = new Set(["backend", "cli", "model", "reasoning_effort", "timeoutSeconds", "session"]);
160
165
  for (const key of Object.keys(mapping)) {
161
166
  if (!allowedFields.has(key)) fail(`未知本机 CLI 映射字段:${key}`);
162
167
  }
@@ -166,6 +171,7 @@ async function runLocalRole() {
166
171
  const route = validateLocalCliRoute({
167
172
  backend: mapping.backend, cli: mapping.cli, model: mapping.model,
168
173
  ...(mapping.reasoning_effort != null ? { reasoningEffort: mapping.reasoning_effort } : {}),
174
+ ...(mapping.session !== undefined ? { session: mapping.session } : {}),
169
175
  ...(mapping.timeoutSeconds !== undefined ? { timeoutSeconds: mapping.timeoutSeconds } : {}),
170
176
  });
171
177
  // Only an explicit flag overrides the shared executor's 1200-second default.
@@ -176,7 +182,54 @@ async function runLocalRole() {
176
182
  const contractFile = path.join(scriptDir, "role-contracts", `odai-${args.role}.md`);
177
183
  if (!existsSync(contractFile)) fail(`缺少角色契约:${contractFile}`);
178
184
  const contract = readFileSync(contractFile, "utf8");
179
- result = await executeLocalCli({ route, prompt: `${contract}\n\n${input}`, cwd: workdir });
185
+ const invoke = resumeSessionId => executeLocalCli({ route, prompt: `${contract}\n\n${input}`, cwd: workdir, ...(resumeSessionId ? { resumeSessionId } : {}) });
186
+ let stateFile;
187
+ let previous;
188
+ if (route.session === "continue") {
189
+ const hostSession = (manifest.host === "codex" ? process.env.CODEX_THREAD_ID || process.env.CLAUDE_CODE_SESSION_ID : process.env.CLAUDE_CODE_SESSION_ID || process.env.CODEX_THREAD_ID) || args.sessionKey;
190
+ if (!hostSession) fail("会话续接缺少宿主会话键;请显式传 --session-key");
191
+ const key = createHash("sha256").update(JSON.stringify([manifest.host, hostSession, route.backend, route.cli, route.model])).digest("hex");
192
+ const stateRoot = path.join(stateBase, "role-sessions");
193
+ mkdirSync(stateRoot, { recursive: true, mode: 0o700 });
194
+ stateFile = path.join(stateRoot, `${key}.json`);
195
+ const lock = `${stateFile}.lock`;
196
+ const deadline = Date.now() + args.timeout * 1000;
197
+ while (true) {
198
+ try { mkdirSync(lock, { mode: 0o700 }); break; }
199
+ catch (failure) {
200
+ if (failure.code !== "EEXIST") throw failure;
201
+ if (Date.now() >= deadline) fail(`等待会话锁超时:${lock};确认没有运行进程后再移除遗留锁`);
202
+ await new Promise(resolve => setTimeout(resolve, 50));
203
+ }
204
+ }
205
+ unlock = () => rmSync(lock, { recursive: true });
206
+ try {
207
+ if (existsSync(stateFile)) {
208
+ previous = JSON.parse(readFileSync(stateFile, "utf8")).sessionId;
209
+ if (typeof previous !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9-]*$/.test(previous)) throw new Error("Invalid saved session ID");
210
+ }
211
+ }
212
+ catch { sessionEvidence = { mode: "resume-failed-created", notice: "续接状态损坏,已新开" }; }
213
+ }
214
+ result = await invoke(previous);
215
+ if (previous) {
216
+ sessionEvidence = { mode: "resumed" };
217
+ if (result.status === "error" && !result.boundaryStatus) {
218
+ result = await invoke();
219
+ sessionEvidence = { mode: "resume-failed-created", notice: "续接失败,已新开" };
220
+ }
221
+ }
222
+ if (stateFile && result.status === "completed" && !result.sessionId) { result = { ...result, status: "error", error: "后端未提供可续接会话标识" }; }
223
+ if (stateFile) {
224
+ sessionEvidence ||= { mode: "created" };
225
+ if (sessionEvidence.notice) console.error(sessionEvidence.notice);
226
+ // Clear stale state after failed resume; only verified handbacks become reusable.
227
+ if (result.status === "completed" && result.modelVerified && result.modelVerification === "verified" && result.reasoningEffortVerification !== "mismatch" && result.text?.trim() && result.sessionId) {
228
+ const temporaryState = `${stateFile}.${randomUUID()}.tmp`;
229
+ writeFileSync(temporaryState, JSON.stringify({ sessionId: result.sessionId }), { mode: 0o600 });
230
+ renameSync(temporaryState, stateFile);
231
+ } else if (existsSync(stateFile)) rmSync(stateFile);
232
+ }
180
233
  if (result.status !== "completed") fail(result.error || `本机 CLI 未完成:${result.status}`);
181
234
  if (result.exitCode !== 0) fail(`本机 CLI 退出状态异常:${result.exitCode ?? "unknown"}`);
182
235
  if (typeof result.text !== "string" || !result.text.trim()) fail("角色没有形成输出");
@@ -195,6 +248,7 @@ async function runLocalRole() {
195
248
  } catch (failure) {
196
249
  error = failure instanceof Error ? failure : new Error(String(failure));
197
250
  }
251
+ if (unlock) unlock();
198
252
  const provider = `local-cli:${mapping?.cli || "unknown"}`;
199
253
  const evidence = {
200
254
  version: 1,
@@ -203,6 +257,7 @@ async function runLocalRole() {
203
257
  observed: {
204
258
  provider,
205
259
  session_id: result?.sessionId || null,
260
+ ...(sessionEvidence ? { session: sessionEvidence } : {}),
206
261
  models: result?.actualModel ? [result.actualModel] : [],
207
262
  model_verified: result?.modelVerified === true,
208
263
  model_verification: result?.modelVerification || "missing",
@@ -308,7 +363,7 @@ function parseArgs(values) {
308
363
  const result = { role: "", input: "", output: "", evidence: "", cwd: "", manifest: "", codexBin: "", sandbox: "workspace-write", sessionId: "", closeoutIds: "", timeout: 900, help: false, supplied: new Set() };
309
364
  const fields = new Map([
310
365
  ["--role", "role"], ["--input", "input"], ["--output", "output"], ["--evidence", "evidence"], ["--cwd", "cwd"],
311
- ["--manifest", "manifest"], ["--codex-bin", "codexBin"], ["--sandbox", "sandbox"], ["--session-id", "sessionId"], ["--closeout-ids", "closeoutIds"], ["--timeout", "timeout"],
366
+ ["--session-key", "sessionKey"], ["--manifest", "manifest"], ["--codex-bin", "codexBin"], ["--sandbox", "sandbox"], ["--session-id", "sessionId"], ["--closeout-ids", "closeoutIds"], ["--timeout", "timeout"],
312
367
  ]);
313
368
  for (let index = 0; index < values.length; index += 1) {
314
369
  const value = values[index];
@@ -9,12 +9,12 @@ import { fileURLToPath } from "node:url";
9
9
 
10
10
  const scriptDir = path.dirname(fileURLToPath(import.meta.url));
11
11
  const args = parseArgs(process.argv.slice(2));
12
- const mappings = new Map([["odai_researcher", "researcher"], ["odai_planner", "planner"], ["odai_reviewer", "reviewer"], ["odai_frontend", "frontend"], ["odai_creative", "creative"]]);
12
+ const mappings = new Map([["odai_researcher", "researcher"], ["odai_planner", "planner"], ["odai_reviewer", "reviewer"], ["odai_frontend", "frontend"], ["odai_creative", "creative"], ["odai_writer", "writer"]]);
13
13
 
14
14
  if (args.help) {
15
15
  console.log(`Usage:
16
16
  node odai-verify-routing.mjs --host codex --project <directory> \\
17
- --role <odai_researcher|odai_planner|odai_reviewer|odai_frontend|odai_creative> --after <ISO timestamp> \\
17
+ --role <odai_researcher|odai_planner|odai_reviewer|odai_frontend|odai_creative|odai_writer> --after <ISO timestamp> \\
18
18
  [--agent-path </root/task>] [--sessions <directory>]
19
19
 
20
20
  只读取近期 Codex rollout metadata,把实际角色、模型与推理档和 odai 托管清单对账。`);