@canonmsg/codex-plugin 0.11.12 → 0.13.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/dist/host.js CHANGED
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { setDefaultResultOrder } from 'node:dns';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { spawnSync } from 'node:child_process';
4
5
  import { dirname } from 'node:path';
5
6
  import { parseArgs } from 'node:util';
6
7
  import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, } from '@canonmsg/agent-sdk';
7
- import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, 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, normalizeTurnState, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, saveRuntimeSessionState, 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, 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, shouldTriggerAgentTurn, saveRuntimeSessionState, buildBoundedTurnTrail, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
8
9
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
9
10
  import { CodexConversationAdapter, } from './adapter.js';
11
+ import { CodexAppServerAdapter } from './app-server-adapter.js';
12
+ import { mapCanonApprovalResultToCodexDecision, mapCodexAppServerApprovalRequest, } from './app-server-approval.js';
10
13
  import { clearStoredThreadId, buildCodexThreadPolicyFingerprint, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
11
14
  import { deriveCodexPermissionEnvelope, mapCanonPermissionToCodex, } from './permission-mode.js';
12
15
  import { detectCodexCliVersion } from './codex-cli-version.js';
@@ -64,6 +67,19 @@ let workspaceRoots = [];
64
67
  let workspaceRootMetadata = [];
65
68
  function buildCodexRuntimeDescriptor(input) {
66
69
  const commands = [
70
+ ...(input.supportsPlanMode
71
+ ? [{
72
+ id: 'plan',
73
+ label: 'Plan first',
74
+ description: 'Ask Codex to plan before implementing. Text after /plan becomes the planning prompt.',
75
+ aliases: ['plan'],
76
+ category: 'plan',
77
+ placements: ['composer_slash', 'command_palette'],
78
+ availability: ['always'],
79
+ trailingTextBehavior: 'send_as_prompt',
80
+ dispatch: { kind: 'text_passthrough', template: '/plan {argument}' },
81
+ }]
82
+ : []),
67
83
  {
68
84
  id: 'runtime-status',
69
85
  label: 'Runtime status',
@@ -79,6 +95,8 @@ function buildCodexRuntimeDescriptor(input) {
79
95
  ...RUNTIME_NEW_SESSION_ACTION,
80
96
  primitive: 'session.new',
81
97
  },
98
+ RUNTIME_STOP_ACTION,
99
+ RUNTIME_STOP_AND_DROP_ACTION,
82
100
  ];
83
101
  const descriptor = buildFirstPartyCodingRuntimeDescriptor({
84
102
  clientType: 'codex',
@@ -91,11 +109,21 @@ function buildCodexRuntimeDescriptor(input) {
91
109
  presentation: input.presentation,
92
110
  streamingTextMode: 'snapshot',
93
111
  commands,
94
- actions: [
95
- RUNTIME_STOP_ACTION,
96
- RUNTIME_STOP_AND_DROP_ACTION,
97
- RUNTIME_NEW_SESSION_ACTION,
98
- ],
112
+ ...(input.supportsRichCards
113
+ ? {
114
+ runtimeCards: {
115
+ rich: {
116
+ schema: 'canon.card.v1',
117
+ lifecycle: 'blocking_requires_action',
118
+ responder: 'agent_owner',
119
+ result: 'action_or_values',
120
+ maxTimeoutMs: 30 * 60_000,
121
+ blockKinds: ['summary', 'metricGrid', 'chart', 'table', 'list', 'callout', 'actions'],
122
+ native: true,
123
+ },
124
+ },
125
+ }
126
+ : {}),
99
127
  });
100
128
  if (input.models.length > 0) {
101
129
  return descriptor;
@@ -119,18 +147,6 @@ function buildCodexModelOptions(model) {
119
147
  ? [{ value: model.trim(), label: modelOptionLabel(model.trim()) }]
120
148
  : [];
121
149
  }
122
- function normalizeRuntimeTurnState(value) {
123
- const normalizedTurn = normalizeTurnState(value);
124
- if (normalizedTurn) {
125
- return {
126
- state: normalizedTurn.state,
127
- ...(normalizedTurn.openedAt !== undefined ? { openedAt: normalizedTurn.openedAt } : {}),
128
- ...(normalizedTurn.updatedAt !== undefined ? { updatedAt: normalizedTurn.updatedAt } : {}),
129
- ...(normalizedTurn.turnUpdatedAt !== undefined ? { turnUpdatedAt: normalizedTurn.turnUpdatedAt } : {}),
130
- };
131
- }
132
- return null;
133
- }
134
150
  async function publishAgentRuntime(agentId, runtime) {
135
151
  await publishHostAgentRuntime(agentId, 'codex', runtime);
136
152
  }
@@ -249,6 +265,78 @@ function stringArgs(value) {
249
265
  ? value.filter((item) => typeof item === 'string' && item.trim().length > 0)
250
266
  : undefined;
251
267
  }
268
+ function supportsCodexAppServer(codexBin) {
269
+ if (process.env.CANON_CODEX_TRANSPORT === 'exec')
270
+ return false;
271
+ if (process.env.CANON_CODEX_TRANSPORT === 'app-server')
272
+ return true;
273
+ const result = spawnSync(codexBin, ['app-server', '--help'], {
274
+ encoding: 'utf8',
275
+ stdio: ['ignore', 'ignore', 'ignore'],
276
+ });
277
+ return result.status === 0;
278
+ }
279
+ function parsePlanCommand(content) {
280
+ const trimmed = content.trimStart();
281
+ if (!trimmed.startsWith('/plan'))
282
+ return { planMode: false, content };
283
+ const rest = trimmed.replace(/^\/plan(?:\s+)?/i, '').trim();
284
+ return {
285
+ planMode: true,
286
+ content: rest || 'Please inspect the request and propose a plan before making changes.',
287
+ };
288
+ }
289
+ function mapCodexQuestions(value) {
290
+ if (!Array.isArray(value))
291
+ return undefined;
292
+ const questions = value.slice(0, 12).flatMap((entry) => {
293
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
294
+ return [];
295
+ const record = entry;
296
+ const id = typeof record.id === 'string' && record.id.trim() ? record.id.trim().slice(0, 120) : null;
297
+ const question = typeof record.question === 'string' && record.question.trim()
298
+ ? record.question.trim().slice(0, 1000)
299
+ : null;
300
+ if (!id || !/^[A-Za-z0-9_.:-]{1,120}$/.test(id) || !question)
301
+ return [];
302
+ const header = typeof record.header === 'string' && record.header.trim()
303
+ ? record.header.trim().slice(0, 120)
304
+ : undefined;
305
+ const rawOptions = Array.isArray(record.options) ? record.options : [];
306
+ const choices = rawOptions.slice(0, 12).flatMap((option) => {
307
+ if (!option || typeof option !== 'object' || Array.isArray(option))
308
+ return [];
309
+ const optionRecord = option;
310
+ const label = typeof optionRecord.label === 'string' && optionRecord.label.trim()
311
+ ? optionRecord.label.trim().slice(0, 120)
312
+ : null;
313
+ if (!label)
314
+ return [];
315
+ const description = typeof optionRecord.description === 'string' && optionRecord.description.trim()
316
+ ? optionRecord.description.trim().slice(0, 300)
317
+ : undefined;
318
+ return [{ label, value: label, ...(description ? { description } : {}) }];
319
+ });
320
+ return [{
321
+ id,
322
+ question,
323
+ ...(header ? { header } : {}),
324
+ ...(choices.length > 0 ? { choices } : {}),
325
+ ...(choices.length > 0 && record.allowOther !== false ? { allowOther: true } : {}),
326
+ ...(record.allowOther === true || record.isOther === true ? { allowOther: true } : {}),
327
+ ...(record.isSecret === true ? { isSecret: true } : {}),
328
+ ...(record.multiSelect === true ? { multiSelect: true } : {}),
329
+ }];
330
+ });
331
+ return questions.length > 0 ? questions : undefined;
332
+ }
333
+ function isRecord(value) {
334
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
335
+ }
336
+ function readString(record, key) {
337
+ const value = record[key];
338
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
339
+ }
252
340
  export async function main() {
253
341
  setDefaultResultOrder('ipv4first');
254
342
  const { values: args } = parseArgs({
@@ -290,12 +378,14 @@ export async function main() {
290
378
  }
291
379
  const codexBin = typeof args['codex-bin'] === 'string' ? args['codex-bin'] : 'codex';
292
380
  const codexCliStatus = detectCodexCliVersion(codexBin);
381
+ const useAppServer = supportsCodexAppServer(codexBin);
293
382
  if (codexCliStatus.version) {
294
383
  console.error(`[canon-codex] Detected Codex CLI ${codexCliStatus.version} (${codexBin})`);
295
384
  }
296
385
  else {
297
386
  console.error(`[canon-codex] Could not detect Codex CLI version for ${codexBin}: ${codexCliStatus.error ?? 'unknown result'}`);
298
387
  }
388
+ console.error(`[canon-codex] Codex transport: ${useAppServer ? 'app-server' : 'exec --json'}`);
299
389
  const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-codex]', expectedClientType: 'codex' });
300
390
  console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
301
391
  const client = new CanonClient(apiKey, baseUrl);
@@ -418,14 +508,6 @@ export async function main() {
418
508
  pendingMembershipChanges.delete(conversationId);
419
509
  }
420
510
  }
421
- async function loadSenderRuntimeState(conversationId, senderId) {
422
- try {
423
- return normalizeRuntimeTurnState(await rtdbRead(`/turn-state/${conversationId}/${senderId}`));
424
- }
425
- catch {
426
- return null;
427
- }
428
- }
429
511
  async function loadHydratedInboundContext(input) {
430
512
  const [conversation, page] = await Promise.all([
431
513
  getConversationMeta(input.conversationId),
@@ -523,6 +605,61 @@ export async function main() {
523
605
  function clearStreaming(conversationId) {
524
606
  runtimeState.clearStreaming(conversationId).catch(() => { });
525
607
  }
608
+ function writeCodexStreaming(session, text, status) {
609
+ if (text !== null) {
610
+ session.turnLiveText = text;
611
+ }
612
+ else if (status !== 'thinking' && session.turnLiveText === 'Thinking…') {
613
+ session.turnLiveText = '';
614
+ }
615
+ runtimeState.writeStreaming(session.conversationId, {
616
+ text: session.turnLiveText,
617
+ status,
618
+ messageId: session.currentTurnId ?? undefined,
619
+ turnId: session.currentTurnId,
620
+ blocks: session.turnBlocks,
621
+ }).catch(() => { });
622
+ }
623
+ function upsertTurnBlock(session, block) {
624
+ const now = Date.now();
625
+ const index = session.turnBlocks.findIndex((existing) => existing.id === block.id);
626
+ const existing = index >= 0 ? session.turnBlocks[index] : null;
627
+ const next = {
628
+ ...(existing ?? {
629
+ sequence: session.turnBlocks.length + 1,
630
+ createdAt: now,
631
+ }),
632
+ ...block,
633
+ turnId: session.currentTurnId ?? block.id,
634
+ updatedAt: now,
635
+ };
636
+ session.turnBlocks = index >= 0
637
+ ? [
638
+ ...session.turnBlocks.slice(0, index),
639
+ next,
640
+ ...session.turnBlocks.slice(index + 1),
641
+ ]
642
+ : [...session.turnBlocks, next];
643
+ }
644
+ function completeTurnBlock(session, id, summary) {
645
+ const existing = session.turnBlocks.find((block) => block.id === id);
646
+ if (!existing)
647
+ return;
648
+ upsertTurnBlock(session, {
649
+ id,
650
+ kind: existing.kind,
651
+ status: 'completed',
652
+ title: existing.title,
653
+ text: existing.text,
654
+ summary: summary ?? existing.summary,
655
+ });
656
+ }
657
+ function buildFinalTurnTrail(session) {
658
+ return buildBoundedTurnTrail(session.turnBlocks.map((block) => ({
659
+ ...block,
660
+ turnId: session.currentTurnId ?? block.turnId,
661
+ })));
662
+ }
526
663
  async function handoffFinalMessage(conversationId) {
527
664
  await sleep(FINAL_MESSAGE_HANDOFF_MS);
528
665
  clearStreaming(conversationId);
@@ -555,6 +692,9 @@ export async function main() {
555
692
  return;
556
693
  session.closed = true;
557
694
  stopVisibleWorkSignal(session);
695
+ if ('close' in session.adapter && typeof session.adapter.close === 'function') {
696
+ session.adapter.close();
697
+ }
558
698
  releaseConversationEnvironment(session.environment);
559
699
  clearStreaming(conversationId);
560
700
  runtimeState.clearSessionState(conversationId).catch(() => { });
@@ -638,11 +778,20 @@ export async function main() {
638
778
  throw new ExecutionEnvironmentError(modelGuard, modelGuard);
639
779
  }
640
780
  const storedThreadId = loadStoredThreadId(runtimeId, agentId, conversationId, environment.baseCwd, environment.mode, policy.fingerprint);
641
- const session = {
642
- conversationId,
643
- cwd: sessionCwd,
644
- environment,
645
- adapter: new CodexConversationAdapter({
781
+ const adapter = useAppServer
782
+ ? new CodexAppServerAdapter({
783
+ cwd: sessionCwd,
784
+ threadId: storedThreadId,
785
+ codexBin,
786
+ model: policy.model ?? null,
787
+ sandbox: policy.sandbox,
788
+ approvalPolicy: policy.approvalPolicy,
789
+ addDirs: args['add-dir'] ?? [],
790
+ configOverrides: args.config ?? [],
791
+ fullAuto: policy.fullAuto,
792
+ bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
793
+ })
794
+ : new CodexConversationAdapter({
646
795
  cwd: sessionCwd,
647
796
  threadId: storedThreadId,
648
797
  codexBin,
@@ -654,7 +803,12 @@ export async function main() {
654
803
  configOverrides: args.config ?? [],
655
804
  fullAuto: policy.fullAuto,
656
805
  bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
657
- }),
806
+ });
807
+ const session = {
808
+ conversationId,
809
+ cwd: sessionCwd,
810
+ environment,
811
+ adapter,
658
812
  queue: [],
659
813
  running: false,
660
814
  state: {
@@ -673,6 +827,8 @@ export async function main() {
673
827
  lastActivity: Date.now(),
674
828
  typingKeepaliveTimer: null,
675
829
  closed: false,
830
+ turnLiveText: '',
831
+ turnBlocks: [],
676
832
  };
677
833
  sessions.set(conversationId, session);
678
834
  await Promise.all([
@@ -697,8 +853,8 @@ export async function main() {
697
853
  pendingSessionCreations.delete(conversationId);
698
854
  }
699
855
  }
700
- function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = []) {
701
- const nextPrompt = { prompt, intent, sourceMessageId, markAccepted, imagePaths, mediaAddDirs };
856
+ function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false, imagePaths = [], mediaAddDirs = [], planMode = false) {
857
+ const nextPrompt = { prompt, intent, sourceMessageId, markAccepted, imagePaths, mediaAddDirs, planMode };
702
858
  if (toFront) {
703
859
  session.queue.unshift(nextPrompt);
704
860
  }
@@ -709,8 +865,276 @@ export async function main() {
709
865
  writeTurn(session);
710
866
  void runNextTurn(session);
711
867
  }
868
+ async function waitForRuntimeInputResponse(input) {
869
+ while (Date.now() < input.expiresAt) {
870
+ const response = await client.consumeRuntimeInputResponse({
871
+ conversationId: input.conversationId,
872
+ inputId: input.inputId,
873
+ }).catch(() => null);
874
+ if (response?.status === 'submitted') {
875
+ return { status: 'submitted', value: response.value, answers: response.answers };
876
+ }
877
+ if (response?.status === 'cancelled' || response?.status === 'timeout') {
878
+ return { status: response.status };
879
+ }
880
+ await sleep(1_000);
881
+ }
882
+ const response = await client.consumeRuntimeInputResponse({
883
+ conversationId: input.conversationId,
884
+ inputId: input.inputId,
885
+ }).catch(() => null);
886
+ if (response?.status === 'submitted') {
887
+ return { status: 'submitted', value: response.value, answers: response.answers };
888
+ }
889
+ return { status: 'timeout' };
890
+ }
891
+ async function waitForRuntimeApprovalResponse(input) {
892
+ while (Date.now() < input.expiresAt) {
893
+ const response = await client.consumeRuntimeApprovalResponse({
894
+ conversationId: input.conversationId,
895
+ approvalId: input.approvalId,
896
+ }).catch(() => null);
897
+ if (response?.status === 'allow') {
898
+ return { decision: 'allow', sessionRule: response.sessionRule };
899
+ }
900
+ if (response?.status === 'deny' || response?.status === 'timeout') {
901
+ return { decision: 'deny' };
902
+ }
903
+ await sleep(1_000);
904
+ }
905
+ await client.consumeRuntimeApprovalResponse({
906
+ conversationId: input.conversationId,
907
+ approvalId: input.approvalId,
908
+ }).catch(() => null);
909
+ return { decision: 'deny' };
910
+ }
911
+ async function waitForRuntimeCardResponse(input) {
912
+ while (Date.now() < input.expiresAt) {
913
+ const response = await client.consumeRuntimeCardResponse({
914
+ conversationId: input.conversationId,
915
+ cardId: input.cardId,
916
+ }).catch(() => null);
917
+ if (response?.status === 'submitted') {
918
+ return {
919
+ status: 'submitted',
920
+ ...(response.actionId ? { actionId: response.actionId } : {}),
921
+ ...(response.values ? { values: response.values } : {}),
922
+ };
923
+ }
924
+ if (response?.status === 'cancelled' || response?.status === 'timeout') {
925
+ return { status: response.status };
926
+ }
927
+ await sleep(1_000);
928
+ }
929
+ const response = await client.consumeRuntimeCardResponse({
930
+ conversationId: input.conversationId,
931
+ cardId: input.cardId,
932
+ }).catch(() => null);
933
+ if (response?.status === 'submitted') {
934
+ return {
935
+ status: 'submitted',
936
+ ...(response.actionId ? { actionId: response.actionId } : {}),
937
+ ...(response.values ? { values: response.values } : {}),
938
+ };
939
+ }
940
+ return { status: 'timeout' };
941
+ }
942
+ function runtimeCardRequestPayload(method, params) {
943
+ if (method !== 'item/runtimeCard/request'
944
+ && method !== 'runtimeCard/request') {
945
+ return null;
946
+ }
947
+ const input = isRecord(params.input) ? params.input : null;
948
+ const args = isRecord(params.arguments) ? params.arguments : null;
949
+ return params.card ?? params.cardDocument ?? input?.card ?? args?.card ?? null;
950
+ }
951
+ async function handleCodexServerRequest(session, request) {
952
+ const requestId = String(request.id);
953
+ const params = request.params;
954
+ const expiresAt = Date.now() + 30 * 60_000;
955
+ const runtimeCardPayload = runtimeCardRequestPayload(request.method, params);
956
+ if (runtimeCardPayload) {
957
+ const card = parseRuntimeCardV1(runtimeCardPayload);
958
+ if (!card) {
959
+ return { status: 'cancelled', error: 'Invalid canon.card.v1 card' };
960
+ }
961
+ const cardId = readString(params, 'cardId')
962
+ ?? readString(params, 'itemId')
963
+ ?? card.cardId
964
+ ?? requestId;
965
+ let requestCreated = false;
966
+ let requestResolved = false;
967
+ try {
968
+ await client.createRuntimeCardRequest({
969
+ conversationId: session.conversationId,
970
+ cardId,
971
+ card: { ...card, cardId },
972
+ expiresAt,
973
+ // Omit responseUserId so the backend targets a reachable member (the
974
+ // owner if present, else the sole other member) — the owner is often
975
+ // not a member of agent-to-user DMs.
976
+ native: {
977
+ runtime: 'codex',
978
+ method: request.method,
979
+ requestId,
980
+ turnId: readString(params, 'turnId') ?? session.currentTurnId ?? undefined,
981
+ handles: {
982
+ itemId: readString(params, 'itemId') ?? '',
983
+ threadId: readString(params, 'threadId') ?? '',
984
+ },
985
+ },
986
+ turnId: session.currentTurnId ?? undefined,
987
+ });
988
+ requestCreated = true;
989
+ session.turnState = 'waiting_input';
990
+ markTurnProgress(session);
991
+ upsertTurnBlock(session, {
992
+ id: `card:${cardId}`,
993
+ kind: 'input',
994
+ status: 'pending',
995
+ title: card.title,
996
+ summary: card.template ?? 'runtime card',
997
+ });
998
+ writeTurn(session);
999
+ stopVisibleWorkSignal(session);
1000
+ writeCodexStreaming(session, null, 'waiting_input');
1001
+ const response = await waitForRuntimeCardResponse({
1002
+ conversationId: session.conversationId,
1003
+ cardId,
1004
+ expiresAt,
1005
+ });
1006
+ requestResolved = true;
1007
+ const outcome = buildRuntimeCardOutcome(cardId, response.status, { reason: response.status });
1008
+ await client.sendMessage(session.conversationId, outcome.text, {
1009
+ metadata: {
1010
+ ...outcome.metadata,
1011
+ turnId: session.currentTurnId ?? undefined,
1012
+ turnSemantics: 'control',
1013
+ replyBehavior: 'suppress_auto_reply',
1014
+ },
1015
+ });
1016
+ completeTurnBlock(session, `card:${cardId}`, `Card ${response.status}`);
1017
+ if (session.turnState === 'waiting_input') {
1018
+ session.turnState = 'thinking';
1019
+ markTurnProgress(session);
1020
+ writeTurn(session);
1021
+ startVisibleWorkSignal(session);
1022
+ writeCodexStreaming(session, null, 'thinking');
1023
+ }
1024
+ return response;
1025
+ }
1026
+ catch (error) {
1027
+ if (requestCreated && !requestResolved) {
1028
+ await client.consumeRuntimeCardResponse({
1029
+ conversationId: session.conversationId,
1030
+ cardId,
1031
+ cancel: true,
1032
+ }).catch(() => null);
1033
+ const outcome = buildRuntimeCardOutcome(cardId, 'cancelled', { reason: 'interrupted' });
1034
+ await client.sendMessage(session.conversationId, outcome.text, {
1035
+ metadata: {
1036
+ ...outcome.metadata,
1037
+ turnId: session.currentTurnId ?? undefined,
1038
+ turnSemantics: 'control',
1039
+ replyBehavior: 'suppress_auto_reply',
1040
+ },
1041
+ }).catch(() => null);
1042
+ }
1043
+ throw error;
1044
+ }
1045
+ }
1046
+ if (request.method === 'item/tool/requestUserInput') {
1047
+ const paramsInput = isRecord(params.input) ? params.input : null;
1048
+ const paramsArguments = isRecord(params.arguments) ? params.arguments : null;
1049
+ const questions = mapCodexQuestions(params.questions ?? paramsInput?.questions ?? paramsArguments?.questions);
1050
+ const inputId = readString(params, 'itemId') ?? requestId;
1051
+ await client.createRuntimeInputRequest({
1052
+ conversationId: session.conversationId,
1053
+ inputId,
1054
+ kind: 'clarify',
1055
+ expiresAt,
1056
+ responseUserId: ownerId ?? undefined,
1057
+ title: 'Codex needs input',
1058
+ prompt: questions?.length
1059
+ ? 'Codex needs your input to continue.'
1060
+ : 'Codex needs input.',
1061
+ ...(questions ? { questions } : {}),
1062
+ sensitive: Boolean(questions?.some((question) => question.isSecret)),
1063
+ native: {
1064
+ runtime: 'codex',
1065
+ method: request.method,
1066
+ requestId,
1067
+ turnId: readString(params, 'turnId') ?? session.currentTurnId ?? undefined,
1068
+ handles: {
1069
+ itemId: readString(params, 'itemId') ?? '',
1070
+ threadId: readString(params, 'threadId') ?? '',
1071
+ },
1072
+ },
1073
+ turnId: session.currentTurnId ?? undefined,
1074
+ });
1075
+ const response = await waitForRuntimeInputResponse({
1076
+ conversationId: session.conversationId,
1077
+ inputId,
1078
+ expiresAt,
1079
+ });
1080
+ return { answers: response.status === 'submitted' ? response.answers ?? {} : {} };
1081
+ }
1082
+ const mappedApproval = mapCodexAppServerApprovalRequest({
1083
+ method: request.method,
1084
+ params,
1085
+ });
1086
+ if (mappedApproval) {
1087
+ const approvalId = readString(params, 'approvalId') ?? readString(params, 'itemId') ?? requestId;
1088
+ await client.createRuntimeApprovalRequest({
1089
+ conversationId: session.conversationId,
1090
+ approvalId,
1091
+ toolName: mappedApproval.toolName,
1092
+ toolSummary: mappedApproval.toolSummary,
1093
+ category: mappedApproval.category,
1094
+ risk: mappedApproval.risk,
1095
+ riskLevel: mappedApproval.riskLevel,
1096
+ native: {
1097
+ ...mappedApproval.native,
1098
+ requestId,
1099
+ method: request.method,
1100
+ },
1101
+ details: mappedApproval.details,
1102
+ responseUserId: ownerId ?? undefined,
1103
+ allowSessionRule: true,
1104
+ expiresAt,
1105
+ turnId: session.currentTurnId ?? undefined,
1106
+ });
1107
+ const response = await waitForRuntimeApprovalResponse({
1108
+ conversationId: session.conversationId,
1109
+ approvalId,
1110
+ expiresAt,
1111
+ });
1112
+ if (request.method === 'item/permissions/requestApproval') {
1113
+ return response.decision === 'allow'
1114
+ ? { permissions: isRecord(params.permissions) ? params.permissions : {}, scope: response.sessionRule ? 'session' : 'turn' }
1115
+ : { permissions: {}, scope: 'turn' };
1116
+ }
1117
+ return mapCanonApprovalResultToCodexDecision({
1118
+ decision: response.decision,
1119
+ ...(response.sessionRule ? { sessionRule: response.sessionRule } : {}),
1120
+ });
1121
+ }
1122
+ return {};
1123
+ }
712
1124
  async function enqueueInboundMessage(input) {
713
1125
  knownConversationIds.add(input.conversationId);
1126
+ if (isRecord(input.message.metadata)
1127
+ && input.message.metadata.type === 'plan_approval_reply'
1128
+ && typeof input.message.metadata.decision === 'string') {
1129
+ const session = await getOrCreateSession(input.conversationId);
1130
+ const feedback = readString(input.message.metadata, 'feedback');
1131
+ const decision = input.message.metadata.decision;
1132
+ const prompt = decision === 'approve'
1133
+ ? 'The plan was approved. Implement the approved plan now.'
1134
+ : `Please revise the plan.${feedback ? `\n\nRevision feedback:\n${feedback}` : ''}`;
1135
+ enqueuePrompt(session, prompt, 'queue', false, input.message.id, false, [], [], decision !== 'approve');
1136
+ return;
1137
+ }
714
1138
  let materialized = [];
715
1139
  if (input.message.id) {
716
1140
  try {
@@ -723,7 +1147,11 @@ export async function main() {
723
1147
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
724
1148
  }
725
1149
  }
726
- const content = renderInboundContent(input.message, materialized);
1150
+ const renderedContent = renderInboundContent(input.message, materialized);
1151
+ const planCommand = useAppServer
1152
+ ? parsePlanCommand(renderedContent)
1153
+ : { planMode: false, content: renderedContent };
1154
+ const content = planCommand.content;
727
1155
  const hydrated = await loadHydratedInboundContext({
728
1156
  conversationId: input.conversationId,
729
1157
  message: input.message,
@@ -773,7 +1201,6 @@ export async function main() {
773
1201
  ...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
774
1202
  metadata: {
775
1203
  turnSemantics: 'turn_complete',
776
- turnComplete: true,
777
1204
  replyBehavior: 'suppress_auto_reply',
778
1205
  },
779
1206
  }).catch(() => { });
@@ -789,14 +1216,14 @@ export async function main() {
789
1216
  replyContext,
790
1217
  });
791
1218
  if (session.running && deliveryIntent === 'interrupt') {
792
- enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs);
1219
+ enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode);
793
1220
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
794
1221
  await session.adapter.interrupt().catch(() => { });
795
1222
  clearStreaming(input.conversationId);
796
1223
  client.setTyping(input.conversationId, false).catch(() => { });
797
1224
  return;
798
1225
  }
799
- enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs);
1226
+ enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode);
800
1227
  }
801
1228
  async function runNextTurn(session) {
802
1229
  if (session.running || session.closed)
@@ -808,6 +1235,8 @@ export async function main() {
808
1235
  session.state.lastError = undefined;
809
1236
  session.state.state = 'running';
810
1237
  session.currentTurnId = randomUUID();
1238
+ session.turnLiveText = '';
1239
+ session.turnBlocks = [];
811
1240
  session.currentTurnOpenedAt = Date.now();
812
1241
  session.currentTurnUpdatedAt = session.currentTurnOpenedAt;
813
1242
  session.lastAcceptedIntent = nextTurn.intent;
@@ -817,12 +1246,7 @@ export async function main() {
817
1246
  writeState(session);
818
1247
  writeTurn(session);
819
1248
  startVisibleWorkSignal(session);
820
- runtimeState.writeStreaming(session.conversationId, {
821
- text: 'Thinking…',
822
- status: 'thinking',
823
- messageId: session.currentTurnId ?? undefined,
824
- turnId: session.currentTurnId,
825
- }).catch(() => { });
1249
+ writeCodexStreaming(session, 'Thinking…', 'thinking');
826
1250
  try {
827
1251
  const modelGuard = buildCodexModelGuardMessage(session.state.model, codexCliStatus);
828
1252
  if (modelGuard) {
@@ -846,12 +1270,31 @@ export async function main() {
846
1270
  writeTurn(session);
847
1271
  stopVisibleWorkSignal(session);
848
1272
  client.setTyping(session.conversationId, false).catch(() => { });
849
- runtimeState.writeStreaming(session.conversationId, {
1273
+ writeCodexStreaming(session, event.text, 'streaming');
1274
+ return;
1275
+ }
1276
+ if (event.type === 'plan.updated') {
1277
+ session.turnState = 'streaming';
1278
+ markTurnProgress(session);
1279
+ writeTurn(session);
1280
+ stopVisibleWorkSignal(session);
1281
+ client.setTyping(session.conversationId, false).catch(() => { });
1282
+ upsertTurnBlock(session, {
1283
+ id: `plan:${session.currentTurnId}`,
1284
+ kind: 'plan',
1285
+ status: 'running',
1286
+ title: 'Plan',
850
1287
  text: event.text,
851
- status: 'streaming',
852
- messageId: session.currentTurnId ?? undefined,
853
- turnId: session.currentTurnId,
854
- }).catch(() => { });
1288
+ });
1289
+ writeCodexStreaming(session, event.text, 'streaming');
1290
+ return;
1291
+ }
1292
+ if (event.type === 'waiting') {
1293
+ session.turnState = 'waiting_input';
1294
+ markTurnProgress(session);
1295
+ writeTurn(session);
1296
+ stopVisibleWorkSignal(session);
1297
+ writeCodexStreaming(session, null, 'waiting_input');
855
1298
  return;
856
1299
  }
857
1300
  if (event.type === 'command.started') {
@@ -859,20 +1302,24 @@ export async function main() {
859
1302
  markTurnProgress(session);
860
1303
  writeTurn(session);
861
1304
  startVisibleWorkSignal(session);
862
- runtimeState.writeStreaming(session.conversationId, {
863
- text: summarizeCommand(event.command),
864
- status: 'tool',
865
- messageId: session.currentTurnId ?? undefined,
866
- turnId: session.currentTurnId,
867
- }).catch(() => { });
1305
+ upsertTurnBlock(session, {
1306
+ id: `command:${session.currentTurnId}`,
1307
+ kind: 'tool',
1308
+ status: 'running',
1309
+ title: summarizeCommand(event.command),
1310
+ summary: 'Command running',
1311
+ });
1312
+ writeCodexStreaming(session, null, 'tool');
868
1313
  return;
869
1314
  }
870
1315
  if (event.type === 'command.completed') {
1316
+ completeTurnBlock(session, `command:${session.currentTurnId}`, 'Command completed');
871
1317
  if (session.turnState === 'tool') {
872
1318
  session.turnState = 'thinking';
873
1319
  markTurnProgress(session);
874
1320
  writeTurn(session);
875
1321
  startVisibleWorkSignal(session);
1322
+ writeCodexStreaming(session, null, 'thinking');
876
1323
  }
877
1324
  return;
878
1325
  }
@@ -887,7 +1334,10 @@ export async function main() {
887
1334
  clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
888
1335
  session.adapter.clearThreadId();
889
1336
  };
890
- const runTurnOnce = () => session.adapter.runTurn(nextTurn.prompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs);
1337
+ const runTurnOnce = () => session.adapter.runTurn(nextTurn.prompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
1338
+ planMode: nextTurn.planMode,
1339
+ onServerRequest: (request) => handleCodexServerRequest(session, request),
1340
+ });
891
1341
  let result = await runTurnOnce();
892
1342
  if (!result.interrupted
893
1343
  && !result.finalMessage
@@ -901,10 +1351,28 @@ export async function main() {
901
1351
  if (result.threadId && !session.resetRequested) {
902
1352
  saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
903
1353
  }
904
- if (!result.interrupted && result.finalMessage) {
1354
+ if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
1355
+ const planApproval = buildPlanApprovalRequest(session.currentTurnId ?? randomUUID(), 'Plan ready for review.', {
1356
+ responseUserId: ownerId ?? undefined,
1357
+ title: 'Codex Plan',
1358
+ body: result.finalMessage,
1359
+ });
1360
+ await client.sendMessage(session.conversationId, planApproval.text, {
1361
+ metadata: {
1362
+ ...planApproval.metadata,
1363
+ turnId: session.currentTurnId,
1364
+ turnSemantics: 'control',
1365
+ replyBehavior: 'suppress_auto_reply',
1366
+ },
1367
+ });
1368
+ await handoffFinalMessage(session.conversationId);
1369
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent plan approval card`);
1370
+ }
1371
+ else if (!result.interrupted && result.finalMessage) {
905
1372
  if (isRecoverableCodexThreadError(result.errorText)) {
906
1373
  clearStoredThread();
907
1374
  }
1375
+ const turnTrail = buildFinalTurnTrail(session);
908
1376
  await client.sendMessage(session.conversationId, result.finalMessage, {
909
1377
  ...(session.activeSelfContextId
910
1378
  ? { selfContextId: session.activeSelfContextId }
@@ -912,8 +1380,8 @@ export async function main() {
912
1380
  metadata: {
913
1381
  turnId: session.currentTurnId,
914
1382
  turnSemantics: 'turn_complete',
915
- turnComplete: true,
916
1383
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
1384
+ ...(turnTrail.length > 0 ? { turnTrail } : {}),
917
1385
  },
918
1386
  });
919
1387
  await handoffFinalMessage(session.conversationId);
@@ -926,6 +1394,7 @@ export async function main() {
926
1394
  if (result.errorText) {
927
1395
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
928
1396
  }
1397
+ const turnTrail = buildFinalTurnTrail(session);
929
1398
  await client.sendMessage(session.conversationId, userVisibleError, {
930
1399
  ...(session.activeSelfContextId
931
1400
  ? { selfContextId: session.activeSelfContextId }
@@ -933,8 +1402,8 @@ export async function main() {
933
1402
  metadata: {
934
1403
  turnId: session.currentTurnId,
935
1404
  turnSemantics: 'turn_complete',
936
- turnComplete: true,
937
1405
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
1406
+ ...(turnTrail.length > 0 ? { turnTrail } : {}),
938
1407
  },
939
1408
  });
940
1409
  await handoffFinalMessage(session.conversationId);
@@ -964,7 +1433,6 @@ export async function main() {
964
1433
  metadata: {
965
1434
  turnId: session.currentTurnId,
966
1435
  turnSemantics: 'turn_complete',
967
- turnComplete: true,
968
1436
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
969
1437
  },
970
1438
  }).catch(() => { });
@@ -1024,6 +1492,8 @@ export async function main() {
1024
1492
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
1025
1493
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
1026
1494
  presentation: runtimePresentation,
1495
+ supportsPlanMode: useAppServer,
1496
+ supportsRichCards: useAppServer,
1027
1497
  }),
1028
1498
  };
1029
1499
  async function baselineControlSignal(conversationId) {
@@ -1108,12 +1578,14 @@ export async function main() {
1108
1578
  {
1109
1579
  id: 'transport',
1110
1580
  label: 'Transport',
1111
- value: 'exec --json',
1581
+ value: useAppServer ? 'app-server' : 'exec --json',
1112
1582
  },
1113
1583
  {
1114
1584
  id: 'streaming',
1115
1585
  label: 'Live output',
1116
- value: 'Thinking, tools, and completed-message previews',
1586
+ value: useAppServer
1587
+ ? 'Plans, questions, approvals, tools, and message deltas'
1588
+ : 'Thinking, tools, and completed-message previews',
1117
1589
  },
1118
1590
  {
1119
1591
  id: 'codex-cli',
@@ -1124,8 +1596,8 @@ export async function main() {
1124
1596
  {
1125
1597
  id: 'nativeActions',
1126
1598
  label: 'Native actions',
1127
- value: 'Limited until app-server transport',
1128
- tone: 'warning',
1599
+ value: useAppServer ? 'Enabled' : 'Limited until app-server transport',
1600
+ ...(useAppServer ? {} : { tone: 'warning' }),
1129
1601
  },
1130
1602
  ],
1131
1603
  execution: {
@@ -1139,8 +1611,9 @@ export async function main() {
1139
1611
  fallbackReason: resolveExecutionFallbackReason(session?.environment),
1140
1612
  },
1141
1613
  notes: [
1142
- 'This Codex host uses the current exec --json transport, so Canon can show thinking, tool activity, and completed assistant-message previews, but not token-by-token text deltas.',
1143
- 'Codex review, compact/rollback, live plan/diff/reasoning updates, PTY command execution, plugin/app/MCP inventory, and structured approvals require the future app-server transport.',
1614
+ useAppServer
1615
+ ? 'This Codex host uses the app-server transport, so Canon can route native plan mode, runtime questions, approvals, and live turn updates.'
1616
+ : 'This Codex host uses the current exec --json transport, so Canon can show thinking, tool activity, and completed assistant-message previews, but not native plan questions or structured approvals.',
1144
1617
  ],
1145
1618
  };
1146
1619
  await runtimeState.writeRuntimeInfo(conversationId, payload);
@@ -1215,6 +1688,8 @@ export async function main() {
1215
1688
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
1216
1689
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
1217
1690
  presentation: runtimePresentation,
1691
+ supportsPlanMode: useAppServer,
1692
+ supportsRichCards: useAppServer,
1218
1693
  }),
1219
1694
  };
1220
1695
  }
@@ -1235,6 +1710,8 @@ export async function main() {
1235
1710
  permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
1236
1711
  defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
1237
1712
  presentation: runtimePresentation,
1713
+ supportsPlanMode: useAppServer,
1714
+ supportsRichCards: useAppServer,
1238
1715
  }),
1239
1716
  };
1240
1717
  }
@@ -1264,13 +1741,9 @@ export async function main() {
1264
1741
  ? inboundMessages.slice(cursorIndex + 1)
1265
1742
  : inboundMessages.slice(-1);
1266
1743
  for (const latestMessage of messagesToRecover) {
1267
- const senderTurnState = latestMessage.senderType === 'ai_agent'
1268
- ? await loadSenderRuntimeState(conversation.id, latestMessage.senderId)
1269
- : null;
1270
1744
  const triggerDecision = shouldTriggerAgentTurn({
1271
1745
  senderType: latestMessage.senderType ?? 'human',
1272
1746
  metadata: latestMessage.metadata,
1273
- senderTurnState,
1274
1747
  });
1275
1748
  if (!triggerDecision.allow) {
1276
1749
  console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Skipping startup recovery for suppressed message (${triggerDecision.reason})`);