@canonmsg/codex-plugin 0.2.0 → 0.3.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
@@ -16,12 +16,14 @@ codex login status
16
16
  # Register (approve in Canon when prompted)
17
17
  canon-codex-register --name "My Codex" --description "My local coding agent" --phone "+15551234567"
18
18
 
19
- # Run inside a project
19
+ # Run inside a project and keep the host process running
20
20
  canon-codex --cwd /path/to/project
21
21
  ```
22
22
 
23
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
24
 
25
+ You do not need a git repo for host mode. The plugin passes `--skip-git-repo-check` to Codex, so any readable working directory is valid.
26
+
25
27
  ## What v1 supports
26
28
 
27
29
  - Canon messages routed into Codex turns
@@ -57,6 +59,24 @@ npm run smoke -- /path/to/project
57
59
 
58
60
  ## Troubleshooting
59
61
 
62
+ If Canon messages are not getting replies, first confirm the local host process is still running:
63
+
64
+ ```bash
65
+ ps aux | rg canon-codex
66
+ ```
67
+
68
+ If you installed the package only inside this repo and not globally, run the built host directly:
69
+
70
+ ```bash
71
+ node packages/codex-plugin/dist/host.js --cwd /path/to/project --full-auto
72
+ ```
73
+
74
+ If Canon rejects authenticated requests with `401 Invalid API key`, the stored Canon profile needs a fresh key. Rerun registration for the same profile to overwrite `~/.canon/agents.json`, then restart the host:
75
+
76
+ ```bash
77
+ canon-codex-register --name "My Codex" --description "My local coding agent" --phone "+15551234567" --profile my-codex
78
+ ```
79
+
60
80
  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
81
 
62
82
  ```bash
package/dist/host.js CHANGED
@@ -3,8 +3,7 @@ import { setDefaultResultOrder } from 'node:dns';
3
3
  setDefaultResultOrder('ipv4first');
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { parseArgs } from 'node:util';
6
- import { basename, resolve } from 'node:path';
7
- import { CanonClient, CanonStream, clearSessionState, clearTurnState, DEFAULT_RUNTIME_CAPABILITIES, getActiveProfile, initRTDBAuth, normalizeTurnMetadata, normalizeTurnState, releaseLock, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, writeSessionState, writeTurnState, } from '@canonmsg/core';
6
+ import { buildParticipationHistorySnapshot, buildBehaviorPolicyLines, buildConfiguredWorkspaceOptions, buildPublicWorkspaceOptions, ExecutionEnvironmentError, isEnabledFlag, readSessionWorkspaceConfig, resolveConfiguredWorkspaceCwd, CanonClient, CanonStream, clearSessionState, clearTurnState, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfile, initRTDBAuth, normalizeTurnMetadata, normalizeTurnState, prepareConversationEnvironment, releaseLock, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, writeSessionState, writeTurnState, } from '@canonmsg/core';
8
7
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
9
8
  import { CodexConversationAdapter, } from './adapter.js';
10
9
  import { clearStoredThreadId, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
@@ -17,16 +16,10 @@ const CODEX_RUNTIME_CAPABILITIES = {
17
16
  ...DEFAULT_RUNTIME_CAPABILITIES,
18
17
  supportsInterrupt: true,
19
18
  supportsQueue: true,
20
- supportsNonFinalPermanentMessages: true,
19
+ supportsNonFinalPermanentMessages: false,
21
20
  };
22
21
  let workingDir = process.cwd();
23
22
  let workspaceOptions = [];
24
- function normalizeString(value) {
25
- if (typeof value !== 'string')
26
- return undefined;
27
- const trimmed = value.trim();
28
- return trimmed ? trimmed : undefined;
29
- }
30
23
  function normalizeRuntimeTurnState(value) {
31
24
  const normalizedTurn = normalizeTurnState(value);
32
25
  if (normalizedTurn) {
@@ -43,20 +36,6 @@ function normalizeRuntimeTurnState(value) {
43
36
  }
44
37
  return null;
45
38
  }
46
- function buildWorkspaceOptions(primaryCwd, configured) {
47
- const uniqueDirs = Array.from(new Set([primaryCwd, ...configured].map((dir) => resolve(dir))));
48
- const seenLabels = new Map();
49
- return uniqueDirs.map((cwd, index) => {
50
- const baseLabel = basename(cwd) || cwd;
51
- const seenCount = (seenLabels.get(baseLabel) ?? 0) + 1;
52
- seenLabels.set(baseLabel, seenCount);
53
- return {
54
- id: index === 0 ? 'default' : `workspace-${index + 1}`,
55
- label: seenCount === 1 ? baseLabel : `${baseLabel} (${seenCount})`,
56
- cwd,
57
- };
58
- });
59
- }
60
39
  async function publishAgentRuntime(agentId, runtime) {
61
40
  await rtdbWrite(`/agent-runtime/${agentId}`, {
62
41
  clientType: 'codex',
@@ -67,40 +46,24 @@ async function publishAgentRuntime(agentId, runtime) {
67
46
  }
68
47
  async function loadSessionConfig(conversationId, agentId) {
69
48
  const raw = await rtdbRead(`/session-config/${conversationId}/${agentId}`);
70
- if (!raw || typeof raw !== 'object')
71
- return null;
72
- const data = raw;
73
- return {
74
- workspaceId: normalizeString(data.workspaceId),
75
- legacyCwd: normalizeString(data.cwd),
76
- model: normalizeString(data.model),
77
- };
49
+ return readSessionWorkspaceConfig(raw);
78
50
  }
79
51
  function resolveWorkspaceCwd(config) {
80
- if (config?.workspaceId) {
81
- const workspace = workspaceOptions.find((option) => option.id === config.workspaceId);
82
- if (workspace)
83
- return workspace.cwd;
84
- }
85
- if (config?.legacyCwd) {
86
- const workspace = workspaceOptions.find((option) => option.cwd === config.legacyCwd);
87
- if (workspace)
88
- return workspace.cwd;
89
- }
90
- return workspaceOptions[0]?.cwd ?? workingDir;
91
- }
92
- function toPublicWorkspaceOptions() {
93
- return workspaceOptions.map(({ id, label }) => ({ id, label }));
52
+ return resolveConfiguredWorkspaceCwd({
53
+ workspaceOptions,
54
+ config,
55
+ defaultCwd: workingDir,
56
+ });
94
57
  }
95
58
  function buildCanonPrompt(input) {
96
59
  return [
97
60
  'You are connected to Canon messaging through a Codex host wrapper.',
98
- 'Reply naturally to Canon participants.',
99
61
  'Only the last assistant message from this turn will be delivered as the permanent Canon reply.',
100
62
  'Short intermediate assistant messages may be shown as ephemeral status while you work.',
101
63
  ...buildInboundContextLines(input.participantContext),
64
+ ...buildBehaviorPolicyLines(input.behavior),
102
65
  '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.',
66
+ 'Honor the Canon behavior policy above when deciding how proactively to participate.',
104
67
  `Conversation ID: ${input.conversationId}`,
105
68
  '',
106
69
  'New Canon message:',
@@ -155,6 +118,9 @@ function formatTurnFailure(errorText) {
155
118
  const shortened = normalized.length > 280 ? `${normalized.slice(0, 277)}...` : normalized;
156
119
  return `Codex failed before sending a final reply: ${shortened}`;
157
120
  }
121
+ function sleep(ms) {
122
+ return new Promise((resolve) => setTimeout(resolve, ms));
123
+ }
158
124
  async function main() {
159
125
  const { values: args } = parseArgs({
160
126
  options: {
@@ -168,12 +134,17 @@ async function main() {
168
134
  config: { type: 'string', multiple: true },
169
135
  'codex-bin': { type: 'string' },
170
136
  'full-auto': { type: 'boolean' },
137
+ 'enable-worktrees': { type: 'boolean' },
171
138
  'dangerously-bypass-approvals-and-sandbox': { type: 'boolean' },
172
139
  },
173
140
  strict: true,
174
141
  });
175
142
  workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
176
- workspaceOptions = buildWorkspaceOptions(workingDir, args.workspace ?? []);
143
+ workspaceOptions = buildConfiguredWorkspaceOptions(workingDir, args.workspace ?? []);
144
+ const allowWorktrees = isEnabledFlag(args['enable-worktrees'] ?? process.env.CANON_ENABLE_WORKTREES);
145
+ if (!allowWorktrees) {
146
+ console.error('[canon-codex] Worktree isolation is disabled; sessions will lock their selected workspace unless explicitly enabled.');
147
+ }
177
148
  if (typeof args['ask-for-approval'] === 'string') {
178
149
  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
150
  }
@@ -242,17 +213,9 @@ async function main() {
242
213
  async function loadParticipantContext(input) {
243
214
  const [conversation, recentMessages] = await Promise.all([
244
215
  getConversationMeta(input.conversationId),
245
- client.getMessages(input.conversationId, 6).catch(() => []),
216
+ client.getMessages(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => []),
246
217
  ]);
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
- }
218
+ const history = buildParticipationHistorySnapshot(recentMessages, agentId);
256
219
  return {
257
220
  conversationType: conversation?.type ?? 'unknown',
258
221
  memberCount: conversation?.memberIds?.length ?? null,
@@ -260,10 +223,11 @@ async function main() {
260
223
  senderName: input.senderName,
261
224
  isOwner: input.isOwner,
262
225
  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,
226
+ recentSenderTypes: history.recentSenderTypes,
227
+ recentHumanCount: history.recentHumanCount,
228
+ recentAgentCount: history.recentAgentCount,
229
+ consecutiveAgentTurns: history.consecutiveAgentTurns,
230
+ currentAgentStreakStartedByHuman: history.currentAgentStreakStartedByHuman,
267
231
  };
268
232
  }
269
233
  function writeState(session) {
@@ -271,6 +235,8 @@ async function main() {
271
235
  lastError: session.state.lastError,
272
236
  model: session.state.model,
273
237
  cwd: session.cwd,
238
+ executionMode: session.environment.mode,
239
+ ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
274
240
  hostMode: true,
275
241
  clientType: 'codex',
276
242
  state: session.state.state,
@@ -299,11 +265,17 @@ async function main() {
299
265
  function clearStreaming(conversationId) {
300
266
  rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
301
267
  }
268
+ async function handoffFinalMessage(conversationId) {
269
+ await sleep(FINAL_MESSAGE_HANDOFF_MS);
270
+ clearStreaming(conversationId);
271
+ client.setTyping(conversationId, false).catch(() => { });
272
+ }
302
273
  function closeSession(conversationId) {
303
274
  const session = sessions.get(conversationId);
304
275
  if (!session)
305
276
  return;
306
277
  session.closed = true;
278
+ releaseConversationEnvironment(session.environment);
307
279
  clearStreaming(conversationId);
308
280
  clearSessionState(conversationId, agentId).catch(() => { });
309
281
  clearTurnState(conversationId, agentId).catch(() => { });
@@ -337,44 +309,59 @@ async function main() {
337
309
  }
338
310
  const creation = (async () => {
339
311
  const config = await loadSessionConfig(conversationId, agentId);
340
- const sessionCwd = resolveWorkspaceCwd(config);
341
- const sessionModel = config?.model ?? (typeof args.model === 'string' ? args.model : undefined);
342
- const storedThreadId = loadStoredThreadId(agentId, conversationId, sessionCwd);
343
- const session = {
312
+ const workspaceCwd = resolveWorkspaceCwd(config);
313
+ const environment = prepareConversationEnvironment({
314
+ agentId,
344
315
  conversationId,
345
- cwd: sessionCwd,
346
- adapter: new CodexConversationAdapter({
316
+ workspaceCwd,
317
+ allowWorktrees,
318
+ });
319
+ try {
320
+ const sessionCwd = environment.cwd;
321
+ const sessionModel = config?.model ?? (typeof args.model === 'string' ? args.model : undefined);
322
+ const storedThreadId = loadStoredThreadId(agentId, conversationId, sessionCwd);
323
+ const session = {
324
+ conversationId,
347
325
  cwd: sessionCwd,
348
- threadId: storedThreadId,
349
- codexBin: typeof args['codex-bin'] === 'string' ? args['codex-bin'] : 'codex',
350
- model: sessionModel ?? null,
351
- sandbox: (typeof args.sandbox === 'string' ? args.sandbox : null),
352
- approvalPolicy: (typeof args['ask-for-approval'] === 'string'
353
- ? args['ask-for-approval']
354
- : null),
355
- codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
356
- addDirs: args['add-dir'] ?? [],
357
- configOverrides: args.config ?? [],
358
- fullAuto: Boolean(args['full-auto']),
359
- bypassApprovalsAndSandbox: Boolean(args['dangerously-bypass-approvals-and-sandbox']),
360
- }),
361
- queue: [],
362
- running: false,
363
- state: {
364
- model: sessionModel,
365
- state: 'idle',
366
- },
367
- turnState: 'idle',
368
- currentTurnId: null,
369
- currentTurnOpenedAt: null,
370
- lastAcceptedIntent: null,
371
- lastActivity: Date.now(),
372
- closed: false,
373
- };
374
- sessions.set(conversationId, session);
375
- writeState(session);
376
- writeTurn(session);
377
- return session;
326
+ environment,
327
+ adapter: new CodexConversationAdapter({
328
+ cwd: sessionCwd,
329
+ threadId: storedThreadId,
330
+ codexBin: typeof args['codex-bin'] === 'string' ? args['codex-bin'] : 'codex',
331
+ model: sessionModel ?? null,
332
+ sandbox: (typeof args.sandbox === 'string' ? args.sandbox : null),
333
+ approvalPolicy: (typeof args['ask-for-approval'] === 'string'
334
+ ? args['ask-for-approval']
335
+ : null),
336
+ codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
337
+ addDirs: args['add-dir'] ?? [],
338
+ configOverrides: args.config ?? [],
339
+ fullAuto: Boolean(args['full-auto']),
340
+ bypassApprovalsAndSandbox: Boolean(args['dangerously-bypass-approvals-and-sandbox']),
341
+ }),
342
+ queue: [],
343
+ running: false,
344
+ state: {
345
+ model: sessionModel,
346
+ state: 'idle',
347
+ },
348
+ turnState: 'idle',
349
+ currentTurnId: null,
350
+ currentTurnOpenedAt: null,
351
+ lastAcceptedIntent: null,
352
+ lastActivity: Date.now(),
353
+ closed: false,
354
+ };
355
+ sessions.set(conversationId, session);
356
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
357
+ writeState(session);
358
+ writeTurn(session);
359
+ return session;
360
+ }
361
+ catch (error) {
362
+ releaseConversationEnvironment(environment);
363
+ throw error;
364
+ }
378
365
  })();
379
366
  pendingSessionCreations.set(conversationId, creation);
380
367
  try {
@@ -398,19 +385,31 @@ async function main() {
398
385
  }
399
386
  async function enqueueInboundMessage(input) {
400
387
  const content = renderInboundContent(input.message);
388
+ const conversation = await getConversationMeta(input.conversationId);
389
+ const behavior = input.behavior ?? conversation?.behavior;
401
390
  const participantContext = await loadParticipantContext({
402
391
  conversationId: input.conversationId,
403
392
  message: input.message,
404
393
  senderName: input.senderName,
405
394
  isOwner: input.isOwner,
406
395
  });
407
- const autoReply = decideAutoReply(participantContext);
396
+ const autoReply = decideAutoReply(participantContext, behavior);
408
397
  if (!autoReply.allow) {
409
398
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
410
399
  return;
411
400
  }
412
401
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
413
- const session = await getOrCreateSession(input.conversationId);
402
+ let session;
403
+ try {
404
+ session = await getOrCreateSession(input.conversationId);
405
+ }
406
+ catch (error) {
407
+ const message = error instanceof Error ? error.message : String(error);
408
+ const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
409
+ console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to create session: ${message}`);
410
+ await client.sendMessage(input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`).catch(() => { });
411
+ return;
412
+ }
414
413
  const turnMetadata = normalizeTurnMetadata(input.message.metadata);
415
414
  const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
416
415
  const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
@@ -418,6 +417,7 @@ async function main() {
418
417
  content,
419
418
  conversationId: input.conversationId,
420
419
  participantContext,
420
+ behavior,
421
421
  });
422
422
  if (session.running && deliveryIntent === 'interrupt') {
423
423
  enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted);
@@ -482,8 +482,6 @@ async function main() {
482
482
  return;
483
483
  }
484
484
  if (event.type === 'turn.completed') {
485
- session.turnState = 'completed';
486
- writeTurn(session);
487
485
  writeState(session);
488
486
  }
489
487
  }, (line) => {
@@ -492,11 +490,7 @@ async function main() {
492
490
  if (result.threadId) {
493
491
  saveStoredThreadId(agentId, session.conversationId, session.cwd, result.threadId);
494
492
  }
495
- clearStreaming(session.conversationId);
496
- client.setTyping(session.conversationId, false).catch(() => { });
497
493
  if (!result.interrupted && result.finalMessage) {
498
- session.turnState = 'completed';
499
- writeTurn(session);
500
494
  await client.sendMessage(session.conversationId, result.finalMessage, {
501
495
  metadata: {
502
496
  turnId: session.currentTurnId,
@@ -505,14 +499,13 @@ async function main() {
505
499
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
506
500
  },
507
501
  });
502
+ await handoffFinalMessage(session.conversationId);
508
503
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${result.finalMessage.length} chars)`);
509
504
  }
510
505
  else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
511
506
  const userVisibleError = formatTurnFailure(result.errorText);
512
507
  session.state.lastError = userVisibleError;
513
- session.turnState = 'completed';
514
508
  writeState(session);
515
- writeTurn(session);
516
509
  if (result.errorText) {
517
510
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
518
511
  }
@@ -524,21 +517,23 @@ async function main() {
524
517
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
525
518
  },
526
519
  });
520
+ await handoffFinalMessage(session.conversationId);
521
+ }
522
+ else if (!result.interrupted) {
523
+ await handoffFinalMessage(session.conversationId);
527
524
  }
528
525
  else if (result.interrupted) {
529
526
  session.turnState = 'interrupted';
530
527
  writeTurn(session);
528
+ clearStreaming(session.conversationId);
529
+ client.setTyping(session.conversationId, false).catch(() => { });
531
530
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn interrupted`);
532
531
  }
533
532
  }
534
533
  catch (error) {
535
- clearStreaming(session.conversationId);
536
- client.setTyping(session.conversationId, false).catch(() => { });
537
534
  const message = `The Codex host failed to start a turn: ${error instanceof Error ? error.message : String(error)}`;
538
535
  session.state.lastError = message;
539
- session.turnState = 'completed';
540
536
  writeState(session);
541
- writeTurn(session);
542
537
  await client.sendMessage(session.conversationId, message, {
543
538
  metadata: {
544
539
  turnId: session.currentTurnId,
@@ -547,6 +542,7 @@ async function main() {
547
542
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
548
543
  },
549
544
  }).catch(() => { });
545
+ await handoffFinalMessage(session.conversationId);
550
546
  clearStoredThreadId(agentId, session.conversationId);
551
547
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
552
548
  }
@@ -577,7 +573,8 @@ async function main() {
577
573
  conversationId: payload.conversationId,
578
574
  message,
579
575
  senderName: message.senderName || message.senderId,
580
- isOwner: Boolean(message.isOwner),
576
+ isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
577
+ behavior: payload.behavior,
581
578
  });
582
579
  },
583
580
  onConnected: () => console.error('[canon-codex] SSE connected'),
@@ -589,7 +586,7 @@ async function main() {
589
586
  await publishAgentRuntime(agentId, {
590
587
  defaultWorkspaceId: workspaceOptions[0]?.id,
591
588
  ...(typeof args.model === 'string' ? { defaultModel: args.model } : {}),
592
- availableWorkspaces: toPublicWorkspaceOptions(),
589
+ availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
593
590
  });
594
591
  }
595
592
  catch (error) {
@@ -605,8 +602,8 @@ async function main() {
605
602
  for (const conversation of conversations) {
606
603
  if (!conversation.lastMessage || conversation.lastMessage.senderId === agentId)
607
604
  continue;
608
- const latestMessages = await client.getMessages(conversation.id, 1);
609
- const latestMessage = latestMessages[0];
605
+ const latestPage = await client.getMessagesPage(conversation.id, 1);
606
+ const latestMessage = latestPage.messages[0];
610
607
  if (!latestMessage || latestMessage.senderId === agentId)
611
608
  continue;
612
609
  const senderTurnState = latestMessage.senderType === 'ai_agent'
@@ -627,6 +624,7 @@ async function main() {
627
624
  message: latestMessage,
628
625
  senderName: latestMessage.senderId,
629
626
  isOwner: ownerId != null && latestMessage.senderId === ownerId,
627
+ behavior: latestPage.behavior,
630
628
  });
631
629
  }
632
630
  }
@@ -1,4 +1,4 @@
1
- import type { CanonConversation } from '@canonmsg/core';
1
+ import { type CanonConversation, type ResolvedAgentBehaviorPolicy } from '@canonmsg/core';
2
2
  export interface InboundParticipantContext {
3
3
  conversationType: CanonConversation['type'] | 'unknown';
4
4
  memberCount: number | null;
@@ -10,10 +10,11 @@ export interface InboundParticipantContext {
10
10
  recentHumanCount: number;
11
11
  recentAgentCount: number;
12
12
  consecutiveAgentTurns: number;
13
+ currentAgentStreakStartedByHuman: boolean;
13
14
  }
14
15
  export interface AutoReplyDecision {
15
16
  allow: boolean;
16
17
  reason: string;
17
18
  }
18
19
  export declare function buildInboundContextLines(context: InboundParticipantContext): string[];
19
- export declare function decideAutoReply(context: InboundParticipantContext): AutoReplyDecision;
20
+ export declare function decideAutoReply(context: InboundParticipantContext, behavior?: ResolvedAgentBehaviorPolicy | null): AutoReplyDecision;
@@ -1,3 +1,4 @@
1
+ import { evaluateParticipationPolicy, resolveAgentBehaviorPolicy, } from '@canonmsg/core';
1
2
  function formatRecentSenders(senderTypes) {
2
3
  if (senderTypes.length === 0)
3
4
  return 'none';
@@ -22,29 +23,21 @@ export function buildInboundContextLines(context) {
22
23
  `Recent human messages: ${context.recentHumanCount}`,
23
24
  `Recent agent messages: ${context.recentAgentCount}`,
24
25
  `Consecutive recent agent turns: ${context.consecutiveAgentTurns}`,
26
+ `Current agent streak started after a human message: ${context.currentAgentStreakStartedByHuman ? 'yes' : 'no'}`,
25
27
  ];
26
28
  }
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' };
29
+ export function decideAutoReply(context, behavior) {
30
+ const decision = evaluateParticipationPolicy(behavior ?? resolveAgentBehaviorPolicy(), {
31
+ conversationType: context.conversationType,
32
+ senderType: context.senderType,
33
+ isOwner: context.isOwner,
34
+ mentionedAgent: context.mentionedAgent,
35
+ recentHumanCount: context.recentHumanCount,
36
+ consecutiveAgentTurns: context.consecutiveAgentTurns,
37
+ currentAgentStreakStartedByHuman: context.currentAgentStreakStartedByHuman,
38
+ });
39
+ return {
40
+ allow: decision.allow,
41
+ reason: decision.reason,
42
+ };
50
43
  }
package/dist/setup.js CHANGED
@@ -17,9 +17,11 @@ console.log('');
17
17
  console.log(' 1. Register your agent');
18
18
  console.log(' canon-codex-register --name "My Codex" --description "My local coding agent" --phone "+15551234567"');
19
19
  console.log('');
20
- console.log(' 2. Start the host in a project directory');
20
+ console.log(' 2. Start the host in a project directory and keep it running');
21
21
  console.log(' canon-codex --cwd /path/to/project');
22
22
  console.log('');
23
+ console.log(' A git repo is not required; any readable directory works.');
24
+ console.log('');
23
25
  console.log('Optional flags:');
24
26
  console.log(' --model gpt-5.4');
25
27
  console.log(' --sandbox workspace-write');
@@ -27,3 +29,4 @@ console.log(' --full-auto');
27
29
  console.log('');
28
30
  console.log('Note: recent Codex CLI versions use --full-auto for non-interactive write access.');
29
31
  console.log('Note: Canon uses the local Codex login state by default (for example ChatGPT/device auth or API-key auth).');
32
+ console.log('Note: If Canon starts returning "Invalid API key", rerun canon-codex-register to replace the saved profile and then restart the host.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/host.js",
@@ -22,7 +22,7 @@
22
22
  "prepack": "npm run build"
23
23
  },
24
24
  "dependencies": {
25
- "@canonmsg/core": "^0.3.0"
25
+ "@canonmsg/core": "^0.4.0"
26
26
  },
27
27
  "engines": {
28
28
  "node": ">=18.0.0"