@canonmsg/codex-plugin 0.1.0 → 0.1.1
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 +32 -1
- package/dist/adapter.js +36 -5
- package/dist/adapter.test.d.ts +1 -0
- package/dist/adapter.test.js +59 -0
- package/dist/host.js +111 -12
- package/dist/inbound-policy.d.ts +19 -0
- package/dist/inbound-policy.js +50 -0
- package/dist/inbound-policy.test.d.ts +1 -0
- package/dist/inbound-policy.test.js +97 -0
- package/dist/setup.js +6 -1
- package/package.json +7 -3
- 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
|
|
|
@@ -37,9 +42,35 @@ canon-codex --cwd /path/to/project
|
|
|
37
42
|
Useful flags:
|
|
38
43
|
|
|
39
44
|
```bash
|
|
40
|
-
canon-codex --cwd /path/to/project --model gpt-5.4 --
|
|
45
|
+
canon-codex --cwd /path/to/project --model gpt-5.4 --full-auto
|
|
41
46
|
```
|
|
42
47
|
|
|
48
|
+
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`.
|
|
49
|
+
|
|
50
|
+
Local smoke test:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm run smoke -- /path/to/project
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Troubleshooting
|
|
57
|
+
|
|
58
|
+
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:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
codex login status
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
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:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
codex logout
|
|
68
|
+
codex login --device-auth
|
|
69
|
+
codex login status
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Restart `canon-codex` after changing the Codex login state.
|
|
73
|
+
|
|
43
74
|
## Multiple agents
|
|
44
75
|
|
|
45
76
|
```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...',
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { PassThrough } from 'node:stream';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
const { spawnMock } = vi.hoisted(() => ({
|
|
5
|
+
spawnMock: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
vi.mock('node:child_process', () => ({
|
|
8
|
+
spawn: spawnMock,
|
|
9
|
+
}));
|
|
10
|
+
import { CodexConversationAdapter } from './adapter.js';
|
|
11
|
+
class MockChildProcess extends EventEmitter {
|
|
12
|
+
stdout = new PassThrough();
|
|
13
|
+
stderr = new PassThrough();
|
|
14
|
+
}
|
|
15
|
+
describe('CodexConversationAdapter', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
spawnMock.mockReset();
|
|
18
|
+
});
|
|
19
|
+
it('translates legacy non-interactive approval flags into --full-auto', () => {
|
|
20
|
+
const adapter = new CodexConversationAdapter({
|
|
21
|
+
cwd: '/tmp/project',
|
|
22
|
+
sandbox: 'workspace-write',
|
|
23
|
+
approvalPolicy: 'never',
|
|
24
|
+
});
|
|
25
|
+
const args = adapter.buildArgs('hello');
|
|
26
|
+
expect(args).toContain('--full-auto');
|
|
27
|
+
expect(args).not.toContain('-a');
|
|
28
|
+
expect(args).not.toContain('--ask-for-approval');
|
|
29
|
+
});
|
|
30
|
+
it('does not force --full-auto for read-only sessions', () => {
|
|
31
|
+
const adapter = new CodexConversationAdapter({
|
|
32
|
+
cwd: '/tmp/project',
|
|
33
|
+
sandbox: 'read-only',
|
|
34
|
+
approvalPolicy: 'never',
|
|
35
|
+
});
|
|
36
|
+
const args = adapter.buildArgs('hello');
|
|
37
|
+
expect(args).toContain('-s');
|
|
38
|
+
expect(args).toContain('read-only');
|
|
39
|
+
expect(args).not.toContain('--full-auto');
|
|
40
|
+
});
|
|
41
|
+
it('preserves structured turn failure text from Codex JSON output', async () => {
|
|
42
|
+
const child = new MockChildProcess();
|
|
43
|
+
spawnMock.mockReturnValue(child);
|
|
44
|
+
const adapter = new CodexConversationAdapter({
|
|
45
|
+
cwd: '/tmp/project',
|
|
46
|
+
});
|
|
47
|
+
const turnPromise = adapter.runTurn('hello', () => { });
|
|
48
|
+
child.stdout.write('{"type":"thread.started","thread_id":"thread-123"}\n');
|
|
49
|
+
child.stdout.write('{"type":"turn.started"}\n');
|
|
50
|
+
child.stdout.write('{"type":"error","message":"Quota exceeded. Check your plan and billing details."}\n');
|
|
51
|
+
child.stdout.write('{"type":"turn.failed","error":{"message":"Quota exceeded. Check your plan and billing details."}}\n');
|
|
52
|
+
child.emit('close', 1);
|
|
53
|
+
const result = await turnPromise;
|
|
54
|
+
expect(result.threadId).toBe('thread-123');
|
|
55
|
+
expect(result.exitCode).toBe(1);
|
|
56
|
+
expect(result.finalMessage).toBeNull();
|
|
57
|
+
expect(result.errorText).toBe('Quota exceeded. Check your plan and billing details.');
|
|
58
|
+
});
|
|
59
|
+
});
|
package/dist/host.js
CHANGED
|
@@ -4,6 +4,7 @@ setDefaultResultOrder('ipv4first');
|
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
5
|
import { basename, resolve } from 'node:path';
|
|
6
6
|
import { CanonClient, CanonStream, clearSessionState, getActiveProfile, initRTDBAuth, releaseLock, resolveCanonAgent, rtdbRead, rtdbWrite, writeSessionState, } from '@canonmsg/core';
|
|
7
|
+
import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
|
|
7
8
|
import { CodexConversationAdapter, } from './adapter.js';
|
|
8
9
|
import { clearStoredThreadId, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
|
|
9
10
|
const MAX_SESSIONS = 12;
|
|
@@ -69,17 +70,15 @@ function toPublicWorkspaceOptions() {
|
|
|
69
70
|
return workspaceOptions.map(({ id, label }) => ({ id, label }));
|
|
70
71
|
}
|
|
71
72
|
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
73
|
return [
|
|
76
74
|
'You are connected to Canon messaging through a Codex host wrapper.',
|
|
77
|
-
'Reply naturally to
|
|
75
|
+
'Reply naturally to Canon participants.',
|
|
78
76
|
'Only the last assistant message from this turn will be delivered as the permanent Canon reply.',
|
|
79
77
|
'Short intermediate assistant messages may be shown as ephemeral status while you work.',
|
|
80
|
-
|
|
78
|
+
...buildInboundContextLines(input.participantContext),
|
|
79
|
+
'Canon participants may be humans or AI agents.',
|
|
80
|
+
'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
81
|
`Conversation ID: ${input.conversationId}`,
|
|
82
|
-
`Sender: ${input.senderName}`,
|
|
83
82
|
'',
|
|
84
83
|
'New Canon message:',
|
|
85
84
|
input.content,
|
|
@@ -87,7 +86,25 @@ function buildCanonPrompt(input) {
|
|
|
87
86
|
}
|
|
88
87
|
function renderInboundContent(message) {
|
|
89
88
|
let content = message.text || '';
|
|
90
|
-
|
|
89
|
+
const attachment = message.attachments?.[0];
|
|
90
|
+
if (attachment?.kind === 'audio' && attachment.url) {
|
|
91
|
+
const duration = attachment.durationMs ? ` (${Math.round(attachment.durationMs / 1000)}s)` : '';
|
|
92
|
+
content = content
|
|
93
|
+
? `[Voice message${duration}: ${attachment.url}]\n${content}`
|
|
94
|
+
: `[Voice message${duration}: ${attachment.url}]`;
|
|
95
|
+
}
|
|
96
|
+
else if (attachment?.kind === 'image' && attachment.url) {
|
|
97
|
+
content = content
|
|
98
|
+
? `[Image: ${attachment.url}]\n${content}`
|
|
99
|
+
: `[Image: ${attachment.url}]`;
|
|
100
|
+
}
|
|
101
|
+
else if (attachment?.kind === 'file' && attachment.url) {
|
|
102
|
+
const label = attachment.fileName || 'File';
|
|
103
|
+
content = content
|
|
104
|
+
? `[File: ${label} ${attachment.url}]\n${content}`
|
|
105
|
+
: `[File: ${label} ${attachment.url}]`;
|
|
106
|
+
}
|
|
107
|
+
else if (message.contentType === 'audio' && message.audioUrl) {
|
|
91
108
|
const duration = message.audioDurationMs ? ` (${Math.round(message.audioDurationMs / 1000)}s)` : '';
|
|
92
109
|
content = content
|
|
93
110
|
? `[Voice message${duration}: ${message.audioUrl}]\n${content}`
|
|
@@ -107,6 +124,14 @@ function summarizeCommand(command) {
|
|
|
107
124
|
const shortened = trimmed.length > 140 ? `${trimmed.slice(0, 137)}...` : trimmed;
|
|
108
125
|
return `Running: ${shortened}`;
|
|
109
126
|
}
|
|
127
|
+
function formatTurnFailure(errorText) {
|
|
128
|
+
if (!errorText) {
|
|
129
|
+
return 'The Codex session stopped unexpectedly before sending a final reply.';
|
|
130
|
+
}
|
|
131
|
+
const normalized = errorText.replace(/^error:\s*/i, '').trim();
|
|
132
|
+
const shortened = normalized.length > 280 ? `${normalized.slice(0, 277)}...` : normalized;
|
|
133
|
+
return `Codex failed before sending a final reply: ${shortened}`;
|
|
134
|
+
}
|
|
110
135
|
async function main() {
|
|
111
136
|
const { values: args } = parseArgs({
|
|
112
137
|
options: {
|
|
@@ -126,6 +151,9 @@ async function main() {
|
|
|
126
151
|
});
|
|
127
152
|
workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
|
|
128
153
|
workspaceOptions = buildWorkspaceOptions(workingDir, args.workspace ?? []);
|
|
154
|
+
if (typeof args['ask-for-approval'] === 'string') {
|
|
155
|
+
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.');
|
|
156
|
+
}
|
|
129
157
|
const { apiKey, agentId: profileAgentId, profile } = resolveCanonAgent({ logPrefix: '[canon-codex]' });
|
|
130
158
|
console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
|
|
131
159
|
const client = new CanonClient(apiKey);
|
|
@@ -150,8 +178,62 @@ async function main() {
|
|
|
150
178
|
}
|
|
151
179
|
const sessions = new Map();
|
|
152
180
|
const pendingSessionCreations = new Map();
|
|
181
|
+
const conversationCache = new Map();
|
|
182
|
+
let conversationCacheLoadedAt = 0;
|
|
183
|
+
async function refreshConversationCache(force = false) {
|
|
184
|
+
if (!force && conversationCache.size > 0 && Date.now() - conversationCacheLoadedAt < 10_000) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const conversations = await client.getConversations();
|
|
188
|
+
conversationCache.clear();
|
|
189
|
+
for (const conversation of conversations) {
|
|
190
|
+
conversationCache.set(conversation.id, conversation);
|
|
191
|
+
}
|
|
192
|
+
conversationCacheLoadedAt = Date.now();
|
|
193
|
+
}
|
|
194
|
+
async function getConversationMeta(conversationId) {
|
|
195
|
+
try {
|
|
196
|
+
await refreshConversationCache();
|
|
197
|
+
const cached = conversationCache.get(conversationId);
|
|
198
|
+
if (cached)
|
|
199
|
+
return cached;
|
|
200
|
+
await refreshConversationCache(true);
|
|
201
|
+
return conversationCache.get(conversationId) ?? null;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return conversationCache.get(conversationId) ?? null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async function loadParticipantContext(input) {
|
|
208
|
+
const [conversation, recentMessages] = await Promise.all([
|
|
209
|
+
getConversationMeta(input.conversationId),
|
|
210
|
+
client.getMessages(input.conversationId, 6).catch(() => []),
|
|
211
|
+
]);
|
|
212
|
+
const recentSenderTypes = recentMessages
|
|
213
|
+
.filter((message) => message.senderId !== agentId)
|
|
214
|
+
.map((message) => message.senderType);
|
|
215
|
+
let consecutiveAgentTurns = 0;
|
|
216
|
+
for (const senderType of recentSenderTypes) {
|
|
217
|
+
if (senderType !== 'ai_agent')
|
|
218
|
+
break;
|
|
219
|
+
consecutiveAgentTurns += 1;
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
conversationType: conversation?.type ?? 'unknown',
|
|
223
|
+
memberCount: conversation?.memberIds?.length ?? null,
|
|
224
|
+
senderType: input.message.senderType ?? 'human',
|
|
225
|
+
senderName: input.senderName,
|
|
226
|
+
isOwner: input.isOwner,
|
|
227
|
+
mentionedAgent: Array.isArray(input.message.mentions) && input.message.mentions.includes(agentId),
|
|
228
|
+
recentSenderTypes,
|
|
229
|
+
recentHumanCount: recentSenderTypes.filter((senderType) => senderType === 'human').length,
|
|
230
|
+
recentAgentCount: recentSenderTypes.filter((senderType) => senderType === 'ai_agent').length,
|
|
231
|
+
consecutiveAgentTurns,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
153
234
|
function writeState(session) {
|
|
154
235
|
writeSessionState(session.conversationId, agentId, {
|
|
236
|
+
lastError: session.state.lastError,
|
|
155
237
|
model: session.state.model,
|
|
156
238
|
cwd: session.cwd,
|
|
157
239
|
hostMode: true,
|
|
@@ -249,13 +331,23 @@ async function main() {
|
|
|
249
331
|
}
|
|
250
332
|
async function enqueueInboundMessage(input) {
|
|
251
333
|
const content = renderInboundContent(input.message);
|
|
252
|
-
|
|
334
|
+
const participantContext = await loadParticipantContext({
|
|
335
|
+
conversationId: input.conversationId,
|
|
336
|
+
message: input.message,
|
|
337
|
+
senderName: input.senderName,
|
|
338
|
+
isOwner: input.isOwner,
|
|
339
|
+
});
|
|
340
|
+
const autoReply = decideAutoReply(participantContext);
|
|
341
|
+
if (!autoReply.allow) {
|
|
342
|
+
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
|
|
253
346
|
const session = await getOrCreateSession(input.conversationId);
|
|
254
347
|
enqueuePrompt(session, buildCanonPrompt({
|
|
255
348
|
content,
|
|
256
349
|
conversationId: input.conversationId,
|
|
257
|
-
|
|
258
|
-
senderName: input.senderName,
|
|
350
|
+
participantContext,
|
|
259
351
|
}));
|
|
260
352
|
}
|
|
261
353
|
async function runNextTurn(session) {
|
|
@@ -265,6 +357,7 @@ async function main() {
|
|
|
265
357
|
if (!prompt)
|
|
266
358
|
return;
|
|
267
359
|
session.running = true;
|
|
360
|
+
session.state.lastError = undefined;
|
|
268
361
|
session.state.state = 'running';
|
|
269
362
|
session.lastActivity = Date.now();
|
|
270
363
|
writeState(session);
|
|
@@ -315,10 +408,13 @@ async function main() {
|
|
|
315
408
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${result.finalMessage.length} chars)`);
|
|
316
409
|
}
|
|
317
410
|
else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
|
|
411
|
+
const userVisibleError = formatTurnFailure(result.errorText);
|
|
412
|
+
session.state.lastError = userVisibleError;
|
|
413
|
+
writeState(session);
|
|
318
414
|
if (result.errorText) {
|
|
319
415
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
|
|
320
416
|
}
|
|
321
|
-
await client.sendMessage(session.conversationId,
|
|
417
|
+
await client.sendMessage(session.conversationId, userVisibleError);
|
|
322
418
|
}
|
|
323
419
|
else if (result.interrupted) {
|
|
324
420
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn interrupted`);
|
|
@@ -327,7 +423,10 @@ async function main() {
|
|
|
327
423
|
catch (error) {
|
|
328
424
|
clearStreaming(session.conversationId);
|
|
329
425
|
client.setTyping(session.conversationId, false).catch(() => { });
|
|
330
|
-
|
|
426
|
+
const message = `The Codex host failed to start a turn: ${error instanceof Error ? error.message : String(error)}`;
|
|
427
|
+
session.state.lastError = message;
|
|
428
|
+
writeState(session);
|
|
429
|
+
await client.sendMessage(session.conversationId, message).catch(() => { });
|
|
331
430
|
clearStoredThreadId(agentId, session.conversationId);
|
|
332
431
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
|
|
333
432
|
}
|
|
@@ -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' && context.recentHumanCount === 0) {
|
|
38
|
+
return {
|
|
39
|
+
allow: false,
|
|
40
|
+
reason: 'suppressing agent-only group 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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildInboundContextLines, decideAutoReply } from './inbound-policy.js';
|
|
3
|
+
function makeContext(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
conversationType: 'direct',
|
|
6
|
+
memberCount: 2,
|
|
7
|
+
senderType: 'human',
|
|
8
|
+
senderName: 'Alice',
|
|
9
|
+
isOwner: false,
|
|
10
|
+
mentionedAgent: false,
|
|
11
|
+
recentSenderTypes: ['human'],
|
|
12
|
+
recentHumanCount: 1,
|
|
13
|
+
recentAgentCount: 0,
|
|
14
|
+
consecutiveAgentTurns: 0,
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
describe('decideAutoReply', () => {
|
|
19
|
+
it('allows human senders', () => {
|
|
20
|
+
const decision = decideAutoReply(makeContext());
|
|
21
|
+
expect(decision).toEqual({
|
|
22
|
+
allow: true,
|
|
23
|
+
reason: 'latest sender is human',
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
it('suppresses group messages from another agent without a mention', () => {
|
|
27
|
+
const decision = decideAutoReply(makeContext({
|
|
28
|
+
conversationType: 'group',
|
|
29
|
+
memberCount: 3,
|
|
30
|
+
senderType: 'ai_agent',
|
|
31
|
+
recentSenderTypes: ['ai_agent', 'ai_agent'],
|
|
32
|
+
recentHumanCount: 0,
|
|
33
|
+
recentAgentCount: 2,
|
|
34
|
+
consecutiveAgentTurns: 2,
|
|
35
|
+
}));
|
|
36
|
+
expect(decision.allow).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
it('allows group agent replies while a human is still active in the recent window', () => {
|
|
39
|
+
const decision = decideAutoReply(makeContext({
|
|
40
|
+
conversationType: 'group',
|
|
41
|
+
memberCount: 3,
|
|
42
|
+
senderType: 'ai_agent',
|
|
43
|
+
recentSenderTypes: ['ai_agent', 'human'],
|
|
44
|
+
recentHumanCount: 1,
|
|
45
|
+
recentAgentCount: 1,
|
|
46
|
+
consecutiveAgentTurns: 1,
|
|
47
|
+
}));
|
|
48
|
+
expect(decision).toEqual({
|
|
49
|
+
allow: true,
|
|
50
|
+
reason: 'direct agent message allowed',
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
it('allows explicitly addressed agent messages in groups', () => {
|
|
54
|
+
const decision = decideAutoReply(makeContext({
|
|
55
|
+
conversationType: 'group',
|
|
56
|
+
memberCount: 3,
|
|
57
|
+
senderType: 'ai_agent',
|
|
58
|
+
mentionedAgent: true,
|
|
59
|
+
recentSenderTypes: ['ai_agent', 'human'],
|
|
60
|
+
recentHumanCount: 1,
|
|
61
|
+
recentAgentCount: 1,
|
|
62
|
+
consecutiveAgentTurns: 1,
|
|
63
|
+
}));
|
|
64
|
+
expect(decision).toEqual({
|
|
65
|
+
allow: true,
|
|
66
|
+
reason: 'another agent explicitly addressed this agent',
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
it('suppresses likely direct agent loops with no recent human activity', () => {
|
|
70
|
+
const decision = decideAutoReply(makeContext({
|
|
71
|
+
senderType: 'ai_agent',
|
|
72
|
+
recentSenderTypes: ['ai_agent', 'ai_agent'],
|
|
73
|
+
recentHumanCount: 0,
|
|
74
|
+
recentAgentCount: 2,
|
|
75
|
+
consecutiveAgentTurns: 2,
|
|
76
|
+
}));
|
|
77
|
+
expect(decision.allow).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
describe('buildInboundContextLines', () => {
|
|
81
|
+
it('includes sender and recent activity context', () => {
|
|
82
|
+
const lines = buildInboundContextLines(makeContext({
|
|
83
|
+
conversationType: 'group',
|
|
84
|
+
memberCount: 4,
|
|
85
|
+
senderType: 'ai_agent',
|
|
86
|
+
senderName: 'Ernest',
|
|
87
|
+
recentSenderTypes: ['ai_agent', 'human', 'ai_agent'],
|
|
88
|
+
recentHumanCount: 1,
|
|
89
|
+
recentAgentCount: 2,
|
|
90
|
+
consecutiveAgentTurns: 1,
|
|
91
|
+
}));
|
|
92
|
+
expect(lines).toContain('Latest sender name: Ernest');
|
|
93
|
+
expect(lines).toContain('Latest sender type: ai_agent');
|
|
94
|
+
expect(lines).toContain('Conversation type: group (4 members)');
|
|
95
|
+
expect(lines).toContain('Recent sender pattern: agent -> human -> agent');
|
|
96
|
+
});
|
|
97
|
+
});
|
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.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Canon host integration for Codex CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/host.js",
|
|
@@ -11,11 +11,14 @@
|
|
|
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
18
|
"build": "tsc",
|
|
18
19
|
"dev": "tsc --watch",
|
|
20
|
+
"smoke": "node scripts/smoke-test.mjs",
|
|
21
|
+
"test": "vitest run",
|
|
19
22
|
"prepublishOnly": "npm run build"
|
|
20
23
|
},
|
|
21
24
|
"dependencies": {
|
|
@@ -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);
|