@canonmsg/codex-plugin 0.9.1 → 0.9.3

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
@@ -3,8 +3,7 @@ import { setDefaultResultOrder } from 'node:dns';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { getCodexImagePath, materializeMessageMedia, } from '@canonmsg/agent-sdk';
6
- import { buildConfiguredWorkspaceOptionsWithRoots, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, clearSessionState, clearTurnState, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, normalizeTurnState, prepareConversationEnvironment, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, writeRuntimeInfo, shouldTriggerAgentTurn, saveRuntimeSessionState, writeSessionState, writeTurnState, upsertLocalRuntimeEntry, } from '@canonmsg/core';
7
- import { buildCanonHostPrompt, buildHydratedInboundContext, createConversationMetadataLoader, loadHostSessionConfig, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, } from './host-runtime.js';
6
+ import { buildCanonHostPrompt, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, createConversationMetadataLoader, createRuntimeStatePublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, normalizeTurnState, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, shouldTriggerAgentTurn, saveRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
8
7
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
9
8
  import { CodexConversationAdapter, } from './adapter.js';
10
9
  import { clearStoredThreadId, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
@@ -26,63 +25,15 @@ let workspaceOptions = [];
26
25
  let workspaceRoots = [];
27
26
  let workspaceRootMetadata = [];
28
27
  function buildCodexRuntimeDescriptor(input) {
29
- return {
30
- coreControls: [
31
- {
32
- id: 'model',
33
- label: 'Model',
34
- options: input.models,
35
- defaultValue: input.models[0]?.value ?? null,
36
- availability: 'setup_and_live',
37
- liveBehavior: 'next_turn',
38
- selectionPolicy: 'inherit',
39
- },
40
- {
41
- id: 'workspace',
42
- label: 'Project',
43
- options: input.workspaces.map((workspace) => ({
44
- value: workspace.id,
45
- label: workspace.label,
46
- ...(workspace.description ? { description: workspace.description } : {}),
47
- ...(workspace.workspaceRootId ? { workspaceRootId: workspace.workspaceRootId } : {}),
48
- ...(workspace.workspaceRelativePath ? { workspaceRelativePath: workspace.workspaceRelativePath } : {}),
49
- ...(workspace.source ? { source: workspace.source } : {}),
50
- })),
51
- defaultValue: input.workspaces[0]?.id ?? null,
52
- availability: 'setup',
53
- liveBehavior: 'none',
54
- selectionPolicy: 'inherit',
55
- description: input.workspaceRoots?.length
56
- ? 'Choose one of the projects discovered inside the approved local roots for this host.'
57
- : 'Choose one of the local projects advertised by this host.',
58
- },
59
- {
60
- id: 'executionMode',
61
- label: 'Execution mode',
62
- options: input.executionModes.map((mode) => ({
63
- value: mode,
64
- label: mode === 'worktree' ? 'Isolated worktree' : 'Use shared project',
65
- description: mode === 'worktree'
66
- ? 'Creates or reuses a per-conversation git worktree under ~/.canon/conversation-worktrees when the selected project is a git repo.'
67
- : 'Runs directly in the selected project folder. Changes happen there.',
68
- })),
69
- defaultValue: null,
70
- availability: 'setup',
71
- liveBehavior: 'none',
72
- selectionPolicy: 'required_explicit',
73
- },
74
- ],
75
- runtimeControls: [
76
- {
77
- id: 'permissionMode',
78
- label: 'Execution policy',
79
- options: input.permissionModes,
80
- defaultValue: input.defaultPermissionMode ?? null,
81
- availability: 'setup',
82
- liveBehavior: 'none',
83
- selectionPolicy: 'inherit',
84
- },
85
- ],
28
+ return buildFirstPartyCodingRuntimeDescriptor({
29
+ clientType: 'codex',
30
+ models: input.models,
31
+ workspaces: input.workspaces,
32
+ workspaceRoots: input.workspaceRoots,
33
+ executionModes: input.executionModes,
34
+ permissionModes: input.permissionModes,
35
+ defaultPermissionMode: input.defaultPermissionMode,
36
+ streamingTextMode: 'snapshot',
86
37
  actions: [
87
38
  {
88
39
  id: 'stop',
@@ -105,25 +56,13 @@ function buildCodexRuntimeDescriptor(input) {
105
56
  dispatch: { kind: 'signal', signal: 'stop_and_drop' },
106
57
  },
107
58
  ],
108
- workspaceRoots: input.workspaceRoots,
109
- supportsInterrupt: true,
110
- streamingTextMode: 'snapshot',
111
- };
59
+ });
112
60
  }
113
61
  function normalizeRuntimeTurnState(value) {
114
62
  const normalizedTurn = normalizeTurnState(value);
115
63
  if (normalizedTurn) {
116
64
  return { state: normalizedTurn.state };
117
65
  }
118
- if (!value || typeof value !== 'object')
119
- return null;
120
- const state = value.state;
121
- if (state === 'running') {
122
- return { state: 'streaming' };
123
- }
124
- if (state === 'requires_action') {
125
- return { state: 'waiting_input' };
126
- }
127
66
  return null;
128
67
  }
129
68
  async function publishAgentRuntime(agentId, runtime) {
@@ -268,6 +207,11 @@ export async function main() {
268
207
  lastStartedAt: new Date().toISOString(),
269
208
  lastHeartbeatAt: new Date().toISOString(),
270
209
  });
210
+ const runtimeState = createRuntimeStatePublisher({
211
+ agentId,
212
+ clientType: 'codex',
213
+ hostMode: true,
214
+ });
271
215
  const sessions = new Map();
272
216
  const pendingSessionCreations = new Map();
273
217
  const conversationCache = new Map();
@@ -294,11 +238,7 @@ export async function main() {
294
238
  }
295
239
  async function loadSenderRuntimeState(conversationId, senderId) {
296
240
  try {
297
- const [turnState, sessionState] = await Promise.all([
298
- rtdbRead(`/turn-state/${conversationId}/${senderId}`),
299
- rtdbRead(`/session-state/${conversationId}/${senderId}`),
300
- ]);
301
- return normalizeRuntimeTurnState(turnState) ?? normalizeRuntimeTurnState(sessionState);
241
+ return normalizeRuntimeTurnState(await rtdbRead(`/turn-state/${conversationId}/${senderId}`));
302
242
  }
303
243
  catch {
304
244
  return null;
@@ -321,7 +261,7 @@ export async function main() {
321
261
  });
322
262
  }
323
263
  function writeState(session) {
324
- writeSessionState(session.conversationId, agentId, {
264
+ runtimeState.writeSessionState(session.conversationId, {
325
265
  lastError: session.state.lastError,
326
266
  model: session.state.model,
327
267
  cwd: session.cwd,
@@ -333,12 +273,11 @@ export async function main() {
333
273
  : {}),
334
274
  hostMode: true,
335
275
  clientType: 'codex',
336
- state: session.state.state,
337
276
  isActive: true,
338
277
  }).catch(() => { });
339
278
  }
340
279
  function writeTurn(session) {
341
- writeTurnState(session.conversationId, agentId, {
280
+ runtimeState.writeTurnState(session.conversationId, {
342
281
  turnId: session.currentTurnId,
343
282
  state: session.turnState,
344
283
  queueDepth: session.queue.length,
@@ -357,7 +296,7 @@ export async function main() {
357
296
  await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
358
297
  }
359
298
  function clearStreaming(conversationId) {
360
- rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
299
+ runtimeState.clearStreaming(conversationId).catch(() => { });
361
300
  }
362
301
  async function handoffFinalMessage(conversationId) {
363
302
  await sleep(FINAL_MESSAGE_HANDOFF_MS);
@@ -393,8 +332,8 @@ export async function main() {
393
332
  stopVisibleWorkSignal(session);
394
333
  releaseConversationEnvironment(session.environment);
395
334
  clearStreaming(conversationId);
396
- clearSessionState(conversationId, agentId).catch(() => { });
397
- clearTurnState(conversationId, agentId).catch(() => { });
335
+ runtimeState.clearSessionState(conversationId).catch(() => { });
336
+ runtimeState.clearTurnState(conversationId).catch(() => { });
398
337
  client.setTyping(conversationId, false).catch(() => { });
399
338
  sessions.delete(conversationId);
400
339
  }
@@ -605,10 +544,9 @@ export async function main() {
605
544
  writeState(session);
606
545
  writeTurn(session);
607
546
  startVisibleWorkSignal(session);
608
- rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
547
+ runtimeState.writeStreaming(session.conversationId, {
609
548
  text: 'Thinking…',
610
549
  status: 'thinking',
611
- updatedAt: { '.sv': 'timestamp' },
612
550
  }).catch(() => { });
613
551
  try {
614
552
  const turnImagePaths = nextTurn.imagePaths ?? [];
@@ -624,10 +562,9 @@ export async function main() {
624
562
  writeTurn(session);
625
563
  stopVisibleWorkSignal(session);
626
564
  client.setTyping(session.conversationId, false).catch(() => { });
627
- rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
565
+ runtimeState.writeStreaming(session.conversationId, {
628
566
  text: event.text,
629
567
  status: 'streaming',
630
- updatedAt: { '.sv': 'timestamp' },
631
568
  }).catch(() => { });
632
569
  return;
633
570
  }
@@ -635,10 +572,9 @@ export async function main() {
635
572
  session.turnState = 'tool';
636
573
  writeTurn(session);
637
574
  startVisibleWorkSignal(session);
638
- rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
575
+ runtimeState.writeStreaming(session.conversationId, {
639
576
  text: summarizeCommand(event.command),
640
577
  status: 'tool',
641
- updatedAt: { '.sv': 'timestamp' },
642
578
  }).catch(() => { });
643
579
  return;
644
580
  }
@@ -772,6 +708,7 @@ export async function main() {
772
708
  runtime: runtimeDescriptor,
773
709
  workspaceOptions,
774
710
  defaultCwd: workingDir,
711
+ extraSessionConfigFields: ['permissionMode'],
775
712
  liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
776
713
  const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
777
714
  return [
@@ -793,15 +730,11 @@ export async function main() {
793
730
  ? resolveWorkspaceIdForBaseCwd(session.environment.baseCwd)
794
731
  : runtimeDescriptor.defaultWorkspaceId;
795
732
  const workspace = workspaceOptions.find((option) => option.id === workspaceId) ?? null;
733
+ const descriptor = runtimeDescriptor.runtimeDescriptor;
734
+ if (!descriptor)
735
+ return;
796
736
  const payload = {
797
- descriptor: runtimeDescriptor.runtimeDescriptor ?? buildCodexRuntimeDescriptor({
798
- models: runtimeDescriptor.availableModels ?? [],
799
- workspaces: buildPublicWorkspaceOptions(workspaceOptions),
800
- workspaceRoots: workspaceRootMetadata,
801
- executionModes: hostAvailableExecutionModes,
802
- permissionModes: [...codexPermissionEnvelope.availablePermissionModes],
803
- defaultPermissionMode: codexPermissionEnvelope.defaultPermissionMode,
804
- }),
737
+ descriptor,
805
738
  surfaceMode: 'host',
806
739
  statusItems: [
807
740
  {
@@ -836,7 +769,7 @@ export async function main() {
836
769
  '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.',
837
770
  ],
838
771
  };
839
- await writeRuntimeInfo(conversationId, agentId, payload);
772
+ await runtimeState.writeRuntimeInfo(conversationId, payload);
840
773
  })).catch((error) => {
841
774
  console.error('[canon-codex] Failed to publish runtime info:', error);
842
775
  });
@@ -872,7 +805,7 @@ export async function main() {
872
805
  },
873
806
  onDisconnected: () => {
874
807
  streamConnected = false;
875
- rtdbWrite(`/agent-runtime/${agentId}`, null).catch(() => { });
808
+ runtimeState.clearAgentRuntime().catch(() => { });
876
809
  console.error('[canon-codex] SSE disconnected');
877
810
  },
878
811
  onError: (error) => console.error(`[canon-codex] SSE error: ${error.message}`),
@@ -924,8 +857,8 @@ export async function main() {
924
857
  knownConversationIds.add(conversation.id);
925
858
  conversationCache.set(conversation.id, conversation);
926
859
  clearStreaming(conversation.id);
927
- clearSessionState(conversation.id, agentId).catch(() => { });
928
- clearTurnState(conversation.id, agentId).catch(() => { });
860
+ runtimeState.clearSessionState(conversation.id).catch(() => { });
861
+ runtimeState.clearTurnState(conversation.id).catch(() => { });
929
862
  }
930
863
  for (const conversation of conversations) {
931
864
  const cursor = loadRuntimeSessionState(runtimeId, {
@@ -1066,7 +999,7 @@ export async function main() {
1066
999
  clearInterval(heartbeat);
1067
1000
  clearInterval(idleCheck);
1068
1001
  stream.stop();
1069
- await rtdbWrite(`/agent-runtime/${agentId}`, null).catch(() => { });
1002
+ await runtimeState.clearAgentRuntime().catch(() => { });
1070
1003
  for (const session of [...sessions.values()]) {
1071
1004
  await session.adapter.interrupt().catch(() => { });
1072
1005
  closeSession(session.conversationId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
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": "^0.10.1",
33
- "@canonmsg/core": "^0.13.0"
32
+ "@canonmsg/agent-sdk": "^1.0.0",
33
+ "@canonmsg/core": "^0.15.0"
34
34
  },
35
35
  "engines": {
36
36
  "node": ">=18.0.0"
@@ -1,133 +0,0 @@
1
- /**
2
- * Host-runtime helpers, inlined from @canonmsg/core.
3
- *
4
- * These helpers glue Canon host wrappers (Codex, Claude Code) to the Canon
5
- * RTDB session/runtime surface. They used to live in
6
- * `@canonmsg/core/src/host-runtime/` but were moved into each plugin in
7
- * media-parity PR C so core does not have to carry host-specific concerns.
8
- *
9
- * Keep this file in lockstep with the equivalent file in
10
- * `packages/claude-code-plugin/src/host-runtime.ts`. If you change the
11
- * behavior here, update that copy too and adjust the shared golden
12
- * fixture test (`packages/codex-plugin/src/host-runtime.test.ts`).
13
- */
14
- import { type AgentClientType, type AgentRuntime, type CanonClient, type CanonConversation, type CanonMessage, type CanonMessagesPage, type MessageCreatedPayload, type ResolvedAgentBehaviorPolicy, type SessionWorkspaceConfig } from '@canonmsg/core';
15
- export interface HostInboundParticipantContext {
16
- conversationType: CanonConversation['type'] | 'unknown';
17
- memberCount: number | null;
18
- senderType: 'human' | 'ai_agent';
19
- senderName: string;
20
- isOwner: boolean;
21
- mentionedAgent: boolean;
22
- recentSenderTypes: Array<'human' | 'ai_agent'>;
23
- recentHumanCount: number;
24
- recentAgentCount: number;
25
- consecutiveAgentTurns: number;
26
- currentAgentStreakStartedByHuman: boolean;
27
- }
28
- type HostInboundMessage = {
29
- text?: string | null;
30
- contentType?: CanonMessage['contentType'] | null;
31
- attachments?: CanonMessage['attachments'];
32
- senderType?: CanonMessage['senderType'];
33
- mentions?: string[] | null;
34
- contactCard?: CanonMessage['contactCard'];
35
- };
36
- interface HostWorkspaceResolverOption {
37
- id: string;
38
- cwd: string;
39
- }
40
- export declare const HOST_ADMISSION_ACTION_CAPABILITIES: Readonly<{
41
- canStartDirectConversation: false;
42
- canSendContactRequest: false;
43
- canApprovePendingContactRequests: false;
44
- canRejectPendingContactRequests: false;
45
- }>;
46
- export declare function buildCanonHostPrompt(input: {
47
- hostLabel: string;
48
- content: string;
49
- conversationId: string;
50
- participantContext: HostInboundParticipantContext;
51
- behavior?: ResolvedAgentBehaviorPolicy | null;
52
- workSession?: MessageCreatedPayload['message']['workSession'];
53
- workSessions?: MessageCreatedPayload['workSessions'];
54
- buildInboundContextLines: (context: HostInboundParticipantContext) => string[];
55
- }): string;
56
- /**
57
- * Render the **text portion** of an inbound Canon message. Images are
58
- * referenced by short placeholders — their actual bytes are delivered to the
59
- * host as native vision/media inputs (Codex `-i <file>`, Anthropic image
60
- * blocks). URLs are intentionally *not* inlined, since the harness never
61
- * needs to refetch and earlier `[Image: <url>]` inlining caused vision
62
- * models to see a string about an image instead of the image itself.
63
- *
64
- * `materialized` may be passed so non-image attachments can reference a
65
- * local path the agent can Read. Without it we fall back to an unadorned
66
- * placeholder; the vision path still works because image args carry the
67
- * file path directly.
68
- */
69
- export declare function renderCanonHostInboundContent(message: HostInboundMessage, materialized?: ReadonlyArray<{
70
- kind: 'image' | 'audio' | 'file';
71
- path: string;
72
- fileName?: string;
73
- durationMs?: number;
74
- index: number;
75
- }>): string;
76
- export declare function buildHydratedInboundContext(input: {
77
- agentId: string;
78
- conversation: CanonConversation | null;
79
- page?: CanonMessagesPage | null;
80
- message: HostInboundMessage;
81
- senderName: string;
82
- isOwner: boolean;
83
- }): {
84
- participantContext: HostInboundParticipantContext;
85
- behavior?: ResolvedAgentBehaviorPolicy | null;
86
- workSessions: NonNullable<MessageCreatedPayload['workSessions']>;
87
- hydratedFromPage: boolean;
88
- };
89
- export declare function publishHostAgentRuntime(agentId: string, clientType: AgentClientType, runtime: AgentRuntime): Promise<void>;
90
- export declare function publishHostSessionSnapshots(input: {
91
- conversationIds: string[];
92
- agentId: string;
93
- clientType: AgentClientType;
94
- runtime: AgentRuntime;
95
- workspaceOptions: HostWorkspaceResolverOption[];
96
- defaultCwd: string;
97
- liveSessionConfigByConversation?: ReadonlyMap<string, {
98
- model?: string;
99
- permissionMode?: string;
100
- effort?: string;
101
- runtimeControlValues?: Record<string, string>;
102
- workspaceId?: string;
103
- executionMode?: SessionWorkspaceConfig['executionMode'];
104
- executionBranch?: string | null;
105
- }>;
106
- }): Promise<void>;
107
- export declare function readHostSessionConfig<TExtra extends string = never>(raw: unknown, extraStringFields?: readonly TExtra[]): (SessionWorkspaceConfig & Partial<Record<TExtra, string>> & {
108
- runtimeControlValues?: Record<string, string>;
109
- }) | null;
110
- export declare function loadHostSessionConfig<TExtra extends string = never>(input: {
111
- conversationId: string;
112
- agentId: string;
113
- extraStringFields?: readonly TExtra[];
114
- }): Promise<(SessionWorkspaceConfig & Partial<Record<TExtra, string>> & {
115
- runtimeControlValues?: Record<string, string>;
116
- }) | null>;
117
- export declare function resolveHostWorkspaceCwd(input: {
118
- workspaceOptions: HostWorkspaceResolverOption[];
119
- config: {
120
- workspaceId?: string;
121
- retiredWorkspaceConfig?: boolean;
122
- } | null;
123
- defaultCwd: string;
124
- }): string;
125
- export declare function createConversationMetadataLoader(input: {
126
- client: CanonClient;
127
- conversationCache: Map<string, CanonConversation>;
128
- cacheTtlMs?: number;
129
- }): {
130
- refreshConversationCache(force?: boolean): Promise<void>;
131
- getConversationMeta(conversationId: string): Promise<CanonConversation | null>;
132
- };
133
- export {};
@@ -1,263 +0,0 @@
1
- /**
2
- * Host-runtime helpers, inlined from @canonmsg/core.
3
- *
4
- * These helpers glue Canon host wrappers (Codex, Claude Code) to the Canon
5
- * RTDB session/runtime surface. They used to live in
6
- * `@canonmsg/core/src/host-runtime/` but were moved into each plugin in
7
- * media-parity PR C so core does not have to carry host-specific concerns.
8
- *
9
- * Keep this file in lockstep with the equivalent file in
10
- * `packages/claude-code-plugin/src/host-runtime.ts`. If you change the
11
- * behavior here, update that copy too and adjust the shared golden
12
- * fixture test (`packages/codex-plugin/src/host-runtime.test.ts`).
13
- */
14
- import { buildAgentSessionSnapshot, buildConversationWorktreeSpec, buildBehaviorPolicyLines, buildParticipationHistorySnapshot, buildWorkSessionsPromptLines, mergeWorkSessionContexts, normalizeOptionalString, patchAgentSessionSnapshot, readSessionWorkspaceConfig, resolveConfiguredWorkspaceCwd, rtdbRead, rtdbWrite, } from '@canonmsg/core';
15
- export const HOST_ADMISSION_ACTION_CAPABILITIES = Object.freeze({
16
- canStartDirectConversation: false,
17
- canSendContactRequest: false,
18
- canApprovePendingContactRequests: false,
19
- canRejectPendingContactRequests: false,
20
- });
21
- export function buildCanonHostPrompt(input) {
22
- const resolvedWorkSessions = mergeWorkSessionContexts(input.workSession, input.workSessions);
23
- return [
24
- `You are connected to Canon messaging through a ${input.hostLabel} host wrapper.`,
25
- 'Only the last assistant message from this turn will be delivered as the permanent Canon reply.',
26
- 'Short intermediate assistant messages may be shown as ephemeral status while you work.',
27
- ...input.buildInboundContextLines(input.participantContext),
28
- ...buildBehaviorPolicyLines(input.behavior),
29
- ...buildWorkSessionsPromptLines(resolvedWorkSessions),
30
- 'Canon participants may be humans or AI agents.',
31
- 'Honor the Canon behavior policy above when deciding how proactively to participate.',
32
- ...(resolvedWorkSessions.length > 0
33
- ? ['Honor the Canon work-session context above within its stated disclosure limits.']
34
- : []),
35
- `Conversation ID: ${input.conversationId}`,
36
- '',
37
- 'New Canon message:',
38
- input.content,
39
- ].join('\n');
40
- }
41
- /**
42
- * Render the **text portion** of an inbound Canon message. Images are
43
- * referenced by short placeholders — their actual bytes are delivered to the
44
- * host as native vision/media inputs (Codex `-i <file>`, Anthropic image
45
- * blocks). URLs are intentionally *not* inlined, since the harness never
46
- * needs to refetch and earlier `[Image: <url>]` inlining caused vision
47
- * models to see a string about an image instead of the image itself.
48
- *
49
- * `materialized` may be passed so non-image attachments can reference a
50
- * local path the agent can Read. Without it we fall back to an unadorned
51
- * placeholder; the vision path still works because image args carry the
52
- * file path directly.
53
- */
54
- export function renderCanonHostInboundContent(message, materialized) {
55
- const body = message.text || '';
56
- const placeholders = [];
57
- const attachments = message.attachments ?? [];
58
- for (let i = 0; i < attachments.length; i += 1) {
59
- const att = attachments[i];
60
- const mat = materialized?.find((m) => m.index === i) ?? null;
61
- placeholders.push(describeAttachment(att, mat));
62
- }
63
- if (message.contentType === 'contact_card' && message.contactCard) {
64
- placeholders.push(describeContactCard(message.contactCard));
65
- }
66
- const rendered = [...placeholders, body].filter(Boolean).join('\n');
67
- return rendered || '[Empty message]';
68
- }
69
- function describeContactCard(card) {
70
- const parts = [`${card.userType} · userId: ${card.userId}`];
71
- if (card.ownerName)
72
- parts.push(`owner: ${card.ownerName}`);
73
- if (card.about)
74
- parts.push(`about: ${card.about}`);
75
- const identity = `📇 Contact card: "${card.displayName}" (${parts.join(' · ')}).`;
76
- const missingCapabilities = [
77
- !HOST_ADMISSION_ACTION_CAPABILITIES.canStartDirectConversation
78
- ? 'start a direct conversation'
79
- : null,
80
- !HOST_ADMISSION_ACTION_CAPABILITIES.canSendContactRequest
81
- ? 'send a contact request'
82
- : null,
83
- !HOST_ADMISSION_ACTION_CAPABILITIES.canApprovePendingContactRequests
84
- ? 'approve pending requests'
85
- : null,
86
- !HOST_ADMISSION_ACTION_CAPABILITIES.canRejectPendingContactRequests
87
- ? 'reject pending requests'
88
- : null,
89
- ].filter(Boolean).join(', ');
90
- const hint = `This host can inspect the card, but Canon admission actions are missing here. Missing capabilities: ${missingCapabilities}. Use another Canon surface for userId ${card.userId}.`;
91
- return `${identity}\n${hint}`;
92
- }
93
- function describeAttachment(attachment, materialized) {
94
- if (attachment.kind === 'image') {
95
- return '[Image attached]';
96
- }
97
- if (attachment.kind === 'audio') {
98
- const durationMs = materialized?.durationMs ?? attachment.durationMs;
99
- const duration = durationMs ? ` (${Math.round(durationMs / 1000)}s)` : '';
100
- const ref = materialized?.path ? ` ${materialized.path}` : '';
101
- return `[Voice message${duration}${ref}]`;
102
- }
103
- // file
104
- const label = materialized?.fileName ?? attachment.fileName ?? 'File';
105
- const ref = materialized?.path ? ` ${materialized.path}` : '';
106
- return `[File: ${label}${ref}]`;
107
- }
108
- export function buildHydratedInboundContext(input) {
109
- const history = buildParticipationHistorySnapshot(input.page?.messages ?? [], input.agentId);
110
- return {
111
- participantContext: {
112
- conversationType: input.conversation?.type ?? 'unknown',
113
- memberCount: input.conversation?.memberIds?.length ?? null,
114
- senderType: input.message.senderType ?? 'human',
115
- senderName: input.senderName,
116
- isOwner: input.isOwner,
117
- mentionedAgent: Array.isArray(input.message.mentions) && input.message.mentions.includes(input.agentId),
118
- recentSenderTypes: history.recentSenderTypes,
119
- recentHumanCount: history.recentHumanCount,
120
- recentAgentCount: history.recentAgentCount,
121
- consecutiveAgentTurns: history.consecutiveAgentTurns,
122
- currentAgentStreakStartedByHuman: history.currentAgentStreakStartedByHuman,
123
- },
124
- behavior: input.page?.behavior ?? input.conversation?.behavior,
125
- workSessions: input.page?.workSessions ?? [],
126
- hydratedFromPage: input.page != null,
127
- };
128
- }
129
- export async function publishHostAgentRuntime(agentId, clientType, runtime) {
130
- await rtdbWrite(`/agent-runtime/${agentId}`, {
131
- clientType,
132
- hostMode: true,
133
- ...runtime,
134
- updatedAt: { '.sv': 'timestamp' },
135
- });
136
- }
137
- export async function publishHostSessionSnapshots(input) {
138
- if (input.conversationIds.length === 0) {
139
- return;
140
- }
141
- await Promise.all(input.conversationIds.map(async (conversationId) => {
142
- const persistedConfig = await loadHostSessionConfig({
143
- conversationId,
144
- agentId: input.agentId,
145
- extraStringFields: ['permissionMode'],
146
- });
147
- const liveConfig = input.liveSessionConfigByConversation?.get(conversationId) ?? null;
148
- const mergedConfig = {
149
- ...(persistedConfig ?? {}),
150
- ...(liveConfig ?? {}),
151
- };
152
- const snapshot = buildAgentSessionSnapshot({
153
- conversationId,
154
- agentId: input.agentId,
155
- runtime: {
156
- ...input.runtime,
157
- clientType: input.clientType,
158
- hostMode: true,
159
- },
160
- sessionConfig: {
161
- ...(mergedConfig.model ? { model: mergedConfig.model } : {}),
162
- ...(mergedConfig.permissionMode ? { permissionMode: mergedConfig.permissionMode } : {}),
163
- ...(mergedConfig.effort ? { effort: mergedConfig.effort } : {}),
164
- ...(mergedConfig.runtimeControlValues
165
- ? { runtimeControlValues: mergedConfig.runtimeControlValues }
166
- : {}),
167
- ...(mergedConfig.workspaceId ? { workspaceId: mergedConfig.workspaceId } : {}),
168
- ...(mergedConfig.executionMode ? { executionMode: mergedConfig.executionMode } : {}),
169
- },
170
- lastHeartbeatAt: undefined,
171
- });
172
- let executionBranch = liveConfig?.executionBranch ?? null;
173
- if (!executionBranch && snapshot.executionMode === 'worktree' && snapshot.workspaceId) {
174
- const workspace = input.workspaceOptions.find((option) => option.id === snapshot.workspaceId);
175
- if (workspace) {
176
- executionBranch = buildConversationWorktreeSpec({
177
- agentId: input.agentId,
178
- conversationId,
179
- workspaceCwd: workspace.cwd,
180
- }).branch;
181
- }
182
- }
183
- return patchAgentSessionSnapshot(conversationId, input.agentId, {
184
- clientType: input.clientType,
185
- hostMode: true,
186
- model: snapshot.model ?? null,
187
- permissionMode: snapshot.permissionMode ?? null,
188
- effort: snapshot.effort ?? null,
189
- runtimeControlValues: snapshot.runtimeControlValues ?? null,
190
- workspaceId: snapshot.workspaceId ?? null,
191
- executionMode: snapshot.executionMode ?? null,
192
- executionBranch,
193
- modelOptions: snapshot.modelOptions,
194
- permissionModeOptions: snapshot.permissionModeOptions,
195
- workspaceOptions: snapshot.workspaceOptions,
196
- availableExecutionModes: snapshot.availableExecutionModes,
197
- lastHeartbeatAt: { '.sv': 'timestamp' },
198
- });
199
- }));
200
- }
201
- export function readHostSessionConfig(raw, extraStringFields = []) {
202
- const baseConfig = readSessionWorkspaceConfig(raw);
203
- if (!raw || typeof raw !== 'object') {
204
- return baseConfig;
205
- }
206
- const data = raw;
207
- const extraConfig = Object.fromEntries(extraStringFields.flatMap((field) => {
208
- const value = normalizeOptionalString(data[field]);
209
- return value ? [[field, value]] : [];
210
- }));
211
- const runtimeControlValues = Object.fromEntries(Object.entries(data.runtimeControlValues && typeof data.runtimeControlValues === 'object'
212
- ? data.runtimeControlValues
213
- : {}).flatMap(([key, value]) => {
214
- const normalizedValue = normalizeOptionalString(value);
215
- return normalizedValue ? [[key, normalizedValue]] : [];
216
- }));
217
- return {
218
- ...(baseConfig ?? {}),
219
- ...extraConfig,
220
- ...(Object.keys(runtimeControlValues).length > 0 ? { runtimeControlValues } : {}),
221
- };
222
- }
223
- export async function loadHostSessionConfig(input) {
224
- const raw = await rtdbRead(`/session-config/${input.conversationId}/${input.agentId}`);
225
- return readHostSessionConfig(raw, input.extraStringFields);
226
- }
227
- export function resolveHostWorkspaceCwd(input) {
228
- return resolveConfiguredWorkspaceCwd(input);
229
- }
230
- export function createConversationMetadataLoader(input) {
231
- const cacheTtlMs = input.cacheTtlMs ?? 10_000;
232
- let conversationCacheLoadedAt = 0;
233
- async function refreshConversationCache(force = false) {
234
- if (!force
235
- && input.conversationCache.size > 0
236
- && Date.now() - conversationCacheLoadedAt < cacheTtlMs) {
237
- return;
238
- }
239
- const conversations = await input.client.getConversations();
240
- input.conversationCache.clear();
241
- for (const conversation of conversations) {
242
- input.conversationCache.set(conversation.id, conversation);
243
- }
244
- conversationCacheLoadedAt = Date.now();
245
- }
246
- async function getConversationMeta(conversationId) {
247
- try {
248
- await refreshConversationCache();
249
- const cached = input.conversationCache.get(conversationId);
250
- if (cached)
251
- return cached;
252
- await refreshConversationCache(true);
253
- return input.conversationCache.get(conversationId) ?? null;
254
- }
255
- catch {
256
- return input.conversationCache.get(conversationId) ?? null;
257
- }
258
- }
259
- return {
260
- refreshConversationCache,
261
- getConversationMeta,
262
- };
263
- }