@canonmsg/codex-plugin 0.1.1 → 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,10 +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
+ 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
+
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
+
23
27
  ## What v1 supports
24
28
 
25
29
  - Canon messages routed into Codex turns
@@ -55,6 +59,24 @@ npm run smoke -- /path/to/project
55
59
 
56
60
  ## Troubleshooting
57
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
+
58
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:
59
81
 
60
82
  ```bash
package/dist/host.js CHANGED
@@ -1,9 +1,9 @@
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
- import { basename, resolve } from 'node:path';
6
- import { CanonClient, CanonStream, clearSessionState, getActiveProfile, initRTDBAuth, releaseLock, resolveCanonAgent, rtdbRead, rtdbWrite, writeSessionState, } 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';
7
7
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
8
8
  import { CodexConversationAdapter, } from './adapter.js';
9
9
  import { clearStoredThreadId, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
@@ -12,27 +12,29 @@ const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
12
12
  const HEARTBEAT_MS = 30_000;
13
13
  const IDLE_CHECK_MS = 60_000;
14
14
  const CONTROL_POLL_MS = 2_000;
15
+ const CODEX_RUNTIME_CAPABILITIES = {
16
+ ...DEFAULT_RUNTIME_CAPABILITIES,
17
+ supportsInterrupt: true,
18
+ supportsQueue: true,
19
+ supportsNonFinalPermanentMessages: false,
20
+ };
15
21
  let workingDir = process.cwd();
16
22
  let workspaceOptions = [];
17
- function normalizeString(value) {
18
- if (typeof value !== 'string')
19
- return undefined;
20
- const trimmed = value.trim();
21
- return trimmed ? trimmed : undefined;
22
- }
23
- function buildWorkspaceOptions(primaryCwd, configured) {
24
- const uniqueDirs = Array.from(new Set([primaryCwd, ...configured].map((dir) => resolve(dir))));
25
- const seenLabels = new Map();
26
- return uniqueDirs.map((cwd, index) => {
27
- const baseLabel = basename(cwd) || cwd;
28
- const seenCount = (seenLabels.get(baseLabel) ?? 0) + 1;
29
- seenLabels.set(baseLabel, seenCount);
30
- return {
31
- id: index === 0 ? 'default' : `workspace-${index + 1}`,
32
- label: seenCount === 1 ? baseLabel : `${baseLabel} (${seenCount})`,
33
- cwd,
34
- };
35
- });
23
+ function normalizeRuntimeTurnState(value) {
24
+ const normalizedTurn = normalizeTurnState(value);
25
+ if (normalizedTurn) {
26
+ return { state: normalizedTurn.state };
27
+ }
28
+ if (!value || typeof value !== 'object')
29
+ return null;
30
+ const state = value.state;
31
+ if (state === 'running') {
32
+ return { state: 'streaming' };
33
+ }
34
+ if (state === 'requires_action') {
35
+ return { state: 'waiting_input' };
36
+ }
37
+ return null;
36
38
  }
37
39
  async function publishAgentRuntime(agentId, runtime) {
38
40
  await rtdbWrite(`/agent-runtime/${agentId}`, {
@@ -44,40 +46,24 @@ async function publishAgentRuntime(agentId, runtime) {
44
46
  }
45
47
  async function loadSessionConfig(conversationId, agentId) {
46
48
  const raw = await rtdbRead(`/session-config/${conversationId}/${agentId}`);
47
- if (!raw || typeof raw !== 'object')
48
- return null;
49
- const data = raw;
50
- return {
51
- workspaceId: normalizeString(data.workspaceId),
52
- legacyCwd: normalizeString(data.cwd),
53
- model: normalizeString(data.model),
54
- };
49
+ return readSessionWorkspaceConfig(raw);
55
50
  }
56
51
  function resolveWorkspaceCwd(config) {
57
- if (config?.workspaceId) {
58
- const workspace = workspaceOptions.find((option) => option.id === config.workspaceId);
59
- if (workspace)
60
- return workspace.cwd;
61
- }
62
- if (config?.legacyCwd) {
63
- const workspace = workspaceOptions.find((option) => option.cwd === config.legacyCwd);
64
- if (workspace)
65
- return workspace.cwd;
66
- }
67
- return workspaceOptions[0]?.cwd ?? workingDir;
68
- }
69
- function toPublicWorkspaceOptions() {
70
- return workspaceOptions.map(({ id, label }) => ({ id, label }));
52
+ return resolveConfiguredWorkspaceCwd({
53
+ workspaceOptions,
54
+ config,
55
+ defaultCwd: workingDir,
56
+ });
71
57
  }
72
58
  function buildCanonPrompt(input) {
73
59
  return [
74
60
  'You are connected to Canon messaging through a Codex host wrapper.',
75
- 'Reply naturally to Canon participants.',
76
61
  'Only the last assistant message from this turn will be delivered as the permanent Canon reply.',
77
62
  'Short intermediate assistant messages may be shown as ephemeral status while you work.',
78
63
  ...buildInboundContextLines(input.participantContext),
64
+ ...buildBehaviorPolicyLines(input.behavior),
79
65
  'Canon participants may be humans or AI agents.',
80
- 'If the latest message came from another AI agent, avoid agent-only ping-pong and only reply when directly addressed, when a human is clearly steering, or when a reply is genuinely necessary.',
66
+ 'Honor the Canon behavior policy above when deciding how proactively to participate.',
81
67
  `Conversation ID: ${input.conversationId}`,
82
68
  '',
83
69
  'New Canon message:',
@@ -132,6 +118,9 @@ function formatTurnFailure(errorText) {
132
118
  const shortened = normalized.length > 280 ? `${normalized.slice(0, 277)}...` : normalized;
133
119
  return `Codex failed before sending a final reply: ${shortened}`;
134
120
  }
121
+ function sleep(ms) {
122
+ return new Promise((resolve) => setTimeout(resolve, ms));
123
+ }
135
124
  async function main() {
136
125
  const { values: args } = parseArgs({
137
126
  options: {
@@ -145,12 +134,17 @@ async function main() {
145
134
  config: { type: 'string', multiple: true },
146
135
  'codex-bin': { type: 'string' },
147
136
  'full-auto': { type: 'boolean' },
137
+ 'enable-worktrees': { type: 'boolean' },
148
138
  'dangerously-bypass-approvals-and-sandbox': { type: 'boolean' },
149
139
  },
150
140
  strict: true,
151
141
  });
152
142
  workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
153
- 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
+ }
154
148
  if (typeof args['ask-for-approval'] === 'string') {
155
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.');
156
150
  }
@@ -204,20 +198,24 @@ async function main() {
204
198
  return conversationCache.get(conversationId) ?? null;
205
199
  }
206
200
  }
201
+ async function loadSenderRuntimeState(conversationId, senderId) {
202
+ try {
203
+ const [turnState, sessionState] = await Promise.all([
204
+ rtdbRead(`/turn-state/${conversationId}/${senderId}`),
205
+ rtdbRead(`/session-state/${conversationId}/${senderId}`),
206
+ ]);
207
+ return normalizeRuntimeTurnState(turnState) ?? normalizeRuntimeTurnState(sessionState);
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ }
207
213
  async function loadParticipantContext(input) {
208
214
  const [conversation, recentMessages] = await Promise.all([
209
215
  getConversationMeta(input.conversationId),
210
- client.getMessages(input.conversationId, 6).catch(() => []),
216
+ client.getMessages(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => []),
211
217
  ]);
212
- const recentSenderTypes = recentMessages
213
- .filter((message) => message.senderId !== agentId)
214
- .map((message) => message.senderType);
215
- let consecutiveAgentTurns = 0;
216
- for (const senderType of recentSenderTypes) {
217
- if (senderType !== 'ai_agent')
218
- break;
219
- consecutiveAgentTurns += 1;
220
- }
218
+ const history = buildParticipationHistorySnapshot(recentMessages, agentId);
221
219
  return {
222
220
  conversationType: conversation?.type ?? 'unknown',
223
221
  memberCount: conversation?.memberIds?.length ?? null,
@@ -225,10 +223,11 @@ async function main() {
225
223
  senderName: input.senderName,
226
224
  isOwner: input.isOwner,
227
225
  mentionedAgent: Array.isArray(input.message.mentions) && input.message.mentions.includes(agentId),
228
- recentSenderTypes,
229
- recentHumanCount: recentSenderTypes.filter((senderType) => senderType === 'human').length,
230
- recentAgentCount: recentSenderTypes.filter((senderType) => senderType === 'ai_agent').length,
231
- consecutiveAgentTurns,
226
+ recentSenderTypes: history.recentSenderTypes,
227
+ recentHumanCount: history.recentHumanCount,
228
+ recentAgentCount: history.recentAgentCount,
229
+ consecutiveAgentTurns: history.consecutiveAgentTurns,
230
+ currentAgentStreakStartedByHuman: history.currentAgentStreakStartedByHuman,
232
231
  };
233
232
  }
234
233
  function writeState(session) {
@@ -236,22 +235,50 @@ async function main() {
236
235
  lastError: session.state.lastError,
237
236
  model: session.state.model,
238
237
  cwd: session.cwd,
238
+ executionMode: session.environment.mode,
239
+ ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
239
240
  hostMode: true,
240
241
  clientType: 'codex',
241
242
  state: session.state.state,
242
243
  isActive: true,
243
244
  }).catch(() => { });
244
245
  }
246
+ function writeTurn(session) {
247
+ writeTurnState(session.conversationId, agentId, {
248
+ turnId: session.currentTurnId,
249
+ state: session.turnState,
250
+ queueDepth: session.queue.length,
251
+ currentSpeakerId: agentId,
252
+ lastAcceptedIntent: session.lastAcceptedIntent,
253
+ capabilities: CODEX_RUNTIME_CAPABILITIES,
254
+ ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
255
+ ...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
256
+ ? { completedAt: { '.sv': 'timestamp' } }
257
+ : {}),
258
+ }).catch(() => { });
259
+ }
260
+ async function markQueuedMessageAccepted(conversationId, sourceMessageId, markAccepted) {
261
+ if (!markAccepted || !sourceMessageId)
262
+ return;
263
+ await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
264
+ }
245
265
  function clearStreaming(conversationId) {
246
266
  rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
247
267
  }
268
+ async function handoffFinalMessage(conversationId) {
269
+ await sleep(FINAL_MESSAGE_HANDOFF_MS);
270
+ clearStreaming(conversationId);
271
+ client.setTyping(conversationId, false).catch(() => { });
272
+ }
248
273
  function closeSession(conversationId) {
249
274
  const session = sessions.get(conversationId);
250
275
  if (!session)
251
276
  return;
252
277
  session.closed = true;
278
+ releaseConversationEnvironment(session.environment);
253
279
  clearStreaming(conversationId);
254
280
  clearSessionState(conversationId, agentId).catch(() => { });
281
+ clearTurnState(conversationId, agentId).catch(() => { });
255
282
  client.setTyping(conversationId, false).catch(() => { });
256
283
  sessions.delete(conversationId);
257
284
  }
@@ -282,39 +309,59 @@ async function main() {
282
309
  }
283
310
  const creation = (async () => {
284
311
  const config = await loadSessionConfig(conversationId, agentId);
285
- const sessionCwd = resolveWorkspaceCwd(config);
286
- const sessionModel = config?.model ?? (typeof args.model === 'string' ? args.model : undefined);
287
- const storedThreadId = loadStoredThreadId(agentId, conversationId, sessionCwd);
288
- const session = {
312
+ const workspaceCwd = resolveWorkspaceCwd(config);
313
+ const environment = prepareConversationEnvironment({
314
+ agentId,
289
315
  conversationId,
290
- cwd: sessionCwd,
291
- 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,
292
325
  cwd: sessionCwd,
293
- threadId: storedThreadId,
294
- codexBin: typeof args['codex-bin'] === 'string' ? args['codex-bin'] : 'codex',
295
- model: sessionModel ?? null,
296
- sandbox: (typeof args.sandbox === 'string' ? args.sandbox : null),
297
- approvalPolicy: (typeof args['ask-for-approval'] === 'string'
298
- ? args['ask-for-approval']
299
- : null),
300
- codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
301
- addDirs: args['add-dir'] ?? [],
302
- configOverrides: args.config ?? [],
303
- fullAuto: Boolean(args['full-auto']),
304
- bypassApprovalsAndSandbox: Boolean(args['dangerously-bypass-approvals-and-sandbox']),
305
- }),
306
- queue: [],
307
- running: false,
308
- state: {
309
- model: sessionModel,
310
- state: 'idle',
311
- },
312
- lastActivity: Date.now(),
313
- closed: false,
314
- };
315
- sessions.set(conversationId, session);
316
- writeState(session);
317
- 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
+ }
318
365
  })();
319
366
  pendingSessionCreations.set(conversationId, creation);
320
367
  try {
@@ -324,43 +371,81 @@ async function main() {
324
371
  pendingSessionCreations.delete(conversationId);
325
372
  }
326
373
  }
327
- function enqueuePrompt(session, prompt) {
328
- session.queue.push(prompt);
374
+ function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false) {
375
+ const nextPrompt = { prompt, intent, sourceMessageId, markAccepted };
376
+ if (toFront) {
377
+ session.queue.unshift(nextPrompt);
378
+ }
379
+ else {
380
+ session.queue.push(nextPrompt);
381
+ }
329
382
  session.lastActivity = Date.now();
383
+ writeTurn(session);
330
384
  void runNextTurn(session);
331
385
  }
332
386
  async function enqueueInboundMessage(input) {
333
387
  const content = renderInboundContent(input.message);
388
+ const conversation = await getConversationMeta(input.conversationId);
389
+ const behavior = input.behavior ?? conversation?.behavior;
334
390
  const participantContext = await loadParticipantContext({
335
391
  conversationId: input.conversationId,
336
392
  message: input.message,
337
393
  senderName: input.senderName,
338
394
  isOwner: input.isOwner,
339
395
  });
340
- const autoReply = decideAutoReply(participantContext);
396
+ const autoReply = decideAutoReply(participantContext, behavior);
341
397
  if (!autoReply.allow) {
342
398
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
343
399
  return;
344
400
  }
345
401
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
346
- const session = await getOrCreateSession(input.conversationId);
347
- enqueuePrompt(session, buildCanonPrompt({
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
+ }
413
+ const turnMetadata = normalizeTurnMetadata(input.message.metadata);
414
+ const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
415
+ const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
416
+ const prompt = buildCanonPrompt({
348
417
  content,
349
418
  conversationId: input.conversationId,
350
419
  participantContext,
351
- }));
420
+ behavior,
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);
352
431
  }
353
432
  async function runNextTurn(session) {
354
433
  if (session.running || session.closed)
355
434
  return;
356
- const prompt = session.queue.shift();
357
- if (!prompt)
435
+ const nextTurn = session.queue.shift();
436
+ if (!nextTurn)
358
437
  return;
359
438
  session.running = true;
360
439
  session.state.lastError = undefined;
361
440
  session.state.state = 'running';
441
+ session.currentTurnId = randomUUID();
442
+ session.currentTurnOpenedAt = Date.now();
443
+ session.lastAcceptedIntent = nextTurn.intent;
444
+ session.turnState = 'thinking';
362
445
  session.lastActivity = Date.now();
446
+ await markQueuedMessageAccepted(session.conversationId, nextTurn.sourceMessageId, nextTurn.markAccepted);
363
447
  writeState(session);
448
+ writeTurn(session);
364
449
  client.setTyping(session.conversationId, true, 'thinking').catch(() => { });
365
450
  rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
366
451
  text: 'Thinking…',
@@ -368,7 +453,7 @@ async function main() {
368
453
  updatedAt: { '.sv': 'timestamp' },
369
454
  }).catch(() => { });
370
455
  try {
371
- const result = await session.adapter.runTurn(prompt, (event) => {
456
+ const result = await session.adapter.runTurn(nextTurn.prompt, (event) => {
372
457
  session.lastActivity = Date.now();
373
458
  if (event.type === 'thread.started') {
374
459
  saveStoredThreadId(agentId, session.conversationId, session.cwd, event.threadId);
@@ -376,6 +461,8 @@ async function main() {
376
461
  return;
377
462
  }
378
463
  if (event.type === 'message') {
464
+ session.turnState = 'streaming';
465
+ writeTurn(session);
379
466
  rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
380
467
  text: event.text,
381
468
  status: 'streaming',
@@ -384,6 +471,8 @@ async function main() {
384
471
  return;
385
472
  }
386
473
  if (event.type === 'command.started') {
474
+ session.turnState = 'tool';
475
+ writeTurn(session);
387
476
  client.setTyping(session.conversationId, false).catch(() => { });
388
477
  rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
389
478
  text: summarizeCommand(event.command),
@@ -401,10 +490,16 @@ async function main() {
401
490
  if (result.threadId) {
402
491
  saveStoredThreadId(agentId, session.conversationId, session.cwd, result.threadId);
403
492
  }
404
- clearStreaming(session.conversationId);
405
- client.setTyping(session.conversationId, false).catch(() => { });
406
493
  if (!result.interrupted && result.finalMessage) {
407
- await client.sendMessage(session.conversationId, result.finalMessage);
494
+ await client.sendMessage(session.conversationId, result.finalMessage, {
495
+ metadata: {
496
+ turnId: session.currentTurnId,
497
+ turnSemantics: 'turn_complete',
498
+ turnComplete: true,
499
+ deliveryIntent: session.lastAcceptedIntent ?? undefined,
500
+ },
501
+ });
502
+ await handoffFinalMessage(session.conversationId);
408
503
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${result.finalMessage.length} chars)`);
409
504
  }
410
505
  else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
@@ -414,27 +509,53 @@ async function main() {
414
509
  if (result.errorText) {
415
510
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
416
511
  }
417
- await client.sendMessage(session.conversationId, userVisibleError);
512
+ await client.sendMessage(session.conversationId, userVisibleError, {
513
+ metadata: {
514
+ turnId: session.currentTurnId,
515
+ turnSemantics: 'turn_complete',
516
+ turnComplete: true,
517
+ deliveryIntent: session.lastAcceptedIntent ?? undefined,
518
+ },
519
+ });
520
+ await handoffFinalMessage(session.conversationId);
521
+ }
522
+ else if (!result.interrupted) {
523
+ await handoffFinalMessage(session.conversationId);
418
524
  }
419
525
  else if (result.interrupted) {
526
+ session.turnState = 'interrupted';
527
+ writeTurn(session);
528
+ clearStreaming(session.conversationId);
529
+ client.setTyping(session.conversationId, false).catch(() => { });
420
530
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn interrupted`);
421
531
  }
422
532
  }
423
533
  catch (error) {
424
- clearStreaming(session.conversationId);
425
- client.setTyping(session.conversationId, false).catch(() => { });
426
534
  const message = `The Codex host failed to start a turn: ${error instanceof Error ? error.message : String(error)}`;
427
535
  session.state.lastError = message;
428
536
  writeState(session);
429
- await client.sendMessage(session.conversationId, message).catch(() => { });
537
+ await client.sendMessage(session.conversationId, message, {
538
+ metadata: {
539
+ turnId: session.currentTurnId,
540
+ turnSemantics: 'turn_complete',
541
+ turnComplete: true,
542
+ deliveryIntent: session.lastAcceptedIntent ?? undefined,
543
+ },
544
+ }).catch(() => { });
545
+ await handoffFinalMessage(session.conversationId);
430
546
  clearStoredThreadId(agentId, session.conversationId);
431
547
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
432
548
  }
433
549
  finally {
434
550
  session.running = false;
435
551
  session.state.state = 'idle';
552
+ session.turnState = 'idle';
553
+ session.currentTurnId = null;
554
+ session.currentTurnOpenedAt = null;
555
+ session.lastAcceptedIntent = null;
436
556
  session.lastActivity = Date.now();
437
557
  writeState(session);
558
+ writeTurn(session);
438
559
  if (session.queue.length > 0) {
439
560
  void runNextTurn(session);
440
561
  }
@@ -452,7 +573,8 @@ async function main() {
452
573
  conversationId: payload.conversationId,
453
574
  message,
454
575
  senderName: message.senderName || message.senderId,
455
- isOwner: Boolean(message.isOwner),
576
+ isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
577
+ behavior: payload.behavior,
456
578
  });
457
579
  },
458
580
  onConnected: () => console.error('[canon-codex] SSE connected'),
@@ -464,7 +586,7 @@ async function main() {
464
586
  await publishAgentRuntime(agentId, {
465
587
  defaultWorkspaceId: workspaceOptions[0]?.id,
466
588
  ...(typeof args.model === 'string' ? { defaultModel: args.model } : {}),
467
- availableWorkspaces: toPublicWorkspaceOptions(),
589
+ availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
468
590
  });
469
591
  }
470
592
  catch (error) {
@@ -475,20 +597,34 @@ async function main() {
475
597
  for (const conversation of conversations) {
476
598
  clearStreaming(conversation.id);
477
599
  clearSessionState(conversation.id, agentId).catch(() => { });
600
+ clearTurnState(conversation.id, agentId).catch(() => { });
478
601
  }
479
602
  for (const conversation of conversations) {
480
603
  if (!conversation.lastMessage || conversation.lastMessage.senderId === agentId)
481
604
  continue;
482
- const latestMessages = await client.getMessages(conversation.id, 1);
483
- const latestMessage = latestMessages[0];
605
+ const latestPage = await client.getMessagesPage(conversation.id, 1);
606
+ const latestMessage = latestPage.messages[0];
484
607
  if (!latestMessage || latestMessage.senderId === agentId)
485
608
  continue;
609
+ const senderTurnState = latestMessage.senderType === 'ai_agent'
610
+ ? await loadSenderRuntimeState(conversation.id, latestMessage.senderId)
611
+ : null;
612
+ const triggerDecision = shouldTriggerAgentTurn({
613
+ senderType: latestMessage.senderType ?? 'human',
614
+ metadata: latestMessage.metadata,
615
+ senderTurnState,
616
+ });
617
+ if (!triggerDecision.allow) {
618
+ console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Skipping startup recovery for suppressed message (${triggerDecision.reason})`);
619
+ continue;
620
+ }
486
621
  console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Recovering latest inbound message on startup`);
487
622
  await enqueueInboundMessage({
488
623
  conversationId: conversation.id,
489
624
  message: latestMessage,
490
625
  senderName: latestMessage.senderId,
491
626
  isOwner: ownerId != null && latestMessage.senderId === ownerId,
627
+ behavior: latestPage.behavior,
492
628
  });
493
629
  }
494
630
  }
@@ -533,16 +669,21 @@ async function main() {
533
669
  continue;
534
670
  const signal = raw;
535
671
  const timestamp = signal.updatedAt ?? 0;
536
- if (signal.type !== 'interrupt' || timestamp <= (lastSeenSignal.get(conversationId) ?? 0)) {
672
+ if ((signal.type !== 'interrupt' && signal.type !== 'stop_and_drop')
673
+ || timestamp <= (lastSeenSignal.get(conversationId) ?? 0)) {
537
674
  continue;
538
675
  }
539
676
  lastSeenSignal.set(conversationId, timestamp);
540
677
  const session = sessions.get(conversationId);
541
678
  if (!session || session.closed)
542
679
  continue;
543
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Interrupt signal`);
680
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${signal.type} signal`);
544
681
  await session.adapter.interrupt();
545
- session.queue.length = 0;
682
+ session.turnState = 'interrupted';
683
+ if (signal.type === 'stop_and_drop') {
684
+ session.queue.length = 0;
685
+ }
686
+ writeTurn(session);
546
687
  clearStreaming(conversationId);
547
688
  client.setTyping(conversationId, false).catch(() => { });
548
689
  }
@@ -557,6 +698,7 @@ async function main() {
557
698
  const heartbeat = setInterval(() => {
558
699
  for (const session of sessions.values()) {
559
700
  writeState(session);
701
+ writeTurn(session);
560
702
  }
561
703
  }, HEARTBEAT_MS);
562
704
  const idleCheck = setInterval(() => {
@@ -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' && context.recentHumanCount === 0) {
38
- return {
39
- allow: false,
40
- reason: 'suppressing agent-only group auto-reply without a direct mention',
41
- };
42
- }
43
- if (context.consecutiveAgentTurns >= 2 && context.recentHumanCount === 0) {
44
- return {
45
- allow: false,
46
- reason: 'suppressing likely agent-only loop in a direct conversation',
47
- };
48
- }
49
- return { allow: true, reason: 'direct agent message allowed' };
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
File without changes
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.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/host.js",
@@ -15,14 +15,14 @@
15
15
  "scripts"
16
16
  ],
17
17
  "scripts": {
18
- "build": "tsc",
18
+ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
19
19
  "dev": "tsc --watch",
20
20
  "smoke": "node scripts/smoke-test.mjs",
21
21
  "test": "vitest run",
22
- "prepublishOnly": "npm run build"
22
+ "prepack": "npm run build"
23
23
  },
24
24
  "dependencies": {
25
- "@canonmsg/core": "^0.2.2"
25
+ "@canonmsg/core": "^0.4.0"
26
26
  },
27
27
  "engines": {
28
28
  "node": ">=18.0.0"
@@ -1 +0,0 @@
1
- export {};
@@ -1,59 +0,0 @@
1
- import { EventEmitter } from 'node:events';
2
- import { PassThrough } from 'node:stream';
3
- import { beforeEach, describe, expect, it, vi } from 'vitest';
4
- const { spawnMock } = vi.hoisted(() => ({
5
- spawnMock: vi.fn(),
6
- }));
7
- vi.mock('node:child_process', () => ({
8
- spawn: spawnMock,
9
- }));
10
- import { CodexConversationAdapter } from './adapter.js';
11
- class MockChildProcess extends EventEmitter {
12
- stdout = new PassThrough();
13
- stderr = new PassThrough();
14
- }
15
- describe('CodexConversationAdapter', () => {
16
- beforeEach(() => {
17
- spawnMock.mockReset();
18
- });
19
- it('translates legacy non-interactive approval flags into --full-auto', () => {
20
- const adapter = new CodexConversationAdapter({
21
- cwd: '/tmp/project',
22
- sandbox: 'workspace-write',
23
- approvalPolicy: 'never',
24
- });
25
- const args = adapter.buildArgs('hello');
26
- expect(args).toContain('--full-auto');
27
- expect(args).not.toContain('-a');
28
- expect(args).not.toContain('--ask-for-approval');
29
- });
30
- it('does not force --full-auto for read-only sessions', () => {
31
- const adapter = new CodexConversationAdapter({
32
- cwd: '/tmp/project',
33
- sandbox: 'read-only',
34
- approvalPolicy: 'never',
35
- });
36
- const args = adapter.buildArgs('hello');
37
- expect(args).toContain('-s');
38
- expect(args).toContain('read-only');
39
- expect(args).not.toContain('--full-auto');
40
- });
41
- it('preserves structured turn failure text from Codex JSON output', async () => {
42
- const child = new MockChildProcess();
43
- spawnMock.mockReturnValue(child);
44
- const adapter = new CodexConversationAdapter({
45
- cwd: '/tmp/project',
46
- });
47
- const turnPromise = adapter.runTurn('hello', () => { });
48
- child.stdout.write('{"type":"thread.started","thread_id":"thread-123"}\n');
49
- child.stdout.write('{"type":"turn.started"}\n');
50
- child.stdout.write('{"type":"error","message":"Quota exceeded. Check your plan and billing details."}\n');
51
- child.stdout.write('{"type":"turn.failed","error":{"message":"Quota exceeded. Check your plan and billing details."}}\n');
52
- child.emit('close', 1);
53
- const result = await turnPromise;
54
- expect(result.threadId).toBe('thread-123');
55
- expect(result.exitCode).toBe(1);
56
- expect(result.finalMessage).toBeNull();
57
- expect(result.errorText).toBe('Quota exceeded. Check your plan and billing details.');
58
- });
59
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,97 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { buildInboundContextLines, decideAutoReply } from './inbound-policy.js';
3
- function makeContext(overrides = {}) {
4
- return {
5
- conversationType: 'direct',
6
- memberCount: 2,
7
- senderType: 'human',
8
- senderName: 'Alice',
9
- isOwner: false,
10
- mentionedAgent: false,
11
- recentSenderTypes: ['human'],
12
- recentHumanCount: 1,
13
- recentAgentCount: 0,
14
- consecutiveAgentTurns: 0,
15
- ...overrides,
16
- };
17
- }
18
- describe('decideAutoReply', () => {
19
- it('allows human senders', () => {
20
- const decision = decideAutoReply(makeContext());
21
- expect(decision).toEqual({
22
- allow: true,
23
- reason: 'latest sender is human',
24
- });
25
- });
26
- it('suppresses group messages from another agent without a mention', () => {
27
- const decision = decideAutoReply(makeContext({
28
- conversationType: 'group',
29
- memberCount: 3,
30
- senderType: 'ai_agent',
31
- recentSenderTypes: ['ai_agent', 'ai_agent'],
32
- recentHumanCount: 0,
33
- recentAgentCount: 2,
34
- consecutiveAgentTurns: 2,
35
- }));
36
- expect(decision.allow).toBe(false);
37
- });
38
- it('allows group agent replies while a human is still active in the recent window', () => {
39
- const decision = decideAutoReply(makeContext({
40
- conversationType: 'group',
41
- memberCount: 3,
42
- senderType: 'ai_agent',
43
- recentSenderTypes: ['ai_agent', 'human'],
44
- recentHumanCount: 1,
45
- recentAgentCount: 1,
46
- consecutiveAgentTurns: 1,
47
- }));
48
- expect(decision).toEqual({
49
- allow: true,
50
- reason: 'direct agent message allowed',
51
- });
52
- });
53
- it('allows explicitly addressed agent messages in groups', () => {
54
- const decision = decideAutoReply(makeContext({
55
- conversationType: 'group',
56
- memberCount: 3,
57
- senderType: 'ai_agent',
58
- mentionedAgent: true,
59
- recentSenderTypes: ['ai_agent', 'human'],
60
- recentHumanCount: 1,
61
- recentAgentCount: 1,
62
- consecutiveAgentTurns: 1,
63
- }));
64
- expect(decision).toEqual({
65
- allow: true,
66
- reason: 'another agent explicitly addressed this agent',
67
- });
68
- });
69
- it('suppresses likely direct agent loops with no recent human activity', () => {
70
- const decision = decideAutoReply(makeContext({
71
- senderType: 'ai_agent',
72
- recentSenderTypes: ['ai_agent', 'ai_agent'],
73
- recentHumanCount: 0,
74
- recentAgentCount: 2,
75
- consecutiveAgentTurns: 2,
76
- }));
77
- expect(decision.allow).toBe(false);
78
- });
79
- });
80
- describe('buildInboundContextLines', () => {
81
- it('includes sender and recent activity context', () => {
82
- const lines = buildInboundContextLines(makeContext({
83
- conversationType: 'group',
84
- memberCount: 4,
85
- senderType: 'ai_agent',
86
- senderName: 'Ernest',
87
- recentSenderTypes: ['ai_agent', 'human', 'ai_agent'],
88
- recentHumanCount: 1,
89
- recentAgentCount: 2,
90
- consecutiveAgentTurns: 1,
91
- }));
92
- expect(lines).toContain('Latest sender name: Ernest');
93
- expect(lines).toContain('Latest sender type: ai_agent');
94
- expect(lines).toContain('Conversation type: group (4 members)');
95
- expect(lines).toContain('Recent sender pattern: agent -> human -> agent');
96
- });
97
- });