@canonmsg/codex-plugin 0.12.0 → 0.13.1

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.
Files changed (2) hide show
  1. package/dist/host.js +191 -21
  2. package/package.json +3 -3
package/dist/host.js CHANGED
@@ -5,7 +5,7 @@ import { spawnSync } from 'node:child_process';
5
5
  import { dirname } from 'node:path';
6
6
  import { parseArgs } from 'node:util';
7
7
  import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, } from '@canonmsg/agent-sdk';
8
- import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildPlanApprovalRequest, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, 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, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, saveRuntimeSessionState, buildBoundedTurnTrail, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
8
+ import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildPlanApprovalRequest, buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createTypingStatusPublisher, 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, parseRuntimeCardV1, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, sendMessageWithRetry, shouldTriggerAgentTurn, saveRuntimeSessionState, buildBoundedTurnTrail, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
9
9
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
10
10
  import { CodexConversationAdapter, } from './adapter.js';
11
11
  import { CodexAppServerAdapter } from './app-server-adapter.js';
@@ -54,6 +54,8 @@ const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
54
54
  const HEARTBEAT_MS = 30_000;
55
55
  const IDLE_CHECK_MS = 60_000;
56
56
  const CONTROL_POLL_MS = 2_000;
57
+ const IDLE_CONTROL_POLL_MS = 10_000;
58
+ const CONTROL_POLL_JITTER_MS = 1_000;
57
59
  const CODEX_RUNTIME_CAPABILITIES = {
58
60
  ...DEFAULT_RUNTIME_CAPABILITIES,
59
61
  supportsInterrupt: true,
@@ -61,6 +63,10 @@ const CODEX_RUNTIME_CAPABILITIES = {
61
63
  supportsQueue: true,
62
64
  supportsNonFinalPermanentMessages: false,
63
65
  };
66
+ function controlPollDelayMs(hasActiveWork) {
67
+ const base = hasActiveWork ? CONTROL_POLL_MS : IDLE_CONTROL_POLL_MS;
68
+ return base + Math.floor(Math.random() * CONTROL_POLL_JITTER_MS);
69
+ }
64
70
  let workingDir = process.cwd();
65
71
  let workspaceOptions = [];
66
72
  let workspaceRoots = [];
@@ -109,6 +115,21 @@ function buildCodexRuntimeDescriptor(input) {
109
115
  presentation: input.presentation,
110
116
  streamingTextMode: 'snapshot',
111
117
  commands,
118
+ ...(input.supportsRichCards
119
+ ? {
120
+ runtimeCards: {
121
+ rich: {
122
+ schema: 'canon.card.v1',
123
+ lifecycle: 'blocking_requires_action',
124
+ responder: 'agent_owner',
125
+ result: 'action_or_values',
126
+ maxTimeoutMs: 30 * 60_000,
127
+ blockKinds: ['summary', 'metricGrid', 'chart', 'table', 'list', 'callout', 'actions'],
128
+ native: true,
129
+ },
130
+ },
131
+ }
132
+ : {}),
112
133
  });
113
134
  if (input.models.length > 0) {
114
135
  return descriptor;
@@ -375,6 +396,11 @@ export async function main() {
375
396
  console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
376
397
  const client = new CanonClient(apiKey, baseUrl);
377
398
  initRTDBAuth(client);
399
+ const typingSignals = createTypingStatusPublisher({
400
+ setTyping: (conversationId, typing, status) => status
401
+ ? client.setTyping(conversationId, typing, status)
402
+ : client.setTyping(conversationId, typing),
403
+ });
378
404
  let agentId;
379
405
  let ownerId = null;
380
406
  let ownerName = null;
@@ -645,31 +671,33 @@ export async function main() {
645
671
  turnId: session.currentTurnId ?? block.turnId,
646
672
  })));
647
673
  }
674
+ function buildCodexMessageId(session, kind) {
675
+ return `codex-${kind}-${session.currentTurnId ?? randomUUID()}`;
676
+ }
677
+ function buildCodexRuntimeCardOutcomeMessageId(cardId, status) {
678
+ return `codex-card-${cardId}-${status}`;
679
+ }
648
680
  async function handoffFinalMessage(conversationId) {
649
681
  await sleep(FINAL_MESSAGE_HANDOFF_MS);
650
682
  clearStreaming(conversationId);
651
- client.setTyping(conversationId, false).catch(() => { });
683
+ typingSignals.clear(conversationId).catch(() => { });
652
684
  }
653
685
  function refreshVisibleWorkSignal(session) {
654
686
  if (!session.running || session.closed)
655
687
  return;
656
688
  if (session.turnState !== 'thinking' && session.turnState !== 'tool')
657
689
  return;
658
- client.setTyping(session.conversationId, true, 'thinking').catch(() => { });
690
+ typingSignals.start(session.conversationId, 'thinking').catch(() => { });
659
691
  }
660
692
  function startVisibleWorkSignal(session) {
661
693
  refreshVisibleWorkSignal(session);
662
- if (session.typingKeepaliveTimer)
663
- return;
664
- session.typingKeepaliveTimer = setInterval(() => {
665
- refreshVisibleWorkSignal(session);
666
- }, 3500);
667
694
  }
668
695
  function stopVisibleWorkSignal(session) {
669
696
  if (session.typingKeepaliveTimer) {
670
697
  clearInterval(session.typingKeepaliveTimer);
671
698
  session.typingKeepaliveTimer = null;
672
699
  }
700
+ typingSignals.clear(session.conversationId).catch(() => { });
673
701
  }
674
702
  function closeSession(conversationId) {
675
703
  const session = sessions.get(conversationId);
@@ -684,7 +712,8 @@ export async function main() {
684
712
  clearStreaming(conversationId);
685
713
  runtimeState.clearSessionState(conversationId).catch(() => { });
686
714
  runtimeState.clearTurnState(conversationId).catch(() => { });
687
- client.setTyping(conversationId, false).catch(() => { });
715
+ typingSignals.clear(conversationId).catch(() => { });
716
+ typingSignals.dispose(conversationId);
688
717
  sessions.delete(conversationId);
689
718
  }
690
719
  async function resetRuntimeSession(session) {
@@ -710,7 +739,7 @@ export async function main() {
710
739
  }
711
740
  stopVisibleWorkSignal(session);
712
741
  clearStreaming(conversationId);
713
- client.setTyping(conversationId, false).catch(() => { });
742
+ typingSignals.clear(conversationId).catch(() => { });
714
743
  writeState(session);
715
744
  writeTurn(session);
716
745
  }
@@ -893,10 +922,143 @@ export async function main() {
893
922
  }).catch(() => null);
894
923
  return { decision: 'deny' };
895
924
  }
925
+ async function waitForRuntimeCardResponse(input) {
926
+ while (Date.now() < input.expiresAt) {
927
+ const response = await client.consumeRuntimeCardResponse({
928
+ conversationId: input.conversationId,
929
+ cardId: input.cardId,
930
+ }).catch(() => null);
931
+ if (response?.status === 'submitted') {
932
+ return {
933
+ status: 'submitted',
934
+ ...(response.actionId ? { actionId: response.actionId } : {}),
935
+ ...(response.values ? { values: response.values } : {}),
936
+ };
937
+ }
938
+ if (response?.status === 'cancelled' || response?.status === 'timeout') {
939
+ return { status: response.status };
940
+ }
941
+ await sleep(1_000);
942
+ }
943
+ const response = await client.consumeRuntimeCardResponse({
944
+ conversationId: input.conversationId,
945
+ cardId: input.cardId,
946
+ }).catch(() => null);
947
+ if (response?.status === 'submitted') {
948
+ return {
949
+ status: 'submitted',
950
+ ...(response.actionId ? { actionId: response.actionId } : {}),
951
+ ...(response.values ? { values: response.values } : {}),
952
+ };
953
+ }
954
+ return { status: 'timeout' };
955
+ }
956
+ function runtimeCardRequestPayload(method, params) {
957
+ if (method !== 'item/runtimeCard/request'
958
+ && method !== 'runtimeCard/request') {
959
+ return null;
960
+ }
961
+ const input = isRecord(params.input) ? params.input : null;
962
+ const args = isRecord(params.arguments) ? params.arguments : null;
963
+ return params.card ?? params.cardDocument ?? input?.card ?? args?.card ?? null;
964
+ }
896
965
  async function handleCodexServerRequest(session, request) {
897
966
  const requestId = String(request.id);
898
967
  const params = request.params;
899
968
  const expiresAt = Date.now() + 30 * 60_000;
969
+ const runtimeCardPayload = runtimeCardRequestPayload(request.method, params);
970
+ if (runtimeCardPayload) {
971
+ const card = parseRuntimeCardV1(runtimeCardPayload);
972
+ if (!card) {
973
+ return { status: 'cancelled', error: 'Invalid canon.card.v1 card' };
974
+ }
975
+ const cardId = readString(params, 'cardId')
976
+ ?? readString(params, 'itemId')
977
+ ?? card.cardId
978
+ ?? requestId;
979
+ let requestCreated = false;
980
+ let requestResolved = false;
981
+ try {
982
+ await client.createRuntimeCardRequest({
983
+ conversationId: session.conversationId,
984
+ cardId,
985
+ card: { ...card, cardId },
986
+ expiresAt,
987
+ // Omit responseUserId so the backend targets a reachable member (the
988
+ // owner if present, else the sole other member) — the owner is often
989
+ // not a member of agent-to-user DMs.
990
+ native: {
991
+ runtime: 'codex',
992
+ method: request.method,
993
+ requestId,
994
+ turnId: readString(params, 'turnId') ?? session.currentTurnId ?? undefined,
995
+ handles: {
996
+ itemId: readString(params, 'itemId') ?? '',
997
+ threadId: readString(params, 'threadId') ?? '',
998
+ },
999
+ },
1000
+ turnId: session.currentTurnId ?? undefined,
1001
+ });
1002
+ requestCreated = true;
1003
+ session.turnState = 'waiting_input';
1004
+ markTurnProgress(session);
1005
+ upsertTurnBlock(session, {
1006
+ id: `card:${cardId}`,
1007
+ kind: 'input',
1008
+ status: 'pending',
1009
+ title: card.title,
1010
+ summary: card.template ?? 'runtime card',
1011
+ });
1012
+ writeTurn(session);
1013
+ stopVisibleWorkSignal(session);
1014
+ writeCodexStreaming(session, null, 'waiting_input');
1015
+ const response = await waitForRuntimeCardResponse({
1016
+ conversationId: session.conversationId,
1017
+ cardId,
1018
+ expiresAt,
1019
+ });
1020
+ requestResolved = true;
1021
+ const outcome = buildRuntimeCardOutcome(cardId, response.status, { reason: response.status });
1022
+ await sendMessageWithRetry(client, session.conversationId, outcome.text, {
1023
+ messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, response.status),
1024
+ metadata: {
1025
+ ...outcome.metadata,
1026
+ turnId: session.currentTurnId ?? undefined,
1027
+ turnSemantics: 'control',
1028
+ replyBehavior: 'suppress_auto_reply',
1029
+ },
1030
+ });
1031
+ completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
1032
+ if (session.turnState === 'waiting_input') {
1033
+ session.turnState = 'thinking';
1034
+ markTurnProgress(session);
1035
+ writeTurn(session);
1036
+ startVisibleWorkSignal(session);
1037
+ writeCodexStreaming(session, null, 'thinking');
1038
+ }
1039
+ return response;
1040
+ }
1041
+ catch (error) {
1042
+ if (requestCreated && !requestResolved) {
1043
+ await client.consumeRuntimeCardResponse({
1044
+ conversationId: session.conversationId,
1045
+ cardId,
1046
+ cancel: true,
1047
+ }).catch(() => null);
1048
+ const outcome = buildRuntimeCardOutcome(cardId, 'cancelled', { reason: 'interrupted' });
1049
+ await sendMessageWithRetry(client, session.conversationId, outcome.text, {
1050
+ messageId: buildCodexRuntimeCardOutcomeMessageId(cardId, 'cancelled'),
1051
+ metadata: {
1052
+ ...outcome.metadata,
1053
+ turnId: session.currentTurnId ?? undefined,
1054
+ turnSemantics: 'control',
1055
+ replyBehavior: 'suppress_auto_reply',
1056
+ },
1057
+ }).catch(() => null);
1058
+ }
1059
+ throw error;
1060
+ }
1061
+ }
900
1062
  if (request.method === 'item/tool/requestUserInput') {
901
1063
  const paramsInput = isRecord(params.input) ? params.input : null;
902
1064
  const paramsArguments = isRecord(params.arguments) ? params.arguments : null;
@@ -1051,7 +1213,8 @@ export async function main() {
1051
1213
  const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
1052
1214
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to create session: ${message}`);
1053
1215
  await markQueuedMessageAccepted(input.conversationId, input.message.id, shouldMarkAccepted);
1054
- await client.sendMessage(input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
1216
+ await sendMessageWithRetry(client, input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
1217
+ messageId: `codex-start-failed-${input.message.id}`,
1055
1218
  ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
1056
1219
  metadata: {
1057
1220
  turnSemantics: 'turn_complete',
@@ -1074,7 +1237,7 @@ export async function main() {
1074
1237
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
1075
1238
  await session.adapter.interrupt().catch(() => { });
1076
1239
  clearStreaming(input.conversationId);
1077
- client.setTyping(input.conversationId, false).catch(() => { });
1240
+ typingSignals.clear(input.conversationId).catch(() => { });
1078
1241
  return;
1079
1242
  }
1080
1243
  enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode);
@@ -1123,7 +1286,6 @@ export async function main() {
1123
1286
  markTurnProgress(session);
1124
1287
  writeTurn(session);
1125
1288
  stopVisibleWorkSignal(session);
1126
- client.setTyping(session.conversationId, false).catch(() => { });
1127
1289
  writeCodexStreaming(session, event.text, 'streaming');
1128
1290
  return;
1129
1291
  }
@@ -1132,7 +1294,6 @@ export async function main() {
1132
1294
  markTurnProgress(session);
1133
1295
  writeTurn(session);
1134
1296
  stopVisibleWorkSignal(session);
1135
- client.setTyping(session.conversationId, false).catch(() => { });
1136
1297
  upsertTurnBlock(session, {
1137
1298
  id: `plan:${session.currentTurnId}`,
1138
1299
  kind: 'plan',
@@ -1211,7 +1372,8 @@ export async function main() {
1211
1372
  title: 'Codex Plan',
1212
1373
  body: result.finalMessage,
1213
1374
  });
1214
- await client.sendMessage(session.conversationId, planApproval.text, {
1375
+ await sendMessageWithRetry(client, session.conversationId, planApproval.text, {
1376
+ messageId: buildCodexMessageId(session, 'plan'),
1215
1377
  metadata: {
1216
1378
  ...planApproval.metadata,
1217
1379
  turnId: session.currentTurnId,
@@ -1227,7 +1389,8 @@ export async function main() {
1227
1389
  clearStoredThread();
1228
1390
  }
1229
1391
  const turnTrail = buildFinalTurnTrail(session);
1230
- await client.sendMessage(session.conversationId, result.finalMessage, {
1392
+ await sendMessageWithRetry(client, session.conversationId, result.finalMessage, {
1393
+ messageId: buildCodexMessageId(session, 'final'),
1231
1394
  ...(session.activeSelfContextId
1232
1395
  ? { selfContextId: session.activeSelfContextId }
1233
1396
  : {}),
@@ -1249,7 +1412,8 @@ export async function main() {
1249
1412
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
1250
1413
  }
1251
1414
  const turnTrail = buildFinalTurnTrail(session);
1252
- await client.sendMessage(session.conversationId, userVisibleError, {
1415
+ await sendMessageWithRetry(client, session.conversationId, userVisibleError, {
1416
+ messageId: buildCodexMessageId(session, 'error'),
1253
1417
  ...(session.activeSelfContextId
1254
1418
  ? { selfContextId: session.activeSelfContextId }
1255
1419
  : {}),
@@ -1270,7 +1434,7 @@ export async function main() {
1270
1434
  writeTurn(session);
1271
1435
  stopVisibleWorkSignal(session);
1272
1436
  clearStreaming(session.conversationId);
1273
- client.setTyping(session.conversationId, false).catch(() => { });
1437
+ typingSignals.clear(session.conversationId).catch(() => { });
1274
1438
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn interrupted`);
1275
1439
  }
1276
1440
  }
@@ -1280,7 +1444,8 @@ export async function main() {
1280
1444
  : `The Codex host failed to start a turn: ${error instanceof Error ? error.message : String(error)}`;
1281
1445
  session.state.lastError = message;
1282
1446
  writeState(session);
1283
- await client.sendMessage(session.conversationId, message, {
1447
+ await sendMessageWithRetry(client, session.conversationId, message, {
1448
+ messageId: buildCodexMessageId(session, 'failure'),
1284
1449
  ...(session.activeSelfContextId
1285
1450
  ? { selfContextId: session.activeSelfContextId }
1286
1451
  : {}),
@@ -1347,6 +1512,7 @@ export async function main() {
1347
1512
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
1348
1513
  presentation: runtimePresentation,
1349
1514
  supportsPlanMode: useAppServer,
1515
+ supportsRichCards: useAppServer,
1350
1516
  }),
1351
1517
  };
1352
1518
  async function baselineControlSignal(conversationId) {
@@ -1542,6 +1708,7 @@ export async function main() {
1542
1708
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
1543
1709
  presentation: runtimePresentation,
1544
1710
  supportsPlanMode: useAppServer,
1711
+ supportsRichCards: useAppServer,
1545
1712
  }),
1546
1713
  };
1547
1714
  }
@@ -1563,6 +1730,7 @@ export async function main() {
1563
1730
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
1564
1731
  presentation: runtimePresentation,
1565
1732
  supportsPlanMode: useAppServer,
1733
+ supportsRichCards: useAppServer,
1566
1734
  }),
1567
1735
  };
1568
1736
  }
@@ -1628,6 +1796,8 @@ export async function main() {
1628
1796
  });
1629
1797
  const pollControl = async () => {
1630
1798
  while (!controlStopped) {
1799
+ const hadActiveWork = [...sessions.values()].some((session) => !session.closed
1800
+ && (session.running || session.queue.length > 0 || session.turnState === 'waiting_input'));
1631
1801
  for (const conversationId of [...sessions.keys()]) {
1632
1802
  try {
1633
1803
  const controlRaw = await rtdbRead(`/control/${conversationId}/${agentId}/session`);
@@ -1696,14 +1866,14 @@ export async function main() {
1696
1866
  session.turnState = 'interrupted';
1697
1867
  writeTurn(session);
1698
1868
  clearStreaming(conversationId);
1699
- client.setTyping(conversationId, false).catch(() => { });
1869
+ typingSignals.clear(conversationId).catch(() => { });
1700
1870
  await rtdbWrite(`/control/${conversationId}/${agentId}/signal`, null).catch(() => { });
1701
1871
  }
1702
1872
  catch {
1703
1873
  // Ignore transient RTDB failures.
1704
1874
  }
1705
1875
  }
1706
- await new Promise((resolve) => setTimeout(resolve, CONTROL_POLL_MS));
1876
+ await new Promise((resolve) => setTimeout(resolve, controlPollDelayMs(hadActiveWork)));
1707
1877
  }
1708
1878
  };
1709
1879
  void pollControl();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
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": "^2.0.0",
33
- "@canonmsg/core": "^1.0.0"
32
+ "@canonmsg/agent-sdk": "^2.1.1",
33
+ "@canonmsg/core": "^1.3.0"
34
34
  },
35
35
  "engines": {
36
36
  "node": ">=18.0.0"