@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 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 --sandbox workspace-write --ask-for-approval never
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 (this.fullAuto) {
187
+ if (execMode.fullAuto) {
179
188
  args.push('--full-auto');
180
189
  }
181
- if (this.bypassApprovalsAndSandbox) {
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 the Canon user.',
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
- ownerLine,
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
- if (message.contentType === 'audio' && message.audioUrl) {
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
- console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}"`);
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
- isOwner: input.isOwner,
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, 'The Codex session stopped unexpectedly before sending a final reply.');
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
- await client.sendMessage(session.conversationId, `The Codex host failed to start a turn: ${error instanceof Error ? error.message : String(error)}`).catch(() => { });
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.0",
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);