@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 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 --sandbox workspace-write --ask-for-approval never
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 (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...',
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 the Canon user.',
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
- ownerLine,
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
- if (message.contentType === 'audio' && message.audioUrl) {
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
- session.queue.push(prompt);
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
- console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}"`);
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
- enqueuePrompt(session, buildCanonPrompt({
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
- isOwner: input.isOwner,
258
- senderName: input.senderName,
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 prompt = session.queue.shift();
265
- if (!prompt)
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
- await client.sendMessage(session.conversationId, result.finalMessage);
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, 'The Codex session stopped unexpectedly before sending a final reply.');
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
- await client.sendMessage(session.conversationId, `The Codex host failed to start a turn: ${error instanceof Error ? error.message : String(error)}`).catch(() => { });
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' || timestamp <= (lastSeenSignal.get(conversationId) ?? 0)) {
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)}] Interrupt signal`);
682
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${signal.type} signal`);
445
683
  await session.adapter.interrupt();
446
- session.queue.length = 0;
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.1.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
- "prepublishOnly": "npm run build"
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.2.2"
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);