@canonmsg/codex-plugin 0.9.8 → 0.11.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
@@ -22,7 +22,9 @@ canon-codex --cwd /path/to/project
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. Install `@canonmsg/local-agents` and run `canon-necromance` to list every recorded local agent from newest to oldest, then revive one in the foreground:
25
+ `canon-codex` is the local agent process. Keep that terminal open while you want Canon to reach the agent. Closing it, logging out, rebooting, or sleeping long enough to stop the process takes the local agent offline until you revive it.
26
+
27
+ Install `@canonmsg/local-agents` and run `canon-necromance` to list every recorded local agent from newest to oldest, then revive one in the foreground:
26
28
 
27
29
  ```bash
28
30
  npm install -g @canonmsg/local-agents
@@ -30,7 +32,9 @@ canon-necromance
30
32
  canon-necromance revive my-codex
31
33
  ```
32
34
 
33
- 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`. Keep the revived terminal open; closing it takes this local agent offline.
35
+ 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`.
36
+
37
+ Public docs: <https://canonmail.com/agents/integrations>. Coding-host concepts: <https://canonmail.com/agents/coding-agents>.
34
38
 
35
39
  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.
36
40
 
package/dist/host.js CHANGED
@@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto';
4
4
  import { dirname } from 'node:path';
5
5
  import { parseArgs } from 'node:util';
6
6
  import { getCodexImagePath, materializeMessageMedia, } from '@canonmsg/agent-sdk';
7
- import { buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, normalizeTurnState, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, saveRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
7
+ import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, normalizeTurnState, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, saveRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
8
8
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
9
9
  import { CodexConversationAdapter, } from './adapter.js';
10
10
  import { clearStoredThreadId, buildCodexThreadPolicyFingerprint, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
@@ -38,7 +38,10 @@ EXAMPLES
38
38
  canon-codex --cwd ~/dev/canon
39
39
  canon-codex --cwd ~/dev/canon --workspace-root ~/dev --full-auto
40
40
 
41
- Keep this terminal open while you want Canon to reach the agent.`;
41
+ Keep this terminal open while you want Canon to reach the agent. Closing it,
42
+ logging out, rebooting, or sleeping long enough to stop the process takes the
43
+ local agent offline until you revive it. Docs:
44
+ https://canonmail.com/agents/integrations`;
42
45
  const MAX_SESSIONS = 12;
43
46
  const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
44
47
  const HEARTBEAT_MS = 30_000;
@@ -65,26 +68,9 @@ function buildCodexRuntimeDescriptor(input) {
65
68
  defaultPermissionMode: input.defaultPermissionMode,
66
69
  streamingTextMode: 'snapshot',
67
70
  actions: [
68
- {
69
- id: 'stop',
70
- label: 'Stop',
71
- description: 'Interrupt the current Codex exec turn.',
72
- aliases: ['stop'],
73
- category: 'turn',
74
- placements: ['composer_slash', 'command_palette'],
75
- availability: ['busy'],
76
- dispatch: { kind: 'signal', signal: 'interrupt' },
77
- },
78
- {
79
- id: 'stop-and-clear-queue',
80
- label: 'Stop & clear queue',
81
- description: 'Interrupt the current Codex exec turn and drop queued Canon messages.',
82
- aliases: ['stop-clear', 'clear-queue'],
83
- category: 'turn',
84
- placements: ['composer_slash', 'command_palette', 'session_strip'],
85
- availability: ['busy_with_queue'],
86
- dispatch: { kind: 'signal', signal: 'stop_and_drop' },
87
- },
71
+ RUNTIME_STOP_ACTION,
72
+ RUNTIME_STOP_AND_DROP_ACTION,
73
+ RUNTIME_NEW_SESSION_ACTION,
88
74
  ],
89
75
  });
90
76
  if (input.models.length > 0) {
@@ -261,10 +247,12 @@ export async function main() {
261
247
  initRTDBAuth(client);
262
248
  let agentId;
263
249
  let ownerId = null;
250
+ let ownerName = null;
264
251
  try {
265
252
  const ctx = await client.getAgentMe();
266
253
  agentId = ctx.agentId;
267
254
  ownerId = ctx.ownerId;
255
+ ownerName = ctx.ownerName;
268
256
  console.error(`[canon-codex] Connected as ${ctx.displayName || agentId}`);
269
257
  }
270
258
  catch {
@@ -314,6 +302,8 @@ export async function main() {
314
302
  const pendingSessionCreations = new Map();
315
303
  const conversationCache = new Map();
316
304
  const knownConversationIds = new Set();
305
+ const promptedGroupContextConversationIds = new Set();
306
+ const pendingMembershipChanges = new Map();
317
307
  let lastKnownConversationRefreshAt = 0;
318
308
  const { getConversationMeta } = createConversationMetadataLoader({
319
309
  client,
@@ -334,6 +324,45 @@ export async function main() {
334
324
  }
335
325
  lastKnownConversationRefreshAt = Date.now();
336
326
  }
327
+ function handleConversationUpdated(payload) {
328
+ const rawMemberIds = payload.changes.memberIds;
329
+ if (!Array.isArray(rawMemberIds))
330
+ return;
331
+ const memberIds = rawMemberIds.filter((id) => typeof id === 'string');
332
+ const cached = conversationCache.get(payload.conversationId);
333
+ const membershipChange = payload.membershipChange
334
+ ?? (cached ? diffCanonMemberIds(cached.memberIds, memberIds) : null);
335
+ if (cached) {
336
+ conversationCache.set(payload.conversationId, {
337
+ ...cached,
338
+ memberIds,
339
+ });
340
+ }
341
+ if (membershipChange) {
342
+ pendingMembershipChanges.set(payload.conversationId, membershipChange);
343
+ }
344
+ if (!memberIds.includes(agentId)) {
345
+ knownConversationIds.delete(payload.conversationId);
346
+ conversationCache.delete(payload.conversationId);
347
+ }
348
+ }
349
+ function getGroupContextMode(conversationId, conversation) {
350
+ if (conversation?.type !== 'group')
351
+ return undefined;
352
+ if (pendingMembershipChanges.has(conversationId))
353
+ return 'membership_change';
354
+ if (!promptedGroupContextConversationIds.has(conversationId))
355
+ return 'initial';
356
+ return undefined;
357
+ }
358
+ function markGroupContextModeUsed(conversationId, mode) {
359
+ if (!mode)
360
+ return;
361
+ promptedGroupContextConversationIds.add(conversationId);
362
+ if (mode === 'membership_change') {
363
+ pendingMembershipChanges.delete(conversationId);
364
+ }
365
+ }
337
366
  async function loadSenderRuntimeState(conversationId, senderId) {
338
367
  try {
339
368
  return normalizeRuntimeTurnState(await rtdbRead(`/turn-state/${conversationId}/${senderId}`));
@@ -353,9 +382,15 @@ export async function main() {
353
382
  agentId,
354
383
  conversation,
355
384
  page,
385
+ activeSelfContextId: input.activeSelfContextId,
386
+ selfContexts: input.selfContexts,
356
387
  message: input.message,
357
388
  senderName: input.senderName,
358
389
  isOwner: input.isOwner,
390
+ ownerId,
391
+ ownerName,
392
+ membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
393
+ groupContextMode: getGroupContextMode(input.conversationId, conversation),
359
394
  });
360
395
  }
361
396
  function writeState(session) {
@@ -452,6 +487,32 @@ export async function main() {
452
487
  client.setTyping(conversationId, false).catch(() => { });
453
488
  sessions.delete(conversationId);
454
489
  }
490
+ async function resetRuntimeSession(session) {
491
+ const conversationId = session.conversationId;
492
+ session.resetRequested = true;
493
+ const droppedPrompts = session.queue.splice(0);
494
+ await markQueuedPromptsRejected(conversationId, droppedPrompts);
495
+ clearStoredThreadId(runtimeId, agentId, conversationId, session.environment.baseCwd, session.environment.mode);
496
+ session.adapter.clearThreadId();
497
+ session.activeSelfContextId = null;
498
+ session.state.lastError = undefined;
499
+ if (session.running) {
500
+ await session.adapter.interrupt();
501
+ session.turnState = 'interrupted';
502
+ }
503
+ else {
504
+ session.turnState = 'idle';
505
+ session.currentTurnId = null;
506
+ session.currentTurnOpenedAt = null;
507
+ session.lastAcceptedIntent = null;
508
+ session.resetRequested = false;
509
+ }
510
+ stopVisibleWorkSignal(session);
511
+ clearStreaming(conversationId);
512
+ client.setTyping(conversationId, false).catch(() => { });
513
+ writeState(session);
514
+ writeTurn(session);
515
+ }
455
516
  function evictOldestIdle() {
456
517
  let oldest = null;
457
518
  for (const session of sessions.values()) {
@@ -531,6 +592,7 @@ export async function main() {
531
592
  currentTurnOpenedAt: null,
532
593
  activeSelfContextId: null,
533
594
  lastAcceptedIntent: null,
595
+ resetRequested: false,
534
596
  lastActivity: Date.now(),
535
597
  typingKeepaliveTimer: null,
536
598
  closed: false,
@@ -594,14 +656,13 @@ export async function main() {
594
656
  message: input.message,
595
657
  senderName: input.senderName,
596
658
  isOwner: input.isOwner,
659
+ activeSelfContextId: input.activeSelfContextId,
660
+ selfContexts: input.selfContexts,
597
661
  hydratedPage: input.hydratedPage,
598
662
  });
599
663
  const behavior = input.behavior ?? hydrated.behavior;
600
- const selfContexts = hydrated.hydratedFromPage
601
- ? hydrated.selfContexts
602
- : Array.isArray(input.selfContexts)
603
- ? input.selfContexts
604
- : hydrated.selfContexts;
664
+ const activeSelfContextId = hydrated.activeSelfContextId;
665
+ const selfContexts = hydrated.selfContexts;
605
666
  const participantContext = hydrated.participantContext;
606
667
  const autoReply = decideAutoReply(participantContext, behavior);
607
668
  if (!autoReply.allow) {
@@ -609,6 +670,7 @@ export async function main() {
609
670
  return;
610
671
  }
611
672
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
673
+ markGroupContextModeUsed(input.conversationId, participantContext.groupContextMode);
612
674
  let session;
613
675
  try {
614
676
  session = await getOrCreateSession(input.conversationId);
@@ -618,7 +680,7 @@ export async function main() {
618
680
  const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
619
681
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to create session: ${message}`);
620
682
  await client.sendMessage(input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
621
- ...(selfContexts[0]?.id ? { selfContextId: selfContexts[0].id } : {}),
683
+ ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
622
684
  metadata: {
623
685
  turnSemantics: 'turn_complete',
624
686
  turnComplete: true,
@@ -628,7 +690,7 @@ export async function main() {
628
690
  return;
629
691
  }
630
692
  const turnMetadata = normalizeTurnMetadata(input.message.metadata);
631
- session.activeSelfContextId = selfContexts[0]?.id ?? null;
693
+ session.activeSelfContextId = activeSelfContextId;
632
694
  const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
633
695
  const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
634
696
  const prompt = buildCanonPrompt({
@@ -680,6 +742,9 @@ export async function main() {
680
742
  const handleCodexEvent = (event) => {
681
743
  session.lastActivity = Date.now();
682
744
  if (event.type === 'thread.started') {
745
+ if (session.resetRequested) {
746
+ return;
747
+ }
683
748
  saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, event.threadId, session.environment.mode, session.policyFingerprint);
684
749
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Thread ${event.threadId}`);
685
750
  return;
@@ -727,7 +792,7 @@ export async function main() {
727
792
  clearStoredThread();
728
793
  result = await runTurnOnce();
729
794
  }
730
- if (result.threadId) {
795
+ if (result.threadId && !session.resetRequested) {
731
796
  saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
732
797
  }
733
798
  if (!result.interrupted && result.finalMessage) {
@@ -811,6 +876,7 @@ export async function main() {
811
876
  session.currentTurnId = null;
812
877
  session.currentTurnOpenedAt = null;
813
878
  session.lastAcceptedIntent = null;
879
+ session.resetRequested = false;
814
880
  session.lastActivity = Date.now();
815
881
  writeState(session);
816
882
  writeTurn(session);
@@ -986,6 +1052,7 @@ export async function main() {
986
1052
  senderName: message.senderName || message.senderId,
987
1053
  isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
988
1054
  behavior: payload.behavior,
1055
+ activeSelfContextId: payload.activeSelfContextId,
989
1056
  selfContexts: payload.selfContexts,
990
1057
  });
991
1058
  if (message.id) {
@@ -996,6 +1063,9 @@ export async function main() {
996
1063
  });
997
1064
  }
998
1065
  },
1066
+ onConversationUpdated: (payload) => {
1067
+ handleConversationUpdated(payload);
1068
+ },
999
1069
  onConnected: () => {
1000
1070
  streamConnected = true;
1001
1071
  void publishRuntimeHeartbeat();
@@ -1090,7 +1160,7 @@ export async function main() {
1090
1160
  await enqueueInboundMessage({
1091
1161
  conversationId: conversation.id,
1092
1162
  message: latestMessage,
1093
- senderName: latestMessage.senderId,
1163
+ senderName: latestMessage.senderName || latestMessage.senderId,
1094
1164
  isOwner: ownerId != null && latestMessage.senderId === ownerId,
1095
1165
  behavior: latestPage.behavior,
1096
1166
  selfContexts: latestPage.selfContexts,
@@ -1153,7 +1223,7 @@ export async function main() {
1153
1223
  continue;
1154
1224
  const signal = raw;
1155
1225
  const timestamp = signal.updatedAt ?? 0;
1156
- if ((signal.type !== 'interrupt' && signal.type !== 'stop_and_drop')
1226
+ if ((signal.type !== 'interrupt' && signal.type !== 'stop_and_drop' && signal.type !== 'new_session')
1157
1227
  || timestamp <= (lastSeenSignal.get(conversationId) ?? 0)) {
1158
1228
  continue;
1159
1229
  }
@@ -1161,6 +1231,12 @@ export async function main() {
1161
1231
  const session = sessions.get(conversationId);
1162
1232
  if (!session || session.closed)
1163
1233
  continue;
1234
+ if (signal.type === 'new_session') {
1235
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] new_session signal`);
1236
+ await resetRuntimeSession(session);
1237
+ await rtdbWrite(`/control/${conversationId}/${agentId}/signal`, null).catch(() => { });
1238
+ continue;
1239
+ }
1164
1240
  if (!session.running && (signal.type !== 'stop_and_drop' || session.queue.length === 0)) {
1165
1241
  await rtdbWrite(`/control/${conversationId}/${agentId}/signal`, null).catch(() => { });
1166
1242
  continue;
@@ -1,4 +1,4 @@
1
- import { type CanonConversation, type ResolvedAgentBehaviorPolicy } from '@canonmsg/core';
1
+ import { type CanonGroupContext, type CanonGroupContextMode, type CanonConversation, type ResolvedAgentBehaviorPolicy } from '@canonmsg/core';
2
2
  export interface InboundParticipantContext {
3
3
  conversationType: CanonConversation['type'] | 'unknown';
4
4
  memberCount: number | null;
@@ -6,6 +6,8 @@ export interface InboundParticipantContext {
6
6
  senderName: string;
7
7
  isOwner: boolean;
8
8
  mentionedAgent: boolean;
9
+ groupContext?: CanonGroupContext;
10
+ groupContextMode?: CanonGroupContextMode;
9
11
  recentSenderTypes: Array<'human' | 'ai_agent'>;
10
12
  recentHumanCount: number;
11
13
  recentAgentCount: number;
@@ -1,4 +1,4 @@
1
- import { evaluateParticipationPolicy, resolveAgentBehaviorPolicy, } from '@canonmsg/core';
1
+ import { buildCompactGroupContextLines, evaluateParticipationPolicy, resolveAgentBehaviorPolicy, } from '@canonmsg/core';
2
2
  function formatRecentSenders(senderTypes) {
3
3
  if (senderTypes.length === 0)
4
4
  return 'none';
@@ -18,6 +18,9 @@ export function buildInboundContextLines(context) {
18
18
  `Latest sender name: ${context.senderName}`,
19
19
  `Latest sender type: ${context.senderType}`,
20
20
  `Conversation type: ${conversationTypeLabel}`,
21
+ ...(context.groupContext && context.groupContextMode
22
+ ? buildCompactGroupContextLines(context.groupContext, context.groupContextMode)
23
+ : []),
21
24
  `Directly addressed to this agent: ${context.mentionedAgent ? 'yes' : 'no'}`,
22
25
  `Recent sender pattern: ${formatRecentSenders(context.recentSenderTypes)}`,
23
26
  `Recent human messages: ${context.recentHumanCount}`,
package/dist/register.js CHANGED
@@ -97,7 +97,9 @@ export async function main() {
97
97
  console.log(`Approved! Agent: ${result.agentName} (${result.agentId})`);
98
98
  console.log(`Saved as profile "${profileName}" in ~/.canon/agents.json`);
99
99
  console.log('Start it with: CANON_AGENT=' + profileName + ' canon-codex --cwd /path/to/project');
100
- console.log('Keep that terminal open. Closing it takes this local agent offline.');
100
+ console.log('Keep that terminal open while you want Canon to reach the agent.');
101
+ console.log('Closing it, logging out, rebooting, or sleeping long enough to stop the process takes this local agent offline until you revive it.');
102
+ console.log('Docs: https://canonmail.com/agents/integrations');
101
103
  break;
102
104
  }
103
105
  case 'rejected':
@@ -84,11 +84,26 @@ export function saveStoredThreadId(runtimeId, agentId, conversationId, baseCwd,
84
84
  }
85
85
  export function clearStoredThreadId(runtimeId, agentId, conversationId, baseCwd, executionMode) {
86
86
  if (runtimeId) {
87
+ const existing = baseCwd
88
+ ? loadRuntimeSessionState(runtimeId, {
89
+ conversationId,
90
+ baseCwd,
91
+ executionMode,
92
+ })
93
+ : null;
87
94
  clearRuntimeSessionState(runtimeId, {
88
95
  conversationId,
89
96
  baseCwd,
90
97
  executionMode,
91
98
  });
99
+ if (existing?.lastInboundMessageId && baseCwd) {
100
+ saveRuntimeSessionState(runtimeId, {
101
+ conversationId,
102
+ baseCwd,
103
+ ...(executionMode ? { executionMode } : {}),
104
+ lastInboundMessageId: existing.lastInboundMessageId,
105
+ });
106
+ }
92
107
  return;
93
108
  }
94
109
  const store = loadStore();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.9.8",
3
+ "version": "0.11.0",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,8 +29,8 @@
29
29
  "prepack": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@canonmsg/agent-sdk": "^1.1.4",
33
- "@canonmsg/core": "^0.15.4"
32
+ "@canonmsg/agent-sdk": "^1.3.0",
33
+ "@canonmsg/core": "^0.17.0"
34
34
  },
35
35
  "engines": {
36
36
  "node": ">=18.0.0"