@canonmsg/codex-plugin 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -1
- package/dist/adapter.js +36 -5
- package/dist/host.js +267 -24
- package/dist/inbound-policy.d.ts +19 -0
- package/dist/inbound-policy.js +50 -0
- package/dist/register.js +0 -0
- package/dist/setup.js +6 -1
- package/package.json +10 -6
- package/scripts/smoke-test.mjs +92 -0
package/README.md
CHANGED
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
Connect the local Codex CLI to [Canon](https://github.com/HeyBobChan/canon) so a Canon user can message your coding agent from the app.
|
|
4
4
|
|
|
5
|
+
The plugin uses the local user's existing Codex authentication by default. That means Canon follows whatever plan or login mode the user has configured in Codex itself, instead of asking for a separate Canon-side OpenAI credential.
|
|
6
|
+
|
|
5
7
|
## Quick start
|
|
6
8
|
|
|
7
9
|
```bash
|
|
8
10
|
# Install
|
|
9
11
|
npm install -g @canonmsg/codex-plugin
|
|
10
12
|
|
|
13
|
+
# Make sure Codex itself is logged in the way you want Canon to use
|
|
14
|
+
codex login status
|
|
15
|
+
|
|
11
16
|
# Register (approve in Canon when prompted)
|
|
12
17
|
canon-codex-register --name "My Codex" --description "My local coding agent" --phone "+15551234567"
|
|
13
18
|
|
|
@@ -15,6 +20,8 @@ canon-codex-register --name "My Codex" --description "My local coding agent" --p
|
|
|
15
20
|
canon-codex --cwd /path/to/project
|
|
16
21
|
```
|
|
17
22
|
|
|
23
|
+
Registration saves a Canon profile in `~/.canon/agents.json`, the same shared profile store used by the Claude Code integration and supported by the OpenClaw plugin.
|
|
24
|
+
|
|
18
25
|
## What v1 supports
|
|
19
26
|
|
|
20
27
|
- Canon messages routed into Codex turns
|
|
@@ -37,9 +44,35 @@ canon-codex --cwd /path/to/project
|
|
|
37
44
|
Useful flags:
|
|
38
45
|
|
|
39
46
|
```bash
|
|
40
|
-
canon-codex --cwd /path/to/project --model gpt-5.4 --
|
|
47
|
+
canon-codex --cwd /path/to/project --model gpt-5.4 --full-auto
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Recent Codex CLI releases no longer accept `--ask-for-approval` with `codex exec`. If you previously launched Canon with `--sandbox workspace-write --ask-for-approval never`, switch to `--full-auto`.
|
|
51
|
+
|
|
52
|
+
Local smoke test:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npm run smoke -- /path/to/project
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Troubleshooting
|
|
59
|
+
|
|
60
|
+
If Codex reports API-key quota errors while another local tool on the same machine uses OpenAI API keys, check Codex's own stored login state:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
codex login status
|
|
41
64
|
```
|
|
42
65
|
|
|
66
|
+
Codex stores its credential mode in `~/.codex/auth.json`. If it says it is logged in using an API key, switch Codex back to ChatGPT/device auth without changing your other tools:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
codex logout
|
|
70
|
+
codex login --device-auth
|
|
71
|
+
codex login status
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Restart `canon-codex` after changing the Codex login state.
|
|
75
|
+
|
|
43
76
|
## Multiple agents
|
|
44
77
|
|
|
45
78
|
```bash
|
package/dist/adapter.js
CHANGED
|
@@ -101,6 +101,12 @@ export class CodexConversationAdapter {
|
|
|
101
101
|
case 'turn.completed':
|
|
102
102
|
onEvent({ type: 'turn.completed', usage: event.usage });
|
|
103
103
|
break;
|
|
104
|
+
case 'error':
|
|
105
|
+
lastErrorText = normalizeErrorText(event.message) ?? lastErrorText;
|
|
106
|
+
break;
|
|
107
|
+
case 'turn.failed':
|
|
108
|
+
lastErrorText = normalizeErrorText(event.error?.message ?? event.message) ?? lastErrorText;
|
|
109
|
+
break;
|
|
104
110
|
default:
|
|
105
111
|
break;
|
|
106
112
|
}
|
|
@@ -157,15 +163,18 @@ export class CodexConversationAdapter {
|
|
|
157
163
|
return args;
|
|
158
164
|
}
|
|
159
165
|
const args = ['exec', '--json', '--color', 'never', '-C', this.cwd, '--skip-git-repo-check'];
|
|
166
|
+
const execMode = resolveExecMode({
|
|
167
|
+
sandbox: this.sandbox,
|
|
168
|
+
approvalPolicy: this.approvalPolicy,
|
|
169
|
+
fullAuto: this.fullAuto,
|
|
170
|
+
bypassApprovalsAndSandbox: this.bypassApprovalsAndSandbox,
|
|
171
|
+
});
|
|
160
172
|
if (this.model) {
|
|
161
173
|
args.push('-m', this.model);
|
|
162
174
|
}
|
|
163
175
|
if (this.sandbox) {
|
|
164
176
|
args.push('-s', this.sandbox);
|
|
165
177
|
}
|
|
166
|
-
if (this.approvalPolicy) {
|
|
167
|
-
args.push('-a', this.approvalPolicy);
|
|
168
|
-
}
|
|
169
178
|
if (this.codexProfile) {
|
|
170
179
|
args.push('-p', this.codexProfile);
|
|
171
180
|
}
|
|
@@ -175,10 +184,10 @@ export class CodexConversationAdapter {
|
|
|
175
184
|
for (const configOverride of this.configOverrides) {
|
|
176
185
|
args.push('-c', configOverride);
|
|
177
186
|
}
|
|
178
|
-
if (
|
|
187
|
+
if (execMode.fullAuto) {
|
|
179
188
|
args.push('--full-auto');
|
|
180
189
|
}
|
|
181
|
-
if (
|
|
190
|
+
if (execMode.bypassApprovalsAndSandbox) {
|
|
182
191
|
args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
183
192
|
}
|
|
184
193
|
args.push(prompt);
|
|
@@ -209,6 +218,28 @@ function normalizeMessageText(value) {
|
|
|
209
218
|
const text = value.trim();
|
|
210
219
|
return text ? text : null;
|
|
211
220
|
}
|
|
221
|
+
function normalizeErrorText(value) {
|
|
222
|
+
if (typeof value !== 'string')
|
|
223
|
+
return null;
|
|
224
|
+
const text = value.trim();
|
|
225
|
+
return text ? text : null;
|
|
226
|
+
}
|
|
227
|
+
function resolveExecMode(input) {
|
|
228
|
+
if (input.bypassApprovalsAndSandbox) {
|
|
229
|
+
return { fullAuto: false, bypassApprovalsAndSandbox: true };
|
|
230
|
+
}
|
|
231
|
+
if (input.fullAuto) {
|
|
232
|
+
return { fullAuto: true, bypassApprovalsAndSandbox: false };
|
|
233
|
+
}
|
|
234
|
+
// Newer Codex CLI releases no longer accept --ask-for-approval for `exec`.
|
|
235
|
+
// Preserve the old Canon example (`--sandbox workspace-write --ask-for-approval never`)
|
|
236
|
+
// by translating it to the supported `--full-auto` mode.
|
|
237
|
+
if (input.approvalPolicy === 'never' &&
|
|
238
|
+
(input.sandbox === 'workspace-write' || input.sandbox == null)) {
|
|
239
|
+
return { fullAuto: true, bypassApprovalsAndSandbox: false };
|
|
240
|
+
}
|
|
241
|
+
return { fullAuto: false, bypassApprovalsAndSandbox: false };
|
|
242
|
+
}
|
|
212
243
|
function isIgnorableCodexLog(line) {
|
|
213
244
|
return [
|
|
214
245
|
'Reading additional input from stdin...',
|
package/dist/host.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { setDefaultResultOrder } from 'node:dns';
|
|
3
3
|
setDefaultResultOrder('ipv4first');
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
4
5
|
import { parseArgs } from 'node:util';
|
|
5
6
|
import { basename, resolve } from 'node:path';
|
|
6
|
-
import { CanonClient, CanonStream, clearSessionState, getActiveProfile, initRTDBAuth, releaseLock, resolveCanonAgent, rtdbRead, rtdbWrite, writeSessionState, } from '@canonmsg/core';
|
|
7
|
+
import { CanonClient, CanonStream, clearSessionState, clearTurnState, DEFAULT_RUNTIME_CAPABILITIES, getActiveProfile, initRTDBAuth, normalizeTurnMetadata, normalizeTurnState, releaseLock, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, writeSessionState, writeTurnState, } from '@canonmsg/core';
|
|
8
|
+
import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
|
|
7
9
|
import { CodexConversationAdapter, } from './adapter.js';
|
|
8
10
|
import { clearStoredThreadId, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
|
|
9
11
|
const MAX_SESSIONS = 12;
|
|
@@ -11,6 +13,12 @@ const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
|
11
13
|
const HEARTBEAT_MS = 30_000;
|
|
12
14
|
const IDLE_CHECK_MS = 60_000;
|
|
13
15
|
const CONTROL_POLL_MS = 2_000;
|
|
16
|
+
const CODEX_RUNTIME_CAPABILITIES = {
|
|
17
|
+
...DEFAULT_RUNTIME_CAPABILITIES,
|
|
18
|
+
supportsInterrupt: true,
|
|
19
|
+
supportsQueue: true,
|
|
20
|
+
supportsNonFinalPermanentMessages: true,
|
|
21
|
+
};
|
|
14
22
|
let workingDir = process.cwd();
|
|
15
23
|
let workspaceOptions = [];
|
|
16
24
|
function normalizeString(value) {
|
|
@@ -19,6 +27,22 @@ function normalizeString(value) {
|
|
|
19
27
|
const trimmed = value.trim();
|
|
20
28
|
return trimmed ? trimmed : undefined;
|
|
21
29
|
}
|
|
30
|
+
function normalizeRuntimeTurnState(value) {
|
|
31
|
+
const normalizedTurn = normalizeTurnState(value);
|
|
32
|
+
if (normalizedTurn) {
|
|
33
|
+
return { state: normalizedTurn.state };
|
|
34
|
+
}
|
|
35
|
+
if (!value || typeof value !== 'object')
|
|
36
|
+
return null;
|
|
37
|
+
const state = value.state;
|
|
38
|
+
if (state === 'running') {
|
|
39
|
+
return { state: 'streaming' };
|
|
40
|
+
}
|
|
41
|
+
if (state === 'requires_action') {
|
|
42
|
+
return { state: 'waiting_input' };
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
22
46
|
function buildWorkspaceOptions(primaryCwd, configured) {
|
|
23
47
|
const uniqueDirs = Array.from(new Set([primaryCwd, ...configured].map((dir) => resolve(dir))));
|
|
24
48
|
const seenLabels = new Map();
|
|
@@ -69,17 +93,15 @@ function toPublicWorkspaceOptions() {
|
|
|
69
93
|
return workspaceOptions.map(({ id, label }) => ({ id, label }));
|
|
70
94
|
}
|
|
71
95
|
function buildCanonPrompt(input) {
|
|
72
|
-
const ownerLine = input.isOwner
|
|
73
|
-
? 'The sender is the verified human owner of this Canon agent.'
|
|
74
|
-
: 'The sender is a Canon user in this conversation.';
|
|
75
96
|
return [
|
|
76
97
|
'You are connected to Canon messaging through a Codex host wrapper.',
|
|
77
|
-
'Reply naturally to
|
|
98
|
+
'Reply naturally to Canon participants.',
|
|
78
99
|
'Only the last assistant message from this turn will be delivered as the permanent Canon reply.',
|
|
79
100
|
'Short intermediate assistant messages may be shown as ephemeral status while you work.',
|
|
80
|
-
|
|
101
|
+
...buildInboundContextLines(input.participantContext),
|
|
102
|
+
'Canon participants may be humans or AI agents.',
|
|
103
|
+
'If the latest message came from another AI agent, avoid agent-only ping-pong and only reply when directly addressed, when a human is clearly steering, or when a reply is genuinely necessary.',
|
|
81
104
|
`Conversation ID: ${input.conversationId}`,
|
|
82
|
-
`Sender: ${input.senderName}`,
|
|
83
105
|
'',
|
|
84
106
|
'New Canon message:',
|
|
85
107
|
input.content,
|
|
@@ -87,7 +109,25 @@ function buildCanonPrompt(input) {
|
|
|
87
109
|
}
|
|
88
110
|
function renderInboundContent(message) {
|
|
89
111
|
let content = message.text || '';
|
|
90
|
-
|
|
112
|
+
const attachment = message.attachments?.[0];
|
|
113
|
+
if (attachment?.kind === 'audio' && attachment.url) {
|
|
114
|
+
const duration = attachment.durationMs ? ` (${Math.round(attachment.durationMs / 1000)}s)` : '';
|
|
115
|
+
content = content
|
|
116
|
+
? `[Voice message${duration}: ${attachment.url}]\n${content}`
|
|
117
|
+
: `[Voice message${duration}: ${attachment.url}]`;
|
|
118
|
+
}
|
|
119
|
+
else if (attachment?.kind === 'image' && attachment.url) {
|
|
120
|
+
content = content
|
|
121
|
+
? `[Image: ${attachment.url}]\n${content}`
|
|
122
|
+
: `[Image: ${attachment.url}]`;
|
|
123
|
+
}
|
|
124
|
+
else if (attachment?.kind === 'file' && attachment.url) {
|
|
125
|
+
const label = attachment.fileName || 'File';
|
|
126
|
+
content = content
|
|
127
|
+
? `[File: ${label} ${attachment.url}]\n${content}`
|
|
128
|
+
: `[File: ${label} ${attachment.url}]`;
|
|
129
|
+
}
|
|
130
|
+
else if (message.contentType === 'audio' && message.audioUrl) {
|
|
91
131
|
const duration = message.audioDurationMs ? ` (${Math.round(message.audioDurationMs / 1000)}s)` : '';
|
|
92
132
|
content = content
|
|
93
133
|
? `[Voice message${duration}: ${message.audioUrl}]\n${content}`
|
|
@@ -107,6 +147,14 @@ function summarizeCommand(command) {
|
|
|
107
147
|
const shortened = trimmed.length > 140 ? `${trimmed.slice(0, 137)}...` : trimmed;
|
|
108
148
|
return `Running: ${shortened}`;
|
|
109
149
|
}
|
|
150
|
+
function formatTurnFailure(errorText) {
|
|
151
|
+
if (!errorText) {
|
|
152
|
+
return 'The Codex session stopped unexpectedly before sending a final reply.';
|
|
153
|
+
}
|
|
154
|
+
const normalized = errorText.replace(/^error:\s*/i, '').trim();
|
|
155
|
+
const shortened = normalized.length > 280 ? `${normalized.slice(0, 277)}...` : normalized;
|
|
156
|
+
return `Codex failed before sending a final reply: ${shortened}`;
|
|
157
|
+
}
|
|
110
158
|
async function main() {
|
|
111
159
|
const { values: args } = parseArgs({
|
|
112
160
|
options: {
|
|
@@ -126,6 +174,9 @@ async function main() {
|
|
|
126
174
|
});
|
|
127
175
|
workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
|
|
128
176
|
workspaceOptions = buildWorkspaceOptions(workingDir, args.workspace ?? []);
|
|
177
|
+
if (typeof args['ask-for-approval'] === 'string') {
|
|
178
|
+
console.error('[canon-codex] Note: newer Codex CLI releases do not accept --ask-for-approval for `codex exec`; Canon will translate compatible legacy usage when possible.');
|
|
179
|
+
}
|
|
129
180
|
const { apiKey, agentId: profileAgentId, profile } = resolveCanonAgent({ logPrefix: '[canon-codex]' });
|
|
130
181
|
console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
|
|
131
182
|
const client = new CanonClient(apiKey);
|
|
@@ -150,8 +201,74 @@ async function main() {
|
|
|
150
201
|
}
|
|
151
202
|
const sessions = new Map();
|
|
152
203
|
const pendingSessionCreations = new Map();
|
|
204
|
+
const conversationCache = new Map();
|
|
205
|
+
let conversationCacheLoadedAt = 0;
|
|
206
|
+
async function refreshConversationCache(force = false) {
|
|
207
|
+
if (!force && conversationCache.size > 0 && Date.now() - conversationCacheLoadedAt < 10_000) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const conversations = await client.getConversations();
|
|
211
|
+
conversationCache.clear();
|
|
212
|
+
for (const conversation of conversations) {
|
|
213
|
+
conversationCache.set(conversation.id, conversation);
|
|
214
|
+
}
|
|
215
|
+
conversationCacheLoadedAt = Date.now();
|
|
216
|
+
}
|
|
217
|
+
async function getConversationMeta(conversationId) {
|
|
218
|
+
try {
|
|
219
|
+
await refreshConversationCache();
|
|
220
|
+
const cached = conversationCache.get(conversationId);
|
|
221
|
+
if (cached)
|
|
222
|
+
return cached;
|
|
223
|
+
await refreshConversationCache(true);
|
|
224
|
+
return conversationCache.get(conversationId) ?? null;
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return conversationCache.get(conversationId) ?? null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async function loadSenderRuntimeState(conversationId, senderId) {
|
|
231
|
+
try {
|
|
232
|
+
const [turnState, sessionState] = await Promise.all([
|
|
233
|
+
rtdbRead(`/turn-state/${conversationId}/${senderId}`),
|
|
234
|
+
rtdbRead(`/session-state/${conversationId}/${senderId}`),
|
|
235
|
+
]);
|
|
236
|
+
return normalizeRuntimeTurnState(turnState) ?? normalizeRuntimeTurnState(sessionState);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async function loadParticipantContext(input) {
|
|
243
|
+
const [conversation, recentMessages] = await Promise.all([
|
|
244
|
+
getConversationMeta(input.conversationId),
|
|
245
|
+
client.getMessages(input.conversationId, 6).catch(() => []),
|
|
246
|
+
]);
|
|
247
|
+
const recentSenderTypes = recentMessages
|
|
248
|
+
.filter((message) => message.senderId !== agentId)
|
|
249
|
+
.map((message) => message.senderType);
|
|
250
|
+
let consecutiveAgentTurns = 0;
|
|
251
|
+
for (const senderType of recentSenderTypes) {
|
|
252
|
+
if (senderType !== 'ai_agent')
|
|
253
|
+
break;
|
|
254
|
+
consecutiveAgentTurns += 1;
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
conversationType: conversation?.type ?? 'unknown',
|
|
258
|
+
memberCount: conversation?.memberIds?.length ?? null,
|
|
259
|
+
senderType: input.message.senderType ?? 'human',
|
|
260
|
+
senderName: input.senderName,
|
|
261
|
+
isOwner: input.isOwner,
|
|
262
|
+
mentionedAgent: Array.isArray(input.message.mentions) && input.message.mentions.includes(agentId),
|
|
263
|
+
recentSenderTypes,
|
|
264
|
+
recentHumanCount: recentSenderTypes.filter((senderType) => senderType === 'human').length,
|
|
265
|
+
recentAgentCount: recentSenderTypes.filter((senderType) => senderType === 'ai_agent').length,
|
|
266
|
+
consecutiveAgentTurns,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
153
269
|
function writeState(session) {
|
|
154
270
|
writeSessionState(session.conversationId, agentId, {
|
|
271
|
+
lastError: session.state.lastError,
|
|
155
272
|
model: session.state.model,
|
|
156
273
|
cwd: session.cwd,
|
|
157
274
|
hostMode: true,
|
|
@@ -160,6 +277,25 @@ async function main() {
|
|
|
160
277
|
isActive: true,
|
|
161
278
|
}).catch(() => { });
|
|
162
279
|
}
|
|
280
|
+
function writeTurn(session) {
|
|
281
|
+
writeTurnState(session.conversationId, agentId, {
|
|
282
|
+
turnId: session.currentTurnId,
|
|
283
|
+
state: session.turnState,
|
|
284
|
+
queueDepth: session.queue.length,
|
|
285
|
+
currentSpeakerId: agentId,
|
|
286
|
+
lastAcceptedIntent: session.lastAcceptedIntent,
|
|
287
|
+
capabilities: CODEX_RUNTIME_CAPABILITIES,
|
|
288
|
+
...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
|
|
289
|
+
...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
|
|
290
|
+
? { completedAt: { '.sv': 'timestamp' } }
|
|
291
|
+
: {}),
|
|
292
|
+
}).catch(() => { });
|
|
293
|
+
}
|
|
294
|
+
async function markQueuedMessageAccepted(conversationId, sourceMessageId, markAccepted) {
|
|
295
|
+
if (!markAccepted || !sourceMessageId)
|
|
296
|
+
return;
|
|
297
|
+
await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
|
|
298
|
+
}
|
|
163
299
|
function clearStreaming(conversationId) {
|
|
164
300
|
rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
|
|
165
301
|
}
|
|
@@ -170,6 +306,7 @@ async function main() {
|
|
|
170
306
|
session.closed = true;
|
|
171
307
|
clearStreaming(conversationId);
|
|
172
308
|
clearSessionState(conversationId, agentId).catch(() => { });
|
|
309
|
+
clearTurnState(conversationId, agentId).catch(() => { });
|
|
173
310
|
client.setTyping(conversationId, false).catch(() => { });
|
|
174
311
|
sessions.delete(conversationId);
|
|
175
312
|
}
|
|
@@ -227,11 +364,16 @@ async function main() {
|
|
|
227
364
|
model: sessionModel,
|
|
228
365
|
state: 'idle',
|
|
229
366
|
},
|
|
367
|
+
turnState: 'idle',
|
|
368
|
+
currentTurnId: null,
|
|
369
|
+
currentTurnOpenedAt: null,
|
|
370
|
+
lastAcceptedIntent: null,
|
|
230
371
|
lastActivity: Date.now(),
|
|
231
372
|
closed: false,
|
|
232
373
|
};
|
|
233
374
|
sessions.set(conversationId, session);
|
|
234
375
|
writeState(session);
|
|
376
|
+
writeTurn(session);
|
|
235
377
|
return session;
|
|
236
378
|
})();
|
|
237
379
|
pendingSessionCreations.set(conversationId, creation);
|
|
@@ -242,32 +384,68 @@ async function main() {
|
|
|
242
384
|
pendingSessionCreations.delete(conversationId);
|
|
243
385
|
}
|
|
244
386
|
}
|
|
245
|
-
function enqueuePrompt(session, prompt) {
|
|
246
|
-
|
|
387
|
+
function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false) {
|
|
388
|
+
const nextPrompt = { prompt, intent, sourceMessageId, markAccepted };
|
|
389
|
+
if (toFront) {
|
|
390
|
+
session.queue.unshift(nextPrompt);
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
session.queue.push(nextPrompt);
|
|
394
|
+
}
|
|
247
395
|
session.lastActivity = Date.now();
|
|
396
|
+
writeTurn(session);
|
|
248
397
|
void runNextTurn(session);
|
|
249
398
|
}
|
|
250
399
|
async function enqueueInboundMessage(input) {
|
|
251
400
|
const content = renderInboundContent(input.message);
|
|
252
|
-
|
|
401
|
+
const participantContext = await loadParticipantContext({
|
|
402
|
+
conversationId: input.conversationId,
|
|
403
|
+
message: input.message,
|
|
404
|
+
senderName: input.senderName,
|
|
405
|
+
isOwner: input.isOwner,
|
|
406
|
+
});
|
|
407
|
+
const autoReply = decideAutoReply(participantContext);
|
|
408
|
+
if (!autoReply.allow) {
|
|
409
|
+
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
|
|
253
413
|
const session = await getOrCreateSession(input.conversationId);
|
|
254
|
-
|
|
414
|
+
const turnMetadata = normalizeTurnMetadata(input.message.metadata);
|
|
415
|
+
const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
|
|
416
|
+
const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
|
|
417
|
+
const prompt = buildCanonPrompt({
|
|
255
418
|
content,
|
|
256
419
|
conversationId: input.conversationId,
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
420
|
+
participantContext,
|
|
421
|
+
});
|
|
422
|
+
if (session.running && deliveryIntent === 'interrupt') {
|
|
423
|
+
enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted);
|
|
424
|
+
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
|
|
425
|
+
await session.adapter.interrupt().catch(() => { });
|
|
426
|
+
clearStreaming(input.conversationId);
|
|
427
|
+
client.setTyping(input.conversationId, false).catch(() => { });
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted);
|
|
260
431
|
}
|
|
261
432
|
async function runNextTurn(session) {
|
|
262
433
|
if (session.running || session.closed)
|
|
263
434
|
return;
|
|
264
|
-
const
|
|
265
|
-
if (!
|
|
435
|
+
const nextTurn = session.queue.shift();
|
|
436
|
+
if (!nextTurn)
|
|
266
437
|
return;
|
|
267
438
|
session.running = true;
|
|
439
|
+
session.state.lastError = undefined;
|
|
268
440
|
session.state.state = 'running';
|
|
441
|
+
session.currentTurnId = randomUUID();
|
|
442
|
+
session.currentTurnOpenedAt = Date.now();
|
|
443
|
+
session.lastAcceptedIntent = nextTurn.intent;
|
|
444
|
+
session.turnState = 'thinking';
|
|
269
445
|
session.lastActivity = Date.now();
|
|
446
|
+
await markQueuedMessageAccepted(session.conversationId, nextTurn.sourceMessageId, nextTurn.markAccepted);
|
|
270
447
|
writeState(session);
|
|
448
|
+
writeTurn(session);
|
|
271
449
|
client.setTyping(session.conversationId, true, 'thinking').catch(() => { });
|
|
272
450
|
rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
|
|
273
451
|
text: 'Thinking…',
|
|
@@ -275,7 +453,7 @@ async function main() {
|
|
|
275
453
|
updatedAt: { '.sv': 'timestamp' },
|
|
276
454
|
}).catch(() => { });
|
|
277
455
|
try {
|
|
278
|
-
const result = await session.adapter.runTurn(prompt, (event) => {
|
|
456
|
+
const result = await session.adapter.runTurn(nextTurn.prompt, (event) => {
|
|
279
457
|
session.lastActivity = Date.now();
|
|
280
458
|
if (event.type === 'thread.started') {
|
|
281
459
|
saveStoredThreadId(agentId, session.conversationId, session.cwd, event.threadId);
|
|
@@ -283,6 +461,8 @@ async function main() {
|
|
|
283
461
|
return;
|
|
284
462
|
}
|
|
285
463
|
if (event.type === 'message') {
|
|
464
|
+
session.turnState = 'streaming';
|
|
465
|
+
writeTurn(session);
|
|
286
466
|
rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
|
|
287
467
|
text: event.text,
|
|
288
468
|
status: 'streaming',
|
|
@@ -291,6 +471,8 @@ async function main() {
|
|
|
291
471
|
return;
|
|
292
472
|
}
|
|
293
473
|
if (event.type === 'command.started') {
|
|
474
|
+
session.turnState = 'tool';
|
|
475
|
+
writeTurn(session);
|
|
294
476
|
client.setTyping(session.conversationId, false).catch(() => { });
|
|
295
477
|
rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
|
|
296
478
|
text: summarizeCommand(event.command),
|
|
@@ -300,6 +482,8 @@ async function main() {
|
|
|
300
482
|
return;
|
|
301
483
|
}
|
|
302
484
|
if (event.type === 'turn.completed') {
|
|
485
|
+
session.turnState = 'completed';
|
|
486
|
+
writeTurn(session);
|
|
303
487
|
writeState(session);
|
|
304
488
|
}
|
|
305
489
|
}, (line) => {
|
|
@@ -311,31 +495,71 @@ async function main() {
|
|
|
311
495
|
clearStreaming(session.conversationId);
|
|
312
496
|
client.setTyping(session.conversationId, false).catch(() => { });
|
|
313
497
|
if (!result.interrupted && result.finalMessage) {
|
|
314
|
-
|
|
498
|
+
session.turnState = 'completed';
|
|
499
|
+
writeTurn(session);
|
|
500
|
+
await client.sendMessage(session.conversationId, result.finalMessage, {
|
|
501
|
+
metadata: {
|
|
502
|
+
turnId: session.currentTurnId,
|
|
503
|
+
turnSemantics: 'turn_complete',
|
|
504
|
+
turnComplete: true,
|
|
505
|
+
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
506
|
+
},
|
|
507
|
+
});
|
|
315
508
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${result.finalMessage.length} chars)`);
|
|
316
509
|
}
|
|
317
510
|
else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
|
|
511
|
+
const userVisibleError = formatTurnFailure(result.errorText);
|
|
512
|
+
session.state.lastError = userVisibleError;
|
|
513
|
+
session.turnState = 'completed';
|
|
514
|
+
writeState(session);
|
|
515
|
+
writeTurn(session);
|
|
318
516
|
if (result.errorText) {
|
|
319
517
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
|
|
320
518
|
}
|
|
321
|
-
await client.sendMessage(session.conversationId,
|
|
519
|
+
await client.sendMessage(session.conversationId, userVisibleError, {
|
|
520
|
+
metadata: {
|
|
521
|
+
turnId: session.currentTurnId,
|
|
522
|
+
turnSemantics: 'turn_complete',
|
|
523
|
+
turnComplete: true,
|
|
524
|
+
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
525
|
+
},
|
|
526
|
+
});
|
|
322
527
|
}
|
|
323
528
|
else if (result.interrupted) {
|
|
529
|
+
session.turnState = 'interrupted';
|
|
530
|
+
writeTurn(session);
|
|
324
531
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn interrupted`);
|
|
325
532
|
}
|
|
326
533
|
}
|
|
327
534
|
catch (error) {
|
|
328
535
|
clearStreaming(session.conversationId);
|
|
329
536
|
client.setTyping(session.conversationId, false).catch(() => { });
|
|
330
|
-
|
|
537
|
+
const message = `The Codex host failed to start a turn: ${error instanceof Error ? error.message : String(error)}`;
|
|
538
|
+
session.state.lastError = message;
|
|
539
|
+
session.turnState = 'completed';
|
|
540
|
+
writeState(session);
|
|
541
|
+
writeTurn(session);
|
|
542
|
+
await client.sendMessage(session.conversationId, message, {
|
|
543
|
+
metadata: {
|
|
544
|
+
turnId: session.currentTurnId,
|
|
545
|
+
turnSemantics: 'turn_complete',
|
|
546
|
+
turnComplete: true,
|
|
547
|
+
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
548
|
+
},
|
|
549
|
+
}).catch(() => { });
|
|
331
550
|
clearStoredThreadId(agentId, session.conversationId);
|
|
332
551
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
|
|
333
552
|
}
|
|
334
553
|
finally {
|
|
335
554
|
session.running = false;
|
|
336
555
|
session.state.state = 'idle';
|
|
556
|
+
session.turnState = 'idle';
|
|
557
|
+
session.currentTurnId = null;
|
|
558
|
+
session.currentTurnOpenedAt = null;
|
|
559
|
+
session.lastAcceptedIntent = null;
|
|
337
560
|
session.lastActivity = Date.now();
|
|
338
561
|
writeState(session);
|
|
562
|
+
writeTurn(session);
|
|
339
563
|
if (session.queue.length > 0) {
|
|
340
564
|
void runNextTurn(session);
|
|
341
565
|
}
|
|
@@ -376,6 +600,7 @@ async function main() {
|
|
|
376
600
|
for (const conversation of conversations) {
|
|
377
601
|
clearStreaming(conversation.id);
|
|
378
602
|
clearSessionState(conversation.id, agentId).catch(() => { });
|
|
603
|
+
clearTurnState(conversation.id, agentId).catch(() => { });
|
|
379
604
|
}
|
|
380
605
|
for (const conversation of conversations) {
|
|
381
606
|
if (!conversation.lastMessage || conversation.lastMessage.senderId === agentId)
|
|
@@ -384,6 +609,18 @@ async function main() {
|
|
|
384
609
|
const latestMessage = latestMessages[0];
|
|
385
610
|
if (!latestMessage || latestMessage.senderId === agentId)
|
|
386
611
|
continue;
|
|
612
|
+
const senderTurnState = latestMessage.senderType === 'ai_agent'
|
|
613
|
+
? await loadSenderRuntimeState(conversation.id, latestMessage.senderId)
|
|
614
|
+
: null;
|
|
615
|
+
const triggerDecision = shouldTriggerAgentTurn({
|
|
616
|
+
senderType: latestMessage.senderType ?? 'human',
|
|
617
|
+
metadata: latestMessage.metadata,
|
|
618
|
+
senderTurnState,
|
|
619
|
+
});
|
|
620
|
+
if (!triggerDecision.allow) {
|
|
621
|
+
console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Skipping startup recovery for suppressed message (${triggerDecision.reason})`);
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
387
624
|
console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Recovering latest inbound message on startup`);
|
|
388
625
|
await enqueueInboundMessage({
|
|
389
626
|
conversationId: conversation.id,
|
|
@@ -434,16 +671,21 @@ async function main() {
|
|
|
434
671
|
continue;
|
|
435
672
|
const signal = raw;
|
|
436
673
|
const timestamp = signal.updatedAt ?? 0;
|
|
437
|
-
if (signal.type !== 'interrupt'
|
|
674
|
+
if ((signal.type !== 'interrupt' && signal.type !== 'stop_and_drop')
|
|
675
|
+
|| timestamp <= (lastSeenSignal.get(conversationId) ?? 0)) {
|
|
438
676
|
continue;
|
|
439
677
|
}
|
|
440
678
|
lastSeenSignal.set(conversationId, timestamp);
|
|
441
679
|
const session = sessions.get(conversationId);
|
|
442
680
|
if (!session || session.closed)
|
|
443
681
|
continue;
|
|
444
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}]
|
|
682
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${signal.type} signal`);
|
|
445
683
|
await session.adapter.interrupt();
|
|
446
|
-
session.
|
|
684
|
+
session.turnState = 'interrupted';
|
|
685
|
+
if (signal.type === 'stop_and_drop') {
|
|
686
|
+
session.queue.length = 0;
|
|
687
|
+
}
|
|
688
|
+
writeTurn(session);
|
|
447
689
|
clearStreaming(conversationId);
|
|
448
690
|
client.setTyping(conversationId, false).catch(() => { });
|
|
449
691
|
}
|
|
@@ -458,6 +700,7 @@ async function main() {
|
|
|
458
700
|
const heartbeat = setInterval(() => {
|
|
459
701
|
for (const session of sessions.values()) {
|
|
460
702
|
writeState(session);
|
|
703
|
+
writeTurn(session);
|
|
461
704
|
}
|
|
462
705
|
}, HEARTBEAT_MS);
|
|
463
706
|
const idleCheck = setInterval(() => {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CanonConversation } from '@canonmsg/core';
|
|
2
|
+
export interface InboundParticipantContext {
|
|
3
|
+
conversationType: CanonConversation['type'] | 'unknown';
|
|
4
|
+
memberCount: number | null;
|
|
5
|
+
senderType: 'human' | 'ai_agent';
|
|
6
|
+
senderName: string;
|
|
7
|
+
isOwner: boolean;
|
|
8
|
+
mentionedAgent: boolean;
|
|
9
|
+
recentSenderTypes: Array<'human' | 'ai_agent'>;
|
|
10
|
+
recentHumanCount: number;
|
|
11
|
+
recentAgentCount: number;
|
|
12
|
+
consecutiveAgentTurns: number;
|
|
13
|
+
}
|
|
14
|
+
export interface AutoReplyDecision {
|
|
15
|
+
allow: boolean;
|
|
16
|
+
reason: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function buildInboundContextLines(context: InboundParticipantContext): string[];
|
|
19
|
+
export declare function decideAutoReply(context: InboundParticipantContext): AutoReplyDecision;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
function formatRecentSenders(senderTypes) {
|
|
2
|
+
if (senderTypes.length === 0)
|
|
3
|
+
return 'none';
|
|
4
|
+
return senderTypes.map((senderType) => (senderType === 'ai_agent' ? 'agent' : 'human')).join(' -> ');
|
|
5
|
+
}
|
|
6
|
+
export function buildInboundContextLines(context) {
|
|
7
|
+
const conversationTypeLabel = context.conversationType === 'unknown'
|
|
8
|
+
? 'unknown'
|
|
9
|
+
: `${context.conversationType}${context.memberCount ? ` (${context.memberCount} members)` : ''}`;
|
|
10
|
+
const senderRole = context.isOwner
|
|
11
|
+
? 'The latest sender is the verified human owner of this Canon agent.'
|
|
12
|
+
: context.senderType === 'ai_agent'
|
|
13
|
+
? 'The latest sender is another AI agent in Canon.'
|
|
14
|
+
: 'The latest sender is a human Canon participant.';
|
|
15
|
+
return [
|
|
16
|
+
senderRole,
|
|
17
|
+
`Latest sender name: ${context.senderName}`,
|
|
18
|
+
`Latest sender type: ${context.senderType}`,
|
|
19
|
+
`Conversation type: ${conversationTypeLabel}`,
|
|
20
|
+
`Directly addressed to this agent: ${context.mentionedAgent ? 'yes' : 'no'}`,
|
|
21
|
+
`Recent sender pattern: ${formatRecentSenders(context.recentSenderTypes)}`,
|
|
22
|
+
`Recent human messages: ${context.recentHumanCount}`,
|
|
23
|
+
`Recent agent messages: ${context.recentAgentCount}`,
|
|
24
|
+
`Consecutive recent agent turns: ${context.consecutiveAgentTurns}`,
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
export function decideAutoReply(context) {
|
|
28
|
+
if (context.senderType !== 'ai_agent') {
|
|
29
|
+
return { allow: true, reason: 'latest sender is human' };
|
|
30
|
+
}
|
|
31
|
+
if (context.isOwner) {
|
|
32
|
+
return { allow: true, reason: 'owner messages always pass through' };
|
|
33
|
+
}
|
|
34
|
+
if (context.mentionedAgent) {
|
|
35
|
+
return { allow: true, reason: 'another agent explicitly addressed this agent' };
|
|
36
|
+
}
|
|
37
|
+
if (context.conversationType === 'group') {
|
|
38
|
+
return {
|
|
39
|
+
allow: false,
|
|
40
|
+
reason: 'suppressing group agent auto-reply without a direct mention',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (context.consecutiveAgentTurns >= 2 && context.recentHumanCount === 0) {
|
|
44
|
+
return {
|
|
45
|
+
allow: false,
|
|
46
|
+
reason: 'suppressing likely agent-only loop in a direct conversation',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return { allow: true, reason: 'direct agent message allowed' };
|
|
50
|
+
}
|
package/dist/register.js
CHANGED
|
File without changes
|
package/dist/setup.js
CHANGED
|
@@ -11,6 +11,9 @@ else {
|
|
|
11
11
|
console.log('Install Codex first, then rerun this setup.\n');
|
|
12
12
|
}
|
|
13
13
|
console.log('Next steps:');
|
|
14
|
+
console.log(' 0. Confirm Codex is logged in the way you want Canon to use');
|
|
15
|
+
console.log(' codex login status');
|
|
16
|
+
console.log('');
|
|
14
17
|
console.log(' 1. Register your agent');
|
|
15
18
|
console.log(' canon-codex-register --name "My Codex" --description "My local coding agent" --phone "+15551234567"');
|
|
16
19
|
console.log('');
|
|
@@ -20,5 +23,7 @@ console.log('');
|
|
|
20
23
|
console.log('Optional flags:');
|
|
21
24
|
console.log(' --model gpt-5.4');
|
|
22
25
|
console.log(' --sandbox workspace-write');
|
|
23
|
-
console.log(' --ask-for-approval never');
|
|
24
26
|
console.log(' --full-auto');
|
|
27
|
+
console.log('');
|
|
28
|
+
console.log('Note: recent Codex CLI versions use --full-auto for non-interactive write access.');
|
|
29
|
+
console.log('Note: Canon uses the local Codex login state by default (for example ChatGPT/device auth or API-key auth).');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/codex-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Canon host integration for Codex CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/host.js",
|
|
@@ -11,15 +11,18 @@
|
|
|
11
11
|
"canon-codex-setup": "dist/setup.js"
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
|
-
"dist"
|
|
14
|
+
"dist",
|
|
15
|
+
"scripts"
|
|
15
16
|
],
|
|
16
17
|
"scripts": {
|
|
17
|
-
"build": "tsc",
|
|
18
|
+
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
|
|
18
19
|
"dev": "tsc --watch",
|
|
19
|
-
"
|
|
20
|
+
"smoke": "node scripts/smoke-test.mjs",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"prepack": "npm run build"
|
|
20
23
|
},
|
|
21
24
|
"dependencies": {
|
|
22
|
-
"@canonmsg/core": "^0.
|
|
25
|
+
"@canonmsg/core": "^0.3.0"
|
|
23
26
|
},
|
|
24
27
|
"engines": {
|
|
25
28
|
"node": ">=18.0.0"
|
|
@@ -42,7 +45,8 @@
|
|
|
42
45
|
},
|
|
43
46
|
"devDependencies": {
|
|
44
47
|
"@types/node": "^22.0.0",
|
|
45
|
-
"typescript": "~5.7.0"
|
|
48
|
+
"typescript": "~5.7.0",
|
|
49
|
+
"vitest": "^3.0.0"
|
|
46
50
|
},
|
|
47
51
|
"license": "MIT"
|
|
48
52
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const targetCwd = resolve(process.argv[2] || process.cwd());
|
|
7
|
+
const prompt = 'Reply with exactly OK.';
|
|
8
|
+
|
|
9
|
+
function run(command, args) {
|
|
10
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
11
|
+
const child = spawn(command, args, {
|
|
12
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
let stdout = '';
|
|
16
|
+
let stderr = '';
|
|
17
|
+
|
|
18
|
+
child.stdout.on('data', (chunk) => {
|
|
19
|
+
stdout += chunk.toString();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
child.stderr.on('data', (chunk) => {
|
|
23
|
+
stderr += chunk.toString();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
child.on('error', rejectPromise);
|
|
27
|
+
child.on('close', (code) => {
|
|
28
|
+
resolvePromise({ code, stdout, stderr });
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseJsonLines(text) {
|
|
34
|
+
return text
|
|
35
|
+
.split('\n')
|
|
36
|
+
.map((line) => line.trim())
|
|
37
|
+
.filter((line) => line.startsWith('{'))
|
|
38
|
+
.map((line) => {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(line);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const version = await run('codex', ['--version']);
|
|
49
|
+
if (version.code !== 0) {
|
|
50
|
+
console.error('codex --version failed');
|
|
51
|
+
process.exit(version.code ?? 1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
console.log(version.stdout.trim());
|
|
55
|
+
console.log(`Workspace: ${targetCwd}`);
|
|
56
|
+
|
|
57
|
+
const result = await run('codex', [
|
|
58
|
+
'exec',
|
|
59
|
+
'--json',
|
|
60
|
+
'--color',
|
|
61
|
+
'never',
|
|
62
|
+
'-C',
|
|
63
|
+
targetCwd,
|
|
64
|
+
'--skip-git-repo-check',
|
|
65
|
+
prompt,
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
const events = parseJsonLines(result.stdout);
|
|
69
|
+
const threadStarted = events.find((event) => event?.type === 'thread.started');
|
|
70
|
+
const turnFailed = events.find((event) => event?.type === 'turn.failed');
|
|
71
|
+
const messageEvent = [...events].reverse().find(
|
|
72
|
+
(event) => event?.type === 'item.completed' && event?.item?.type === 'agent_message',
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (threadStarted) {
|
|
76
|
+
console.log(`Thread started: ${threadStarted.thread_id}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (messageEvent?.item?.text) {
|
|
80
|
+
console.log(`Final message: ${String(messageEvent.item.text).trim()}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (turnFailed?.error?.message) {
|
|
84
|
+
console.error(`Turn failed: ${turnFailed.error.message}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (result.stderr.trim()) {
|
|
88
|
+
console.error('stderr:');
|
|
89
|
+
console.error(result.stderr.trim());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
process.exit(result.code ?? 1);
|