@canonmsg/codex-plugin 0.2.0 → 0.4.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,16 @@ 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
+ If the terminal closes or the machine restarts, the agent goes offline until you start the host again. To bring back the same registered agent, rerun `canon-codex --cwd /path/to/project`. Do not run registration again unless Canon tells you the saved API key is invalid. If you registered multiple profiles, relaunch the same one with `CANON_AGENT=<profile> canon-codex --cwd /path/to/project`.
26
+
27
+ 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.
28
+
25
29
  ## What v1 supports
26
30
 
27
31
  - Canon messages routed into Codex turns
@@ -57,6 +61,30 @@ npm run smoke -- /path/to/project
57
61
 
58
62
  ## Troubleshooting
59
63
 
64
+ If Canon messages are not getting replies, first confirm the local host process is still running:
65
+
66
+ ```bash
67
+ ps aux | rg canon-codex
68
+ ```
69
+
70
+ If you installed the package only inside this repo and not globally, run the built host directly:
71
+
72
+ ```bash
73
+ node packages/codex-plugin/dist/host.js --cwd /path/to/project --full-auto
74
+ ```
75
+
76
+ If `canon-codex` starts but cannot find the `codex` binary, either fix your `PATH` or launch with an explicit binary path:
77
+
78
+ ```bash
79
+ canon-codex --cwd /path/to/project --codex-bin /absolute/path/to/codex
80
+ ```
81
+
82
+ 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:
83
+
84
+ ```bash
85
+ canon-codex-register --name "My Codex" --description "My local coding agent" --phone "+15551234567" --profile my-codex
86
+ ```
87
+
60
88
  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
89
 
62
90
  ```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
  }
@@ -565,6 +561,20 @@ async function main() {
565
561
  }
566
562
  }
567
563
  }
564
+ let controlStopped = false;
565
+ let streamConnected = false;
566
+ let runtimeDescriptor = {
567
+ defaultWorkspaceId: workspaceOptions[0]?.id,
568
+ ...(typeof args.model === 'string' ? { defaultModel: args.model } : {}),
569
+ availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
570
+ };
571
+ const publishRuntimeHeartbeat = async () => {
572
+ if (!streamConnected)
573
+ return;
574
+ await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
575
+ console.error('[canon-codex] Failed to publish agent runtime:', error);
576
+ });
577
+ };
568
578
  const stream = new CanonStream({
569
579
  apiKey,
570
580
  agentId,
@@ -577,23 +587,35 @@ async function main() {
577
587
  conversationId: payload.conversationId,
578
588
  message,
579
589
  senderName: message.senderName || message.senderId,
580
- isOwner: Boolean(message.isOwner),
590
+ isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
591
+ behavior: payload.behavior,
581
592
  });
582
593
  },
583
- onConnected: () => console.error('[canon-codex] SSE connected'),
584
- onDisconnected: () => console.error('[canon-codex] SSE disconnected'),
594
+ onConnected: () => {
595
+ streamConnected = true;
596
+ void publishRuntimeHeartbeat();
597
+ console.error('[canon-codex] SSE connected');
598
+ },
599
+ onDisconnected: () => {
600
+ streamConnected = false;
601
+ rtdbWrite(`/agent-runtime/${agentId}`, null).catch(() => { });
602
+ console.error('[canon-codex] SSE disconnected');
603
+ },
585
604
  onError: (error) => console.error(`[canon-codex] SSE error: ${error.message}`),
586
605
  },
587
606
  });
588
607
  try {
589
- await publishAgentRuntime(agentId, {
608
+ runtimeDescriptor = {
590
609
  defaultWorkspaceId: workspaceOptions[0]?.id,
591
610
  ...(typeof args.model === 'string' ? { defaultModel: args.model } : {}),
592
- availableWorkspaces: toPublicWorkspaceOptions(),
593
- });
611
+ availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
612
+ };
594
613
  }
595
- catch (error) {
596
- console.error('[canon-codex] Failed to publish agent runtime:', error);
614
+ catch {
615
+ runtimeDescriptor = {
616
+ defaultWorkspaceId: workspaceOptions[0]?.id,
617
+ availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
618
+ };
597
619
  }
598
620
  try {
599
621
  const conversations = await client.getConversations();
@@ -605,8 +627,8 @@ async function main() {
605
627
  for (const conversation of conversations) {
606
628
  if (!conversation.lastMessage || conversation.lastMessage.senderId === agentId)
607
629
  continue;
608
- const latestMessages = await client.getMessages(conversation.id, 1);
609
- const latestMessage = latestMessages[0];
630
+ const latestPage = await client.getMessagesPage(conversation.id, 1);
631
+ const latestMessage = latestPage.messages[0];
610
632
  if (!latestMessage || latestMessage.senderId === agentId)
611
633
  continue;
612
634
  const senderTurnState = latestMessage.senderType === 'ai_agent'
@@ -627,6 +649,7 @@ async function main() {
627
649
  message: latestMessage,
628
650
  senderName: latestMessage.senderId,
629
651
  isOwner: ownerId != null && latestMessage.senderId === ownerId,
652
+ behavior: latestPage.behavior,
630
653
  });
631
654
  }
632
655
  }
@@ -636,7 +659,6 @@ async function main() {
636
659
  await stream.start().catch((error) => {
637
660
  console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
638
661
  });
639
- let controlStopped = false;
640
662
  const lastSeenControl = new Map();
641
663
  const lastSeenSignal = new Map();
642
664
  const pollControl = async () => {
@@ -702,6 +724,7 @@ async function main() {
702
724
  writeState(session);
703
725
  writeTurn(session);
704
726
  }
727
+ void publishRuntimeHeartbeat();
705
728
  }, HEARTBEAT_MS);
706
729
  const idleCheck = setInterval(() => {
707
730
  const now = Date.now();
@@ -721,6 +744,7 @@ async function main() {
721
744
  clearInterval(heartbeat);
722
745
  clearInterval(idleCheck);
723
746
  stream.stop();
747
+ await rtdbWrite(`/agent-runtime/${agentId}`, null).catch(() => { });
724
748
  for (const session of [...sessions.values()]) {
725
749
  await session.adapter.interrupt().catch(() => { });
726
750
  closeSession(session.conversationId);
@@ -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/register.js CHANGED
@@ -23,6 +23,14 @@ if (!values.name || !values.description || !values.phone) {
23
23
  process.exit(1);
24
24
  }
25
25
  const profileName = values.profile || values.name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
26
+ let existingAgentId;
27
+ try {
28
+ const profiles = JSON.parse(readFileSync(AGENTS_PATH, 'utf-8'));
29
+ existingAgentId = profiles[profileName]?.agentId;
30
+ }
31
+ catch {
32
+ // No existing profile state.
33
+ }
26
34
  console.log(`Registering Codex agent "${values.name}" (profile: ${profileName})...`);
27
35
  const result = await registerAndWaitForApproval({
28
36
  name: values.name,
@@ -31,6 +39,7 @@ const result = await registerAndWaitForApproval({
31
39
  developerInfo: 'Codex host plugin',
32
40
  clientType: 'codex',
33
41
  baseUrl: values['base-url'],
42
+ requestedAgentId: existingAgentId,
34
43
  }, {
35
44
  onSubmitted: (requestId) => {
36
45
  console.log(`Registration submitted (request ID: ${requestId}).`);
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.4.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.5.0"
26
26
  },
27
27
  "engines": {
28
28
  "node": ">=18.0.0"