@canonmsg/codex-plugin 0.9.1 → 0.9.2

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,10 +56,7 @@ 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);
@@ -268,6 +216,11 @@ export async function main() {
268
216
  lastStartedAt: new Date().toISOString(),
269
217
  lastHeartbeatAt: new Date().toISOString(),
270
218
  });
219
+ const runtimeState = createRuntimeStatePublisher({
220
+ agentId,
221
+ clientType: 'codex',
222
+ hostMode: true,
223
+ });
271
224
  const sessions = new Map();
272
225
  const pendingSessionCreations = new Map();
273
226
  const conversationCache = new Map();
@@ -321,7 +274,7 @@ export async function main() {
321
274
  });
322
275
  }
323
276
  function writeState(session) {
324
- writeSessionState(session.conversationId, agentId, {
277
+ runtimeState.writeSessionState(session.conversationId, {
325
278
  lastError: session.state.lastError,
326
279
  model: session.state.model,
327
280
  cwd: session.cwd,
@@ -338,7 +291,7 @@ export async function main() {
338
291
  }).catch(() => { });
339
292
  }
340
293
  function writeTurn(session) {
341
- writeTurnState(session.conversationId, agentId, {
294
+ runtimeState.writeTurnState(session.conversationId, {
342
295
  turnId: session.currentTurnId,
343
296
  state: session.turnState,
344
297
  queueDepth: session.queue.length,
@@ -357,7 +310,7 @@ export async function main() {
357
310
  await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
358
311
  }
359
312
  function clearStreaming(conversationId) {
360
- rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
313
+ runtimeState.clearStreaming(conversationId).catch(() => { });
361
314
  }
362
315
  async function handoffFinalMessage(conversationId) {
363
316
  await sleep(FINAL_MESSAGE_HANDOFF_MS);
@@ -393,8 +346,8 @@ export async function main() {
393
346
  stopVisibleWorkSignal(session);
394
347
  releaseConversationEnvironment(session.environment);
395
348
  clearStreaming(conversationId);
396
- clearSessionState(conversationId, agentId).catch(() => { });
397
- clearTurnState(conversationId, agentId).catch(() => { });
349
+ runtimeState.clearSessionState(conversationId).catch(() => { });
350
+ runtimeState.clearTurnState(conversationId).catch(() => { });
398
351
  client.setTyping(conversationId, false).catch(() => { });
399
352
  sessions.delete(conversationId);
400
353
  }
@@ -605,10 +558,9 @@ export async function main() {
605
558
  writeState(session);
606
559
  writeTurn(session);
607
560
  startVisibleWorkSignal(session);
608
- rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
561
+ runtimeState.writeStreaming(session.conversationId, {
609
562
  text: 'Thinking…',
610
563
  status: 'thinking',
611
- updatedAt: { '.sv': 'timestamp' },
612
564
  }).catch(() => { });
613
565
  try {
614
566
  const turnImagePaths = nextTurn.imagePaths ?? [];
@@ -624,10 +576,9 @@ export async function main() {
624
576
  writeTurn(session);
625
577
  stopVisibleWorkSignal(session);
626
578
  client.setTyping(session.conversationId, false).catch(() => { });
627
- rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
579
+ runtimeState.writeStreaming(session.conversationId, {
628
580
  text: event.text,
629
581
  status: 'streaming',
630
- updatedAt: { '.sv': 'timestamp' },
631
582
  }).catch(() => { });
632
583
  return;
633
584
  }
@@ -635,10 +586,9 @@ export async function main() {
635
586
  session.turnState = 'tool';
636
587
  writeTurn(session);
637
588
  startVisibleWorkSignal(session);
638
- rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
589
+ runtimeState.writeStreaming(session.conversationId, {
639
590
  text: summarizeCommand(event.command),
640
591
  status: 'tool',
641
- updatedAt: { '.sv': 'timestamp' },
642
592
  }).catch(() => { });
643
593
  return;
644
594
  }
@@ -772,6 +722,7 @@ export async function main() {
772
722
  runtime: runtimeDescriptor,
773
723
  workspaceOptions,
774
724
  defaultCwd: workingDir,
725
+ extraSessionConfigFields: ['permissionMode'],
775
726
  liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
776
727
  const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
777
728
  return [
@@ -836,7 +787,7 @@ export async function main() {
836
787
  '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
788
  ],
838
789
  };
839
- await writeRuntimeInfo(conversationId, agentId, payload);
790
+ await runtimeState.writeRuntimeInfo(conversationId, payload);
840
791
  })).catch((error) => {
841
792
  console.error('[canon-codex] Failed to publish runtime info:', error);
842
793
  });
@@ -872,7 +823,7 @@ export async function main() {
872
823
  },
873
824
  onDisconnected: () => {
874
825
  streamConnected = false;
875
- rtdbWrite(`/agent-runtime/${agentId}`, null).catch(() => { });
826
+ runtimeState.clearAgentRuntime().catch(() => { });
876
827
  console.error('[canon-codex] SSE disconnected');
877
828
  },
878
829
  onError: (error) => console.error(`[canon-codex] SSE error: ${error.message}`),
@@ -924,8 +875,8 @@ export async function main() {
924
875
  knownConversationIds.add(conversation.id);
925
876
  conversationCache.set(conversation.id, conversation);
926
877
  clearStreaming(conversation.id);
927
- clearSessionState(conversation.id, agentId).catch(() => { });
928
- clearTurnState(conversation.id, agentId).catch(() => { });
878
+ runtimeState.clearSessionState(conversation.id).catch(() => { });
879
+ runtimeState.clearTurnState(conversation.id).catch(() => { });
929
880
  }
930
881
  for (const conversation of conversations) {
931
882
  const cursor = loadRuntimeSessionState(runtimeId, {
@@ -1066,7 +1017,7 @@ export async function main() {
1066
1017
  clearInterval(heartbeat);
1067
1018
  clearInterval(idleCheck);
1068
1019
  stream.stop();
1069
- await rtdbWrite(`/agent-runtime/${agentId}`, null).catch(() => { });
1020
+ await runtimeState.clearAgentRuntime().catch(() => { });
1070
1021
  for (const session of [...sessions.values()]) {
1071
1022
  await session.adapter.interrupt().catch(() => { });
1072
1023
  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.2",
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": "^0.10.2",
33
+ "@canonmsg/core": "^0.14.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
- }