@canonmsg/codex-plugin 0.9.0 → 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/README.md +10 -2
- package/dist/host.js +127 -118
- package/dist/register.js +23 -18
- package/dist/session-store.d.ts +4 -3
- package/dist/session-store.js +32 -6
- package/package.json +3 -3
- package/dist/host-runtime.d.ts +0 -133
- package/dist/host-runtime.js +0 -265
package/README.md
CHANGED
|
@@ -22,7 +22,15 @@ canon-codex --cwd /path/to/project
|
|
|
22
22
|
|
|
23
23
|
Registration saves a Canon profile in `~/.canon/agents.json`, the same shared profile store used by the Claude Code integration and supported by the OpenClaw plugin.
|
|
24
24
|
|
|
25
|
-
If the terminal closes or the machine restarts, the agent goes offline until you start the host again.
|
|
25
|
+
If the terminal closes or the machine restarts, the agent goes offline until you start the host again. Install `@canonmsg/local-agents` and run `canon-necromance` to list every recorded local agent from newest to oldest, then revive one in the foreground:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install -g @canonmsg/local-agents
|
|
29
|
+
canon-necromance
|
|
30
|
+
canon-necromance revive my-codex
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Do not run registration again unless Canon tells you the saved API key is invalid. If you registered multiple profiles, relaunch the same one with `CANON_AGENT=<profile> canon-codex --cwd /path/to/project`. Keep the revived terminal open; closing it takes this local agent offline.
|
|
26
34
|
|
|
27
35
|
You do not need a git repo for host mode. The plugin passes `--skip-git-repo-check` to Codex, so any readable working directory is valid.
|
|
28
36
|
|
|
@@ -99,7 +107,7 @@ If `canon-codex` starts but cannot find the `codex` binary, either fix your `PAT
|
|
|
99
107
|
canon-codex --cwd /path/to/project --codex-bin /absolute/path/to/codex
|
|
100
108
|
```
|
|
101
109
|
|
|
102
|
-
If Canon rejects authenticated requests with `401 Invalid API key`, the stored Canon profile needs a fresh key.
|
|
110
|
+
If Canon rejects authenticated requests with `401 Invalid API key`, the stored Canon profile needs a fresh key. Reconnect the same profile to overwrite `~/.canon/agents.json`, then restart or revive the host:
|
|
103
111
|
|
|
104
112
|
```bash
|
|
105
113
|
canon-codex-register --name "My Codex" --description "My local coding agent" --phone "+15551234567" --profile my-codex
|
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,
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
109
|
-
supportsInterrupt: true,
|
|
110
|
-
streamingTextMode: 'snapshot',
|
|
111
|
-
};
|
|
59
|
+
});
|
|
112
60
|
}
|
|
113
61
|
function normalizeRuntimeTurnState(value) {
|
|
114
62
|
const normalizedTurn = normalizeTurnState(value);
|
|
@@ -218,9 +166,9 @@ export async function main() {
|
|
|
218
166
|
if (typeof args['ask-for-approval'] === 'string') {
|
|
219
167
|
console.error('[canon-codex] Note: newer Codex CLI releases do not accept --ask-for-approval for `codex exec`; Canon will translate compatible legacy usage when possible.');
|
|
220
168
|
}
|
|
221
|
-
const { apiKey, agentId: profileAgentId, profile } = resolveCanonAgent({ logPrefix: '[canon-codex]' });
|
|
169
|
+
const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-codex]', expectedClientType: 'codex' });
|
|
222
170
|
console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
|
|
223
|
-
const client = new CanonClient(apiKey);
|
|
171
|
+
const client = new CanonClient(apiKey, baseUrl);
|
|
224
172
|
initRTDBAuth(client);
|
|
225
173
|
let agentId;
|
|
226
174
|
let ownerId = null;
|
|
@@ -240,6 +188,39 @@ export async function main() {
|
|
|
240
188
|
}
|
|
241
189
|
console.error(`[canon-codex] Authenticated as ${agentId}`);
|
|
242
190
|
}
|
|
191
|
+
const launchArgs = [...process.argv.slice(2)];
|
|
192
|
+
if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
|
|
193
|
+
launchArgs.push('--cwd', workingDir);
|
|
194
|
+
}
|
|
195
|
+
const runtimeId = buildLocalRuntimeId({
|
|
196
|
+
runtime: 'codex',
|
|
197
|
+
profile,
|
|
198
|
+
cwd: workingDir,
|
|
199
|
+
launchCommand: ['canon-codex', ...launchArgs],
|
|
200
|
+
});
|
|
201
|
+
upsertLocalRuntimeEntry({
|
|
202
|
+
id: runtimeId,
|
|
203
|
+
runtime: 'codex',
|
|
204
|
+
profile,
|
|
205
|
+
agentId,
|
|
206
|
+
agentName: profileAgentName,
|
|
207
|
+
cwd: workingDir,
|
|
208
|
+
baseCwd: workingDir,
|
|
209
|
+
workspaceRoots: workspaceRoots.map((root) => root.cwd),
|
|
210
|
+
workspaces: workspaceOptions.map((workspace) => workspace.cwd),
|
|
211
|
+
launchCommand: ['canon-codex', ...launchArgs],
|
|
212
|
+
pid: process.pid,
|
|
213
|
+
status: profile ? 'running' : 'manual',
|
|
214
|
+
reviveCapability: profile ? 'revivable' : 'manual',
|
|
215
|
+
surfaceMode: 'host',
|
|
216
|
+
lastStartedAt: new Date().toISOString(),
|
|
217
|
+
lastHeartbeatAt: new Date().toISOString(),
|
|
218
|
+
});
|
|
219
|
+
const runtimeState = createRuntimeStatePublisher({
|
|
220
|
+
agentId,
|
|
221
|
+
clientType: 'codex',
|
|
222
|
+
hostMode: true,
|
|
223
|
+
});
|
|
243
224
|
const sessions = new Map();
|
|
244
225
|
const pendingSessionCreations = new Map();
|
|
245
226
|
const conversationCache = new Map();
|
|
@@ -293,7 +274,7 @@ export async function main() {
|
|
|
293
274
|
});
|
|
294
275
|
}
|
|
295
276
|
function writeState(session) {
|
|
296
|
-
writeSessionState(session.conversationId,
|
|
277
|
+
runtimeState.writeSessionState(session.conversationId, {
|
|
297
278
|
lastError: session.state.lastError,
|
|
298
279
|
model: session.state.model,
|
|
299
280
|
cwd: session.cwd,
|
|
@@ -310,7 +291,7 @@ export async function main() {
|
|
|
310
291
|
}).catch(() => { });
|
|
311
292
|
}
|
|
312
293
|
function writeTurn(session) {
|
|
313
|
-
writeTurnState(session.conversationId,
|
|
294
|
+
runtimeState.writeTurnState(session.conversationId, {
|
|
314
295
|
turnId: session.currentTurnId,
|
|
315
296
|
state: session.turnState,
|
|
316
297
|
queueDepth: session.queue.length,
|
|
@@ -329,7 +310,7 @@ export async function main() {
|
|
|
329
310
|
await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
|
|
330
311
|
}
|
|
331
312
|
function clearStreaming(conversationId) {
|
|
332
|
-
|
|
313
|
+
runtimeState.clearStreaming(conversationId).catch(() => { });
|
|
333
314
|
}
|
|
334
315
|
async function handoffFinalMessage(conversationId) {
|
|
335
316
|
await sleep(FINAL_MESSAGE_HANDOFF_MS);
|
|
@@ -365,8 +346,8 @@ export async function main() {
|
|
|
365
346
|
stopVisibleWorkSignal(session);
|
|
366
347
|
releaseConversationEnvironment(session.environment);
|
|
367
348
|
clearStreaming(conversationId);
|
|
368
|
-
clearSessionState(conversationId
|
|
369
|
-
clearTurnState(conversationId
|
|
349
|
+
runtimeState.clearSessionState(conversationId).catch(() => { });
|
|
350
|
+
runtimeState.clearTurnState(conversationId).catch(() => { });
|
|
370
351
|
client.setTyping(conversationId, false).catch(() => { });
|
|
371
352
|
sessions.delete(conversationId);
|
|
372
353
|
}
|
|
@@ -409,7 +390,7 @@ export async function main() {
|
|
|
409
390
|
try {
|
|
410
391
|
const sessionCwd = environment.cwd;
|
|
411
392
|
const sessionModel = config?.model ?? (typeof args.model === 'string' ? args.model : undefined);
|
|
412
|
-
const storedThreadId = loadStoredThreadId(agentId, conversationId,
|
|
393
|
+
const storedThreadId = loadStoredThreadId(runtimeId, agentId, conversationId, environment.baseCwd, environment.mode);
|
|
413
394
|
if (config?.permissionMode
|
|
414
395
|
&& !codexPermissionEnvelope.availablePermissionModes.some((option) => option.value === config.permissionMode)) {
|
|
415
396
|
throw new ExecutionEnvironmentError(`Permission mode "${config.permissionMode}" is not supported by this Codex host.`, 'This Canon host was started with stricter approval settings. Choose one of the advertised permission modes or restart the host with more permissive flags.');
|
|
@@ -577,17 +558,16 @@ export async function main() {
|
|
|
577
558
|
writeState(session);
|
|
578
559
|
writeTurn(session);
|
|
579
560
|
startVisibleWorkSignal(session);
|
|
580
|
-
|
|
561
|
+
runtimeState.writeStreaming(session.conversationId, {
|
|
581
562
|
text: 'Thinking…',
|
|
582
563
|
status: 'thinking',
|
|
583
|
-
updatedAt: { '.sv': 'timestamp' },
|
|
584
564
|
}).catch(() => { });
|
|
585
565
|
try {
|
|
586
566
|
const turnImagePaths = nextTurn.imagePaths ?? [];
|
|
587
567
|
const result = await session.adapter.runTurn(nextTurn.prompt, (event) => {
|
|
588
568
|
session.lastActivity = Date.now();
|
|
589
569
|
if (event.type === 'thread.started') {
|
|
590
|
-
saveStoredThreadId(agentId, session.conversationId, session.
|
|
570
|
+
saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, event.threadId, session.environment.mode);
|
|
591
571
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Thread ${event.threadId}`);
|
|
592
572
|
return;
|
|
593
573
|
}
|
|
@@ -596,10 +576,9 @@ export async function main() {
|
|
|
596
576
|
writeTurn(session);
|
|
597
577
|
stopVisibleWorkSignal(session);
|
|
598
578
|
client.setTyping(session.conversationId, false).catch(() => { });
|
|
599
|
-
|
|
579
|
+
runtimeState.writeStreaming(session.conversationId, {
|
|
600
580
|
text: event.text,
|
|
601
581
|
status: 'streaming',
|
|
602
|
-
updatedAt: { '.sv': 'timestamp' },
|
|
603
582
|
}).catch(() => { });
|
|
604
583
|
return;
|
|
605
584
|
}
|
|
@@ -607,10 +586,9 @@ export async function main() {
|
|
|
607
586
|
session.turnState = 'tool';
|
|
608
587
|
writeTurn(session);
|
|
609
588
|
startVisibleWorkSignal(session);
|
|
610
|
-
|
|
589
|
+
runtimeState.writeStreaming(session.conversationId, {
|
|
611
590
|
text: summarizeCommand(event.command),
|
|
612
591
|
status: 'tool',
|
|
613
|
-
updatedAt: { '.sv': 'timestamp' },
|
|
614
592
|
}).catch(() => { });
|
|
615
593
|
return;
|
|
616
594
|
}
|
|
@@ -621,7 +599,7 @@ export async function main() {
|
|
|
621
599
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] ${line}`);
|
|
622
600
|
}, turnImagePaths);
|
|
623
601
|
if (result.threadId) {
|
|
624
|
-
saveStoredThreadId(agentId, session.conversationId, session.
|
|
602
|
+
saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode);
|
|
625
603
|
}
|
|
626
604
|
if (!result.interrupted && result.finalMessage) {
|
|
627
605
|
await client.sendMessage(session.conversationId, result.finalMessage, {
|
|
@@ -677,7 +655,9 @@ export async function main() {
|
|
|
677
655
|
},
|
|
678
656
|
}).catch(() => { });
|
|
679
657
|
await handoffFinalMessage(session.conversationId);
|
|
680
|
-
|
|
658
|
+
if (error instanceof Error && /invalid|not found|unknown thread/i.test(error.message)) {
|
|
659
|
+
clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
|
|
660
|
+
}
|
|
681
661
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
|
|
682
662
|
}
|
|
683
663
|
finally {
|
|
@@ -721,6 +701,12 @@ export async function main() {
|
|
|
721
701
|
}),
|
|
722
702
|
};
|
|
723
703
|
const publishRuntimeHeartbeat = async () => {
|
|
704
|
+
heartbeatLocalRuntimeEntry(runtimeId, {
|
|
705
|
+
agentId,
|
|
706
|
+
agentName: profileAgentName,
|
|
707
|
+
cwd: workingDir,
|
|
708
|
+
baseCwd: workingDir,
|
|
709
|
+
});
|
|
724
710
|
if (!streamConnected)
|
|
725
711
|
return;
|
|
726
712
|
await refreshKnownConversationIds().catch((error) => {
|
|
@@ -736,6 +722,7 @@ export async function main() {
|
|
|
736
722
|
runtime: runtimeDescriptor,
|
|
737
723
|
workspaceOptions,
|
|
738
724
|
defaultCwd: workingDir,
|
|
725
|
+
extraSessionConfigFields: ['permissionMode'],
|
|
739
726
|
liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
|
|
740
727
|
const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
|
|
741
728
|
return [
|
|
@@ -800,7 +787,7 @@ export async function main() {
|
|
|
800
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.',
|
|
801
788
|
],
|
|
802
789
|
};
|
|
803
|
-
await writeRuntimeInfo(conversationId,
|
|
790
|
+
await runtimeState.writeRuntimeInfo(conversationId, payload);
|
|
804
791
|
})).catch((error) => {
|
|
805
792
|
console.error('[canon-codex] Failed to publish runtime info:', error);
|
|
806
793
|
});
|
|
@@ -821,6 +808,13 @@ export async function main() {
|
|
|
821
808
|
behavior: payload.behavior,
|
|
822
809
|
workSessions: payload.workSessions,
|
|
823
810
|
});
|
|
811
|
+
if (message.id) {
|
|
812
|
+
saveRuntimeSessionState(runtimeId, {
|
|
813
|
+
conversationId: payload.conversationId,
|
|
814
|
+
baseCwd: workingDir,
|
|
815
|
+
lastInboundMessageId: message.id,
|
|
816
|
+
});
|
|
817
|
+
}
|
|
824
818
|
},
|
|
825
819
|
onConnected: () => {
|
|
826
820
|
streamConnected = true;
|
|
@@ -829,7 +823,7 @@ export async function main() {
|
|
|
829
823
|
},
|
|
830
824
|
onDisconnected: () => {
|
|
831
825
|
streamConnected = false;
|
|
832
|
-
|
|
826
|
+
runtimeState.clearAgentRuntime().catch(() => { });
|
|
833
827
|
console.error('[canon-codex] SSE disconnected');
|
|
834
828
|
},
|
|
835
829
|
onError: (error) => console.error(`[canon-codex] SSE error: ${error.message}`),
|
|
@@ -881,38 +875,55 @@ export async function main() {
|
|
|
881
875
|
knownConversationIds.add(conversation.id);
|
|
882
876
|
conversationCache.set(conversation.id, conversation);
|
|
883
877
|
clearStreaming(conversation.id);
|
|
884
|
-
clearSessionState(conversation.id
|
|
885
|
-
clearTurnState(conversation.id
|
|
878
|
+
runtimeState.clearSessionState(conversation.id).catch(() => { });
|
|
879
|
+
runtimeState.clearTurnState(conversation.id).catch(() => { });
|
|
886
880
|
}
|
|
887
881
|
for (const conversation of conversations) {
|
|
888
|
-
|
|
889
|
-
continue;
|
|
890
|
-
const latestPage = await client.getMessagesPage(conversation.id, 1);
|
|
891
|
-
const latestMessage = latestPage.messages[0];
|
|
892
|
-
if (!latestMessage || latestMessage.senderId === agentId)
|
|
893
|
-
continue;
|
|
894
|
-
const senderTurnState = latestMessage.senderType === 'ai_agent'
|
|
895
|
-
? await loadSenderRuntimeState(conversation.id, latestMessage.senderId)
|
|
896
|
-
: null;
|
|
897
|
-
const triggerDecision = shouldTriggerAgentTurn({
|
|
898
|
-
senderType: latestMessage.senderType ?? 'human',
|
|
899
|
-
metadata: latestMessage.metadata,
|
|
900
|
-
senderTurnState,
|
|
901
|
-
});
|
|
902
|
-
if (!triggerDecision.allow) {
|
|
903
|
-
console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Skipping startup recovery for suppressed message (${triggerDecision.reason})`);
|
|
904
|
-
continue;
|
|
905
|
-
}
|
|
906
|
-
console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Recovering latest inbound message on startup`);
|
|
907
|
-
await enqueueInboundMessage({
|
|
882
|
+
const cursor = loadRuntimeSessionState(runtimeId, {
|
|
908
883
|
conversationId: conversation.id,
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
884
|
+
baseCwd: workingDir,
|
|
885
|
+
})?.lastInboundMessageId;
|
|
886
|
+
const latestPage = await client.getMessagesPage(conversation.id, 25);
|
|
887
|
+
const inboundMessages = latestPage.messages
|
|
888
|
+
.filter((message) => message.senderId !== agentId)
|
|
889
|
+
.sort((a, b) => String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? '')));
|
|
890
|
+
const cursorIndex = cursor
|
|
891
|
+
? inboundMessages.findIndex((message) => message.id === cursor)
|
|
892
|
+
: -1;
|
|
893
|
+
const messagesToRecover = cursorIndex >= 0
|
|
894
|
+
? inboundMessages.slice(cursorIndex + 1)
|
|
895
|
+
: inboundMessages.slice(-1);
|
|
896
|
+
for (const latestMessage of messagesToRecover) {
|
|
897
|
+
const senderTurnState = latestMessage.senderType === 'ai_agent'
|
|
898
|
+
? await loadSenderRuntimeState(conversation.id, latestMessage.senderId)
|
|
899
|
+
: null;
|
|
900
|
+
const triggerDecision = shouldTriggerAgentTurn({
|
|
901
|
+
senderType: latestMessage.senderType ?? 'human',
|
|
902
|
+
metadata: latestMessage.metadata,
|
|
903
|
+
senderTurnState,
|
|
904
|
+
});
|
|
905
|
+
if (!triggerDecision.allow) {
|
|
906
|
+
console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Skipping startup recovery for suppressed message (${triggerDecision.reason})`);
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Recovering inbound message on startup`);
|
|
910
|
+
await enqueueInboundMessage({
|
|
911
|
+
conversationId: conversation.id,
|
|
912
|
+
message: latestMessage,
|
|
913
|
+
senderName: latestMessage.senderId,
|
|
914
|
+
isOwner: ownerId != null && latestMessage.senderId === ownerId,
|
|
915
|
+
behavior: latestPage.behavior,
|
|
916
|
+
workSessions: latestPage.workSessions,
|
|
917
|
+
hydratedPage: latestPage,
|
|
918
|
+
});
|
|
919
|
+
if (latestMessage.id) {
|
|
920
|
+
saveRuntimeSessionState(runtimeId, {
|
|
921
|
+
conversationId: conversation.id,
|
|
922
|
+
baseCwd: workingDir,
|
|
923
|
+
lastInboundMessageId: latestMessage.id,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
}
|
|
916
927
|
}
|
|
917
928
|
}
|
|
918
929
|
catch (error) {
|
|
@@ -1006,25 +1017,23 @@ export async function main() {
|
|
|
1006
1017
|
clearInterval(heartbeat);
|
|
1007
1018
|
clearInterval(idleCheck);
|
|
1008
1019
|
stream.stop();
|
|
1009
|
-
await
|
|
1020
|
+
await runtimeState.clearAgentRuntime().catch(() => { });
|
|
1010
1021
|
for (const session of [...sessions.values()]) {
|
|
1011
1022
|
await session.adapter.interrupt().catch(() => { });
|
|
1012
1023
|
closeSession(session.conversationId);
|
|
1013
1024
|
}
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
releaseLock(activeProfile);
|
|
1025
|
+
markLocalRuntimeStopped(runtimeId);
|
|
1026
|
+
(lockHandle ?? getActiveProfileLock())?.release();
|
|
1017
1027
|
process.exit(0);
|
|
1018
1028
|
};
|
|
1019
1029
|
process.on('SIGINT', shutdown);
|
|
1020
1030
|
process.on('SIGTERM', shutdown);
|
|
1031
|
+
process.on('SIGHUP', shutdown);
|
|
1021
1032
|
console.error('[canon-codex] Ready — sessions created on demand');
|
|
1022
1033
|
await new Promise(() => { });
|
|
1023
1034
|
}
|
|
1024
1035
|
runCli(import.meta.url, main, (error) => {
|
|
1025
1036
|
console.error('[canon-codex] Fatal error:', error);
|
|
1026
|
-
|
|
1027
|
-
if (activeProfile)
|
|
1028
|
-
releaseLock(activeProfile);
|
|
1037
|
+
getActiveProfileLock()?.release();
|
|
1029
1038
|
process.exit(1);
|
|
1030
1039
|
});
|
package/dist/register.js
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { setDefaultResultOrder } from 'node:dns';
|
|
3
|
-
import {
|
|
4
|
-
import { homedir } from 'node:os';
|
|
5
|
-
import { join } from 'node:path';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
6
4
|
import { parseArgs } from 'node:util';
|
|
7
|
-
import { registerAndWaitForApproval } from '@canonmsg/core';
|
|
5
|
+
import { ackRegistrationApproval, clearPendingRegistration, getOrCreatePendingRegistration, registerAndWaitForApproval, updatePendingRegistration, upsertAgentProfile, AGENTS_PATH, } from '@canonmsg/core';
|
|
8
6
|
import { runCli } from './cli-entry.js';
|
|
9
|
-
const CANON_DIR = join(homedir(), '.canon');
|
|
10
|
-
const AGENTS_PATH = join(CANON_DIR, 'agents.json');
|
|
11
7
|
export async function main() {
|
|
12
8
|
setDefaultResultOrder('ipv4first');
|
|
13
9
|
const { values } = parseArgs({
|
|
@@ -34,6 +30,7 @@ export async function main() {
|
|
|
34
30
|
// No existing profile state.
|
|
35
31
|
}
|
|
36
32
|
console.log(`Registering Codex agent "${values.name}" (profile: ${profileName})...`);
|
|
33
|
+
const pending = getOrCreatePendingRegistration(profileName, 'codex');
|
|
37
34
|
const result = await registerAndWaitForApproval({
|
|
38
35
|
name: values.name,
|
|
39
36
|
description: values.description,
|
|
@@ -42,8 +39,14 @@ export async function main() {
|
|
|
42
39
|
clientType: 'codex',
|
|
43
40
|
baseUrl: values['base-url'],
|
|
44
41
|
requestedAgentId: existingAgentId,
|
|
42
|
+
localRegistrationId: pending.localRegistrationId,
|
|
45
43
|
}, {
|
|
46
|
-
onSubmitted: (requestId) => {
|
|
44
|
+
onSubmitted: (requestId, pollToken) => {
|
|
45
|
+
updatePendingRegistration(profileName, {
|
|
46
|
+
requestId,
|
|
47
|
+
pollToken,
|
|
48
|
+
clientType: 'codex',
|
|
49
|
+
});
|
|
47
50
|
console.log(`Registration submitted (request ID: ${requestId}).`);
|
|
48
51
|
console.log('Waiting for approval in Canon app...');
|
|
49
52
|
},
|
|
@@ -54,24 +57,26 @@ export async function main() {
|
|
|
54
57
|
console.log('');
|
|
55
58
|
switch (result.status) {
|
|
56
59
|
case 'approved': {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
profiles = JSON.parse(readFileSync(AGENTS_PATH, 'utf-8'));
|
|
60
|
+
if (!result.apiKey || !result.agentId || !result.agentName) {
|
|
61
|
+
console.error('Approval completed but Canon did not return a usable API key. Run this command again to resume key pickup.');
|
|
62
|
+
process.exit(1);
|
|
61
63
|
}
|
|
62
|
-
|
|
63
|
-
// File does not exist yet.
|
|
64
|
-
}
|
|
65
|
-
profiles[profileName] = {
|
|
64
|
+
upsertAgentProfile(profileName, {
|
|
66
65
|
apiKey: result.apiKey,
|
|
67
66
|
agentId: result.agentId,
|
|
68
67
|
agentName: result.agentName,
|
|
69
68
|
registeredAt: new Date().toISOString(),
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
clientType: 'codex',
|
|
70
|
+
...(typeof values['base-url'] === 'string' ? { baseUrl: values['base-url'] } : {}),
|
|
71
|
+
});
|
|
72
|
+
if (result.requestId) {
|
|
73
|
+
await ackRegistrationApproval(values['base-url'], result.requestId, result.pollToken);
|
|
74
|
+
}
|
|
75
|
+
clearPendingRegistration(profileName);
|
|
72
76
|
console.log(`Approved! Agent: ${result.agentName} (${result.agentId})`);
|
|
73
77
|
console.log(`Saved as profile "${profileName}" in ~/.canon/agents.json`);
|
|
74
|
-
console.log('Start it with: canon-codex --cwd /path/to/project');
|
|
78
|
+
console.log('Start it with: CANON_AGENT=' + profileName + ' canon-codex --cwd /path/to/project');
|
|
79
|
+
console.log('Keep that terminal open. Closing it takes this local agent offline.');
|
|
75
80
|
break;
|
|
76
81
|
}
|
|
77
82
|
case 'rejected':
|
package/dist/session-store.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
export declare function
|
|
3
|
-
export declare function
|
|
1
|
+
import { type ExecutionEnvironmentMode } from '@canonmsg/core';
|
|
2
|
+
export declare function loadStoredThreadId(runtimeId: string | null, agentId: string, conversationId: string, baseCwd: string, executionMode?: ExecutionEnvironmentMode): string | null;
|
|
3
|
+
export declare function saveStoredThreadId(runtimeId: string | null, agentId: string, conversationId: string, baseCwd: string, threadId: string, executionMode?: ExecutionEnvironmentMode): void;
|
|
4
|
+
export declare function clearStoredThreadId(runtimeId: string | null, agentId: string, conversationId: string, baseCwd?: string, executionMode?: ExecutionEnvironmentMode): void;
|
package/dist/session-store.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { CANON_DIR } from '@canonmsg/core';
|
|
3
|
+
import { CANON_DIR, clearRuntimeSessionState, loadRuntimeSessionState, saveRuntimeSessionState, } from '@canonmsg/core';
|
|
4
4
|
const STORE_PATH = join(CANON_DIR, 'codex-sessions.json');
|
|
5
5
|
function loadStore() {
|
|
6
6
|
try {
|
|
@@ -14,24 +14,50 @@ function saveStore(store) {
|
|
|
14
14
|
mkdirSync(CANON_DIR, { recursive: true });
|
|
15
15
|
writeFileSync(STORE_PATH, JSON.stringify(store, null, 2));
|
|
16
16
|
}
|
|
17
|
-
export function loadStoredThreadId(agentId, conversationId,
|
|
17
|
+
export function loadStoredThreadId(runtimeId, agentId, conversationId, baseCwd, executionMode) {
|
|
18
|
+
if (runtimeId) {
|
|
19
|
+
const state = loadRuntimeSessionState(runtimeId, {
|
|
20
|
+
conversationId,
|
|
21
|
+
baseCwd,
|
|
22
|
+
executionMode,
|
|
23
|
+
});
|
|
24
|
+
if (state?.threadId)
|
|
25
|
+
return state.threadId;
|
|
26
|
+
}
|
|
18
27
|
const store = loadStore();
|
|
19
28
|
const record = store.agents[agentId]?.[conversationId];
|
|
20
|
-
if (!record || record.cwd !==
|
|
29
|
+
if (!record || record.cwd !== baseCwd)
|
|
21
30
|
return null;
|
|
22
31
|
return record.threadId;
|
|
23
32
|
}
|
|
24
|
-
export function saveStoredThreadId(agentId, conversationId,
|
|
33
|
+
export function saveStoredThreadId(runtimeId, agentId, conversationId, baseCwd, threadId, executionMode) {
|
|
34
|
+
if (runtimeId) {
|
|
35
|
+
saveRuntimeSessionState(runtimeId, {
|
|
36
|
+
conversationId,
|
|
37
|
+
baseCwd,
|
|
38
|
+
executionMode,
|
|
39
|
+
threadId,
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
25
43
|
const store = loadStore();
|
|
26
44
|
store.agents[agentId] ??= {};
|
|
27
45
|
store.agents[agentId][conversationId] = {
|
|
28
46
|
threadId,
|
|
29
|
-
cwd,
|
|
47
|
+
cwd: baseCwd,
|
|
30
48
|
updatedAt: new Date().toISOString(),
|
|
31
49
|
};
|
|
32
50
|
saveStore(store);
|
|
33
51
|
}
|
|
34
|
-
export function clearStoredThreadId(agentId, conversationId) {
|
|
52
|
+
export function clearStoredThreadId(runtimeId, agentId, conversationId, baseCwd, executionMode) {
|
|
53
|
+
if (runtimeId) {
|
|
54
|
+
clearRuntimeSessionState(runtimeId, {
|
|
55
|
+
conversationId,
|
|
56
|
+
baseCwd,
|
|
57
|
+
executionMode,
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
35
61
|
const store = loadStore();
|
|
36
62
|
if (!store.agents[agentId]?.[conversationId])
|
|
37
63
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/codex-plugin",
|
|
3
|
-
"version": "0.9.
|
|
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.
|
|
33
|
-
"@canonmsg/core": "^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"
|
package/dist/host-runtime.d.ts
DELETED
|
@@ -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 {};
|
package/dist/host-runtime.js
DELETED
|
@@ -1,265 +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.accessLevel)
|
|
72
|
-
parts.push(`access: ${card.accessLevel}`);
|
|
73
|
-
if (card.ownerName)
|
|
74
|
-
parts.push(`owner: ${card.ownerName}`);
|
|
75
|
-
if (card.about)
|
|
76
|
-
parts.push(`about: ${card.about}`);
|
|
77
|
-
const identity = `📇 Contact card: "${card.displayName}" (${parts.join(' · ')}).`;
|
|
78
|
-
const missingCapabilities = [
|
|
79
|
-
!HOST_ADMISSION_ACTION_CAPABILITIES.canStartDirectConversation
|
|
80
|
-
? 'start a direct conversation'
|
|
81
|
-
: null,
|
|
82
|
-
!HOST_ADMISSION_ACTION_CAPABILITIES.canSendContactRequest
|
|
83
|
-
? 'send a contact request'
|
|
84
|
-
: null,
|
|
85
|
-
!HOST_ADMISSION_ACTION_CAPABILITIES.canApprovePendingContactRequests
|
|
86
|
-
? 'approve pending requests'
|
|
87
|
-
: null,
|
|
88
|
-
!HOST_ADMISSION_ACTION_CAPABILITIES.canRejectPendingContactRequests
|
|
89
|
-
? 'reject pending requests'
|
|
90
|
-
: null,
|
|
91
|
-
].filter(Boolean).join(', ');
|
|
92
|
-
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}.`;
|
|
93
|
-
return `${identity}\n${hint}`;
|
|
94
|
-
}
|
|
95
|
-
function describeAttachment(attachment, materialized) {
|
|
96
|
-
if (attachment.kind === 'image') {
|
|
97
|
-
return '[Image attached]';
|
|
98
|
-
}
|
|
99
|
-
if (attachment.kind === 'audio') {
|
|
100
|
-
const durationMs = materialized?.durationMs ?? attachment.durationMs;
|
|
101
|
-
const duration = durationMs ? ` (${Math.round(durationMs / 1000)}s)` : '';
|
|
102
|
-
const ref = materialized?.path ? ` ${materialized.path}` : '';
|
|
103
|
-
return `[Voice message${duration}${ref}]`;
|
|
104
|
-
}
|
|
105
|
-
// file
|
|
106
|
-
const label = materialized?.fileName ?? attachment.fileName ?? 'File';
|
|
107
|
-
const ref = materialized?.path ? ` ${materialized.path}` : '';
|
|
108
|
-
return `[File: ${label}${ref}]`;
|
|
109
|
-
}
|
|
110
|
-
export function buildHydratedInboundContext(input) {
|
|
111
|
-
const history = buildParticipationHistorySnapshot(input.page?.messages ?? [], input.agentId);
|
|
112
|
-
return {
|
|
113
|
-
participantContext: {
|
|
114
|
-
conversationType: input.conversation?.type ?? 'unknown',
|
|
115
|
-
memberCount: input.conversation?.memberIds?.length ?? null,
|
|
116
|
-
senderType: input.message.senderType ?? 'human',
|
|
117
|
-
senderName: input.senderName,
|
|
118
|
-
isOwner: input.isOwner,
|
|
119
|
-
mentionedAgent: Array.isArray(input.message.mentions) && input.message.mentions.includes(input.agentId),
|
|
120
|
-
recentSenderTypes: history.recentSenderTypes,
|
|
121
|
-
recentHumanCount: history.recentHumanCount,
|
|
122
|
-
recentAgentCount: history.recentAgentCount,
|
|
123
|
-
consecutiveAgentTurns: history.consecutiveAgentTurns,
|
|
124
|
-
currentAgentStreakStartedByHuman: history.currentAgentStreakStartedByHuman,
|
|
125
|
-
},
|
|
126
|
-
behavior: input.page?.behavior ?? input.conversation?.behavior,
|
|
127
|
-
workSessions: input.page?.workSessions ?? [],
|
|
128
|
-
hydratedFromPage: input.page != null,
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
export async function publishHostAgentRuntime(agentId, clientType, runtime) {
|
|
132
|
-
await rtdbWrite(`/agent-runtime/${agentId}`, {
|
|
133
|
-
clientType,
|
|
134
|
-
hostMode: true,
|
|
135
|
-
...runtime,
|
|
136
|
-
updatedAt: { '.sv': 'timestamp' },
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
export async function publishHostSessionSnapshots(input) {
|
|
140
|
-
if (input.conversationIds.length === 0) {
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
await Promise.all(input.conversationIds.map(async (conversationId) => {
|
|
144
|
-
const persistedConfig = await loadHostSessionConfig({
|
|
145
|
-
conversationId,
|
|
146
|
-
agentId: input.agentId,
|
|
147
|
-
extraStringFields: ['permissionMode'],
|
|
148
|
-
});
|
|
149
|
-
const liveConfig = input.liveSessionConfigByConversation?.get(conversationId) ?? null;
|
|
150
|
-
const mergedConfig = {
|
|
151
|
-
...(persistedConfig ?? {}),
|
|
152
|
-
...(liveConfig ?? {}),
|
|
153
|
-
};
|
|
154
|
-
const snapshot = buildAgentSessionSnapshot({
|
|
155
|
-
conversationId,
|
|
156
|
-
agentId: input.agentId,
|
|
157
|
-
runtime: {
|
|
158
|
-
...input.runtime,
|
|
159
|
-
clientType: input.clientType,
|
|
160
|
-
hostMode: true,
|
|
161
|
-
},
|
|
162
|
-
sessionConfig: {
|
|
163
|
-
...(mergedConfig.model ? { model: mergedConfig.model } : {}),
|
|
164
|
-
...(mergedConfig.permissionMode ? { permissionMode: mergedConfig.permissionMode } : {}),
|
|
165
|
-
...(mergedConfig.effort ? { effort: mergedConfig.effort } : {}),
|
|
166
|
-
...(mergedConfig.runtimeControlValues
|
|
167
|
-
? { runtimeControlValues: mergedConfig.runtimeControlValues }
|
|
168
|
-
: {}),
|
|
169
|
-
...(mergedConfig.workspaceId ? { workspaceId: mergedConfig.workspaceId } : {}),
|
|
170
|
-
...(mergedConfig.executionMode ? { executionMode: mergedConfig.executionMode } : {}),
|
|
171
|
-
},
|
|
172
|
-
lastHeartbeatAt: undefined,
|
|
173
|
-
});
|
|
174
|
-
let executionBranch = liveConfig?.executionBranch ?? null;
|
|
175
|
-
if (!executionBranch && snapshot.executionMode === 'worktree' && snapshot.workspaceId) {
|
|
176
|
-
const workspace = input.workspaceOptions.find((option) => option.id === snapshot.workspaceId);
|
|
177
|
-
if (workspace) {
|
|
178
|
-
executionBranch = buildConversationWorktreeSpec({
|
|
179
|
-
agentId: input.agentId,
|
|
180
|
-
conversationId,
|
|
181
|
-
workspaceCwd: workspace.cwd,
|
|
182
|
-
}).branch;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
return patchAgentSessionSnapshot(conversationId, input.agentId, {
|
|
186
|
-
clientType: input.clientType,
|
|
187
|
-
hostMode: true,
|
|
188
|
-
model: snapshot.model ?? null,
|
|
189
|
-
permissionMode: snapshot.permissionMode ?? null,
|
|
190
|
-
effort: snapshot.effort ?? null,
|
|
191
|
-
runtimeControlValues: snapshot.runtimeControlValues ?? null,
|
|
192
|
-
workspaceId: snapshot.workspaceId ?? null,
|
|
193
|
-
executionMode: snapshot.executionMode ?? null,
|
|
194
|
-
executionBranch,
|
|
195
|
-
modelOptions: snapshot.modelOptions,
|
|
196
|
-
permissionModeOptions: snapshot.permissionModeOptions,
|
|
197
|
-
workspaceOptions: snapshot.workspaceOptions,
|
|
198
|
-
availableExecutionModes: snapshot.availableExecutionModes,
|
|
199
|
-
lastHeartbeatAt: { '.sv': 'timestamp' },
|
|
200
|
-
});
|
|
201
|
-
}));
|
|
202
|
-
}
|
|
203
|
-
export function readHostSessionConfig(raw, extraStringFields = []) {
|
|
204
|
-
const baseConfig = readSessionWorkspaceConfig(raw);
|
|
205
|
-
if (!raw || typeof raw !== 'object') {
|
|
206
|
-
return baseConfig;
|
|
207
|
-
}
|
|
208
|
-
const data = raw;
|
|
209
|
-
const extraConfig = Object.fromEntries(extraStringFields.flatMap((field) => {
|
|
210
|
-
const value = normalizeOptionalString(data[field]);
|
|
211
|
-
return value ? [[field, value]] : [];
|
|
212
|
-
}));
|
|
213
|
-
const runtimeControlValues = Object.fromEntries(Object.entries(data.runtimeControlValues && typeof data.runtimeControlValues === 'object'
|
|
214
|
-
? data.runtimeControlValues
|
|
215
|
-
: {}).flatMap(([key, value]) => {
|
|
216
|
-
const normalizedValue = normalizeOptionalString(value);
|
|
217
|
-
return normalizedValue ? [[key, normalizedValue]] : [];
|
|
218
|
-
}));
|
|
219
|
-
return {
|
|
220
|
-
...(baseConfig ?? {}),
|
|
221
|
-
...extraConfig,
|
|
222
|
-
...(Object.keys(runtimeControlValues).length > 0 ? { runtimeControlValues } : {}),
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
export async function loadHostSessionConfig(input) {
|
|
226
|
-
const raw = await rtdbRead(`/session-config/${input.conversationId}/${input.agentId}`);
|
|
227
|
-
return readHostSessionConfig(raw, input.extraStringFields);
|
|
228
|
-
}
|
|
229
|
-
export function resolveHostWorkspaceCwd(input) {
|
|
230
|
-
return resolveConfiguredWorkspaceCwd(input);
|
|
231
|
-
}
|
|
232
|
-
export function createConversationMetadataLoader(input) {
|
|
233
|
-
const cacheTtlMs = input.cacheTtlMs ?? 10_000;
|
|
234
|
-
let conversationCacheLoadedAt = 0;
|
|
235
|
-
async function refreshConversationCache(force = false) {
|
|
236
|
-
if (!force
|
|
237
|
-
&& input.conversationCache.size > 0
|
|
238
|
-
&& Date.now() - conversationCacheLoadedAt < cacheTtlMs) {
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
const conversations = await input.client.getConversations();
|
|
242
|
-
input.conversationCache.clear();
|
|
243
|
-
for (const conversation of conversations) {
|
|
244
|
-
input.conversationCache.set(conversation.id, conversation);
|
|
245
|
-
}
|
|
246
|
-
conversationCacheLoadedAt = Date.now();
|
|
247
|
-
}
|
|
248
|
-
async function getConversationMeta(conversationId) {
|
|
249
|
-
try {
|
|
250
|
-
await refreshConversationCache();
|
|
251
|
-
const cached = input.conversationCache.get(conversationId);
|
|
252
|
-
if (cached)
|
|
253
|
-
return cached;
|
|
254
|
-
await refreshConversationCache(true);
|
|
255
|
-
return input.conversationCache.get(conversationId) ?? null;
|
|
256
|
-
}
|
|
257
|
-
catch {
|
|
258
|
-
return input.conversationCache.get(conversationId) ?? null;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
return {
|
|
262
|
-
refreshConversationCache,
|
|
263
|
-
getConversationMeta,
|
|
264
|
-
};
|
|
265
|
-
}
|