@canonmsg/codex-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # Canon Plugin for Codex
2
+
3
+ Connect the local Codex CLI to [Canon](https://github.com/HeyBobChan/canon) so a Canon user can message your coding agent from the app.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ # Install
9
+ npm install -g @canonmsg/codex-plugin
10
+
11
+ # Register (approve in Canon when prompted)
12
+ canon-codex-register --name "My Codex" --description "My local coding agent" --phone "+15551234567"
13
+
14
+ # Run inside a project
15
+ canon-codex --cwd /path/to/project
16
+ ```
17
+
18
+ ## What v1 supports
19
+
20
+ - Canon messages routed into Codex turns
21
+ - One Codex thread per Canon conversation
22
+ - Resume by thread ID across turns
23
+ - RTDB session state and activity heartbeat
24
+ - Interrupt by terminating the active Codex turn
25
+ - Tool/running status surfaced while Codex is working
26
+
27
+ ## Current limitation
28
+
29
+ The stable `codex exec --json` surface exposes completed assistant messages and tool activity, but not token-by-token text deltas. v1 therefore reports live status and publishes the final reply when the turn completes, instead of true token streaming.
30
+
31
+ ## Working directory
32
+
33
+ ```bash
34
+ canon-codex --cwd /path/to/project
35
+ ```
36
+
37
+ Useful flags:
38
+
39
+ ```bash
40
+ canon-codex --cwd /path/to/project --model gpt-5.4 --sandbox workspace-write --ask-for-approval never
41
+ ```
42
+
43
+ ## Multiple agents
44
+
45
+ ```bash
46
+ canon-codex-register --name "Frontend" --description "React work" --phone "+1..." --profile frontend
47
+ CANON_AGENT=frontend canon-codex --cwd ~/projects/frontend
48
+ ```
49
+
50
+ ## Development
51
+
52
+ ```bash
53
+ cd packages/codex-plugin
54
+ npm install
55
+ npm run build
56
+ ```
@@ -0,0 +1,69 @@
1
+ export type CodexSandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access';
2
+ export type CodexApprovalPolicy = 'untrusted' | 'on-request' | 'never';
3
+ export type CodexEvent = {
4
+ type: 'thread.started';
5
+ threadId: string;
6
+ } | {
7
+ type: 'turn.started';
8
+ } | {
9
+ type: 'message';
10
+ text: string;
11
+ } | {
12
+ type: 'command.started';
13
+ command: string;
14
+ } | {
15
+ type: 'command.completed';
16
+ command: string;
17
+ output: string;
18
+ exitCode: number | null;
19
+ } | {
20
+ type: 'turn.completed';
21
+ usage?: {
22
+ input_tokens?: number;
23
+ cached_input_tokens?: number;
24
+ output_tokens?: number;
25
+ };
26
+ };
27
+ export interface CodexTurnResult {
28
+ threadId: string | null;
29
+ finalMessage: string | null;
30
+ exitCode: number | null;
31
+ interrupted: boolean;
32
+ errorText: string | null;
33
+ }
34
+ export declare class CodexConversationAdapter {
35
+ private readonly cwd;
36
+ private readonly codexBin;
37
+ private model;
38
+ private readonly sandbox;
39
+ private readonly approvalPolicy;
40
+ private readonly codexProfile;
41
+ private readonly addDirs;
42
+ private readonly configOverrides;
43
+ private readonly fullAuto;
44
+ private readonly bypassApprovalsAndSandbox;
45
+ private child;
46
+ private threadId;
47
+ private interruptTimer;
48
+ private interrupted;
49
+ constructor(opts: {
50
+ cwd: string;
51
+ threadId?: string | null;
52
+ codexBin?: string;
53
+ model?: string | null;
54
+ sandbox?: CodexSandboxMode | null;
55
+ approvalPolicy?: CodexApprovalPolicy | null;
56
+ codexProfile?: string | null;
57
+ addDirs?: string[];
58
+ configOverrides?: string[];
59
+ fullAuto?: boolean;
60
+ bypassApprovalsAndSandbox?: boolean;
61
+ });
62
+ getThreadId(): string | null;
63
+ setModel(model: string | null): void;
64
+ isRunning(): boolean;
65
+ interrupt(): Promise<void>;
66
+ runTurn(prompt: string, onEvent: (event: CodexEvent) => void, onLog?: (line: string) => void): Promise<CodexTurnResult>;
67
+ private buildArgs;
68
+ private clearActiveProcess;
69
+ }
@@ -0,0 +1,221 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline';
3
+ export class CodexConversationAdapter {
4
+ cwd;
5
+ codexBin;
6
+ model;
7
+ sandbox;
8
+ approvalPolicy;
9
+ codexProfile;
10
+ addDirs;
11
+ configOverrides;
12
+ fullAuto;
13
+ bypassApprovalsAndSandbox;
14
+ child = null;
15
+ threadId;
16
+ interruptTimer = null;
17
+ interrupted = false;
18
+ constructor(opts) {
19
+ this.cwd = opts.cwd;
20
+ this.threadId = opts.threadId ?? null;
21
+ this.codexBin = opts.codexBin ?? 'codex';
22
+ this.model = opts.model ?? null;
23
+ this.sandbox = opts.sandbox ?? null;
24
+ this.approvalPolicy = opts.approvalPolicy ?? null;
25
+ this.codexProfile = opts.codexProfile ?? null;
26
+ this.addDirs = opts.addDirs ?? [];
27
+ this.configOverrides = opts.configOverrides ?? [];
28
+ this.fullAuto = opts.fullAuto ?? false;
29
+ this.bypassApprovalsAndSandbox = opts.bypassApprovalsAndSandbox ?? false;
30
+ }
31
+ getThreadId() {
32
+ return this.threadId;
33
+ }
34
+ setModel(model) {
35
+ this.model = model;
36
+ }
37
+ isRunning() {
38
+ return this.child !== null;
39
+ }
40
+ async interrupt() {
41
+ if (!this.child)
42
+ return;
43
+ this.interrupted = true;
44
+ this.child.kill('SIGINT');
45
+ this.interruptTimer = setTimeout(() => {
46
+ if (this.child)
47
+ this.child.kill('SIGKILL');
48
+ }, 5_000);
49
+ }
50
+ async runTurn(prompt, onEvent, onLog) {
51
+ if (this.child) {
52
+ throw new Error('A Codex turn is already in progress for this conversation');
53
+ }
54
+ const args = this.buildArgs(prompt);
55
+ const child = spawn(this.codexBin, args, {
56
+ cwd: this.cwd,
57
+ stdio: ['ignore', 'pipe', 'pipe'],
58
+ });
59
+ this.child = child;
60
+ this.interrupted = false;
61
+ let latestMessage = null;
62
+ let lastErrorText = null;
63
+ const stdout = createInterface({ input: child.stdout });
64
+ const stderr = createInterface({ input: child.stderr });
65
+ stdout.on('line', (line) => {
66
+ const event = parseEventLine(line);
67
+ if (!event)
68
+ return;
69
+ switch (event.type) {
70
+ case 'thread.started':
71
+ this.threadId = event.thread_id;
72
+ onEvent({ type: 'thread.started', threadId: event.thread_id });
73
+ break;
74
+ case 'turn.started':
75
+ onEvent({ type: 'turn.started' });
76
+ break;
77
+ case 'item.started':
78
+ if (event.item?.type === 'command_execution') {
79
+ onEvent({
80
+ type: 'command.started',
81
+ command: String(event.item.command ?? ''),
82
+ });
83
+ }
84
+ break;
85
+ case 'item.completed':
86
+ if (event.item?.type === 'agent_message') {
87
+ latestMessage = normalizeMessageText(event.item.text);
88
+ if (latestMessage) {
89
+ onEvent({ type: 'message', text: latestMessage });
90
+ }
91
+ }
92
+ else if (event.item?.type === 'command_execution') {
93
+ onEvent({
94
+ type: 'command.completed',
95
+ command: String(event.item.command ?? ''),
96
+ output: String(event.item.aggregated_output ?? ''),
97
+ exitCode: typeof event.item.exit_code === 'number' ? event.item.exit_code : null,
98
+ });
99
+ }
100
+ break;
101
+ case 'turn.completed':
102
+ onEvent({ type: 'turn.completed', usage: event.usage });
103
+ break;
104
+ default:
105
+ break;
106
+ }
107
+ });
108
+ stderr.on('line', (line) => {
109
+ const trimmed = line.trim();
110
+ if (!trimmed)
111
+ return;
112
+ if (isIgnorableCodexLog(trimmed))
113
+ return;
114
+ lastErrorText = trimmed;
115
+ onLog?.(trimmed);
116
+ });
117
+ return await new Promise((resolve, reject) => {
118
+ child.on('error', (error) => {
119
+ this.clearActiveProcess();
120
+ reject(error);
121
+ });
122
+ child.on('close', (code) => {
123
+ stdout.close();
124
+ stderr.close();
125
+ const interrupted = this.interrupted || code === 130;
126
+ const result = {
127
+ threadId: this.threadId,
128
+ finalMessage: latestMessage,
129
+ exitCode: code,
130
+ interrupted,
131
+ errorText: interrupted ? null : lastErrorText,
132
+ };
133
+ this.clearActiveProcess();
134
+ resolve(result);
135
+ });
136
+ });
137
+ }
138
+ buildArgs(prompt) {
139
+ if (this.threadId) {
140
+ const args = ['exec', 'resume', '--json', '--skip-git-repo-check'];
141
+ if (this.model) {
142
+ args.push('-m', this.model);
143
+ }
144
+ if (this.codexProfile) {
145
+ args.push('-p', this.codexProfile);
146
+ }
147
+ for (const configOverride of this.configOverrides) {
148
+ args.push('-c', configOverride);
149
+ }
150
+ if (this.fullAuto) {
151
+ args.push('--full-auto');
152
+ }
153
+ if (this.bypassApprovalsAndSandbox) {
154
+ args.push('--dangerously-bypass-approvals-and-sandbox');
155
+ }
156
+ args.push(this.threadId, prompt);
157
+ return args;
158
+ }
159
+ const args = ['exec', '--json', '--color', 'never', '-C', this.cwd, '--skip-git-repo-check'];
160
+ if (this.model) {
161
+ args.push('-m', this.model);
162
+ }
163
+ if (this.sandbox) {
164
+ args.push('-s', this.sandbox);
165
+ }
166
+ if (this.approvalPolicy) {
167
+ args.push('-a', this.approvalPolicy);
168
+ }
169
+ if (this.codexProfile) {
170
+ args.push('-p', this.codexProfile);
171
+ }
172
+ for (const addDir of this.addDirs) {
173
+ args.push('--add-dir', addDir);
174
+ }
175
+ for (const configOverride of this.configOverrides) {
176
+ args.push('-c', configOverride);
177
+ }
178
+ if (this.fullAuto) {
179
+ args.push('--full-auto');
180
+ }
181
+ if (this.bypassApprovalsAndSandbox) {
182
+ args.push('--dangerously-bypass-approvals-and-sandbox');
183
+ }
184
+ args.push(prompt);
185
+ return args;
186
+ }
187
+ clearActiveProcess() {
188
+ if (this.interruptTimer) {
189
+ clearTimeout(this.interruptTimer);
190
+ this.interruptTimer = null;
191
+ }
192
+ this.child = null;
193
+ }
194
+ }
195
+ function parseEventLine(line) {
196
+ const trimmed = line.trim();
197
+ if (!trimmed.startsWith('{'))
198
+ return null;
199
+ try {
200
+ return JSON.parse(trimmed);
201
+ }
202
+ catch {
203
+ return null;
204
+ }
205
+ }
206
+ function normalizeMessageText(value) {
207
+ if (typeof value !== 'string')
208
+ return null;
209
+ const text = value.trim();
210
+ return text ? text : null;
211
+ }
212
+ function isIgnorableCodexLog(line) {
213
+ return [
214
+ 'Reading additional input from stdin...',
215
+ 'ignoring interface.defaultPrompt',
216
+ 'state db discrepancy during find_thread_path_by_id_str_in_subdir',
217
+ 'failed to open state db',
218
+ 'failed to initialize state runtime',
219
+ 'Failed to delete shell snapshot',
220
+ ].some((pattern) => line.includes(pattern));
221
+ }
package/dist/host.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/host.js ADDED
@@ -0,0 +1,501 @@
1
+ #!/usr/bin/env node
2
+ import { setDefaultResultOrder } from 'node:dns';
3
+ setDefaultResultOrder('ipv4first');
4
+ import { parseArgs } from 'node:util';
5
+ import { basename, resolve } from 'node:path';
6
+ import { CanonClient, CanonStream, clearSessionState, getActiveProfile, initRTDBAuth, releaseLock, resolveCanonAgent, rtdbRead, rtdbWrite, writeSessionState, } from '@canonmsg/core';
7
+ import { CodexConversationAdapter, } from './adapter.js';
8
+ import { clearStoredThreadId, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
9
+ const MAX_SESSIONS = 12;
10
+ const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
11
+ const HEARTBEAT_MS = 30_000;
12
+ const IDLE_CHECK_MS = 60_000;
13
+ const CONTROL_POLL_MS = 2_000;
14
+ let workingDir = process.cwd();
15
+ let workspaceOptions = [];
16
+ function normalizeString(value) {
17
+ if (typeof value !== 'string')
18
+ return undefined;
19
+ const trimmed = value.trim();
20
+ return trimmed ? trimmed : undefined;
21
+ }
22
+ function buildWorkspaceOptions(primaryCwd, configured) {
23
+ const uniqueDirs = Array.from(new Set([primaryCwd, ...configured].map((dir) => resolve(dir))));
24
+ const seenLabels = new Map();
25
+ return uniqueDirs.map((cwd, index) => {
26
+ const baseLabel = basename(cwd) || cwd;
27
+ const seenCount = (seenLabels.get(baseLabel) ?? 0) + 1;
28
+ seenLabels.set(baseLabel, seenCount);
29
+ return {
30
+ id: index === 0 ? 'default' : `workspace-${index + 1}`,
31
+ label: seenCount === 1 ? baseLabel : `${baseLabel} (${seenCount})`,
32
+ cwd,
33
+ };
34
+ });
35
+ }
36
+ async function publishAgentRuntime(agentId, runtime) {
37
+ await rtdbWrite(`/agent-runtime/${agentId}`, {
38
+ clientType: 'codex',
39
+ hostMode: true,
40
+ ...runtime,
41
+ updatedAt: { '.sv': 'timestamp' },
42
+ });
43
+ }
44
+ async function loadSessionConfig(conversationId, agentId) {
45
+ const raw = await rtdbRead(`/session-config/${conversationId}/${agentId}`);
46
+ if (!raw || typeof raw !== 'object')
47
+ return null;
48
+ const data = raw;
49
+ return {
50
+ workspaceId: normalizeString(data.workspaceId),
51
+ legacyCwd: normalizeString(data.cwd),
52
+ model: normalizeString(data.model),
53
+ };
54
+ }
55
+ function resolveWorkspaceCwd(config) {
56
+ if (config?.workspaceId) {
57
+ const workspace = workspaceOptions.find((option) => option.id === config.workspaceId);
58
+ if (workspace)
59
+ return workspace.cwd;
60
+ }
61
+ if (config?.legacyCwd) {
62
+ const workspace = workspaceOptions.find((option) => option.cwd === config.legacyCwd);
63
+ if (workspace)
64
+ return workspace.cwd;
65
+ }
66
+ return workspaceOptions[0]?.cwd ?? workingDir;
67
+ }
68
+ function toPublicWorkspaceOptions() {
69
+ return workspaceOptions.map(({ id, label }) => ({ id, label }));
70
+ }
71
+ function buildCanonPrompt(input) {
72
+ const ownerLine = input.isOwner
73
+ ? 'The sender is the verified human owner of this Canon agent.'
74
+ : 'The sender is a Canon user in this conversation.';
75
+ return [
76
+ 'You are connected to Canon messaging through a Codex host wrapper.',
77
+ 'Reply naturally to the Canon user.',
78
+ 'Only the last assistant message from this turn will be delivered as the permanent Canon reply.',
79
+ 'Short intermediate assistant messages may be shown as ephemeral status while you work.',
80
+ ownerLine,
81
+ `Conversation ID: ${input.conversationId}`,
82
+ `Sender: ${input.senderName}`,
83
+ '',
84
+ 'New Canon message:',
85
+ input.content,
86
+ ].join('\n');
87
+ }
88
+ function renderInboundContent(message) {
89
+ let content = message.text || '';
90
+ if (message.contentType === 'audio' && message.audioUrl) {
91
+ const duration = message.audioDurationMs ? ` (${Math.round(message.audioDurationMs / 1000)}s)` : '';
92
+ content = content
93
+ ? `[Voice message${duration}: ${message.audioUrl}]\n${content}`
94
+ : `[Voice message${duration}: ${message.audioUrl}]`;
95
+ }
96
+ else if (message.contentType === 'image' && message.imageUrl) {
97
+ content = content
98
+ ? `[Image: ${message.imageUrl}]\n${content}`
99
+ : `[Image: ${message.imageUrl}]`;
100
+ }
101
+ return content || '[Empty message]';
102
+ }
103
+ function summarizeCommand(command) {
104
+ const trimmed = command.trim();
105
+ if (!trimmed)
106
+ return 'Running a command';
107
+ const shortened = trimmed.length > 140 ? `${trimmed.slice(0, 137)}...` : trimmed;
108
+ return `Running: ${shortened}`;
109
+ }
110
+ async function main() {
111
+ const { values: args } = parseArgs({
112
+ options: {
113
+ cwd: { type: 'string' },
114
+ model: { type: 'string' },
115
+ sandbox: { type: 'string' },
116
+ 'ask-for-approval': { type: 'string' },
117
+ 'codex-profile': { type: 'string' },
118
+ 'add-dir': { type: 'string', multiple: true },
119
+ workspace: { type: 'string', multiple: true },
120
+ config: { type: 'string', multiple: true },
121
+ 'codex-bin': { type: 'string' },
122
+ 'full-auto': { type: 'boolean' },
123
+ 'dangerously-bypass-approvals-and-sandbox': { type: 'boolean' },
124
+ },
125
+ strict: true,
126
+ });
127
+ workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
128
+ workspaceOptions = buildWorkspaceOptions(workingDir, args.workspace ?? []);
129
+ const { apiKey, agentId: profileAgentId, profile } = resolveCanonAgent({ logPrefix: '[canon-codex]' });
130
+ console.error(`[canon-codex] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
131
+ const client = new CanonClient(apiKey);
132
+ initRTDBAuth(client);
133
+ let agentId;
134
+ let ownerId = null;
135
+ try {
136
+ const ctx = await client.getAgentMe();
137
+ agentId = ctx.agentId;
138
+ ownerId = ctx.ownerId;
139
+ console.error(`[canon-codex] Connected as ${ctx.displayName || agentId}`);
140
+ }
141
+ catch {
142
+ if (profileAgentId) {
143
+ agentId = profileAgentId;
144
+ }
145
+ else {
146
+ const auth = await client.getAuthToken();
147
+ agentId = auth.agentId;
148
+ }
149
+ console.error(`[canon-codex] Authenticated as ${agentId}`);
150
+ }
151
+ const sessions = new Map();
152
+ const pendingSessionCreations = new Map();
153
+ function writeState(session) {
154
+ writeSessionState(session.conversationId, agentId, {
155
+ model: session.state.model,
156
+ cwd: session.cwd,
157
+ hostMode: true,
158
+ clientType: 'codex',
159
+ state: session.state.state,
160
+ isActive: true,
161
+ }).catch(() => { });
162
+ }
163
+ function clearStreaming(conversationId) {
164
+ rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
165
+ }
166
+ function closeSession(conversationId) {
167
+ const session = sessions.get(conversationId);
168
+ if (!session)
169
+ return;
170
+ session.closed = true;
171
+ clearStreaming(conversationId);
172
+ clearSessionState(conversationId, agentId).catch(() => { });
173
+ client.setTyping(conversationId, false).catch(() => { });
174
+ sessions.delete(conversationId);
175
+ }
176
+ function evictOldestIdle() {
177
+ let oldest = null;
178
+ for (const session of sessions.values()) {
179
+ if (session.running)
180
+ continue;
181
+ if (!oldest || session.lastActivity < oldest.lastActivity)
182
+ oldest = session;
183
+ }
184
+ if (oldest) {
185
+ console.error(`[canon-codex] [${oldest.conversationId.slice(0, 8)}] Evicting idle session`);
186
+ closeSession(oldest.conversationId);
187
+ }
188
+ }
189
+ async function getOrCreateSession(conversationId) {
190
+ const existing = sessions.get(conversationId);
191
+ if (existing && !existing.closed) {
192
+ existing.lastActivity = Date.now();
193
+ return existing;
194
+ }
195
+ const pending = pendingSessionCreations.get(conversationId);
196
+ if (pending)
197
+ return pending;
198
+ if (sessions.size >= MAX_SESSIONS) {
199
+ evictOldestIdle();
200
+ }
201
+ const creation = (async () => {
202
+ const config = await loadSessionConfig(conversationId, agentId);
203
+ const sessionCwd = resolveWorkspaceCwd(config);
204
+ const sessionModel = config?.model ?? (typeof args.model === 'string' ? args.model : undefined);
205
+ const storedThreadId = loadStoredThreadId(agentId, conversationId, sessionCwd);
206
+ const session = {
207
+ conversationId,
208
+ cwd: sessionCwd,
209
+ adapter: new CodexConversationAdapter({
210
+ cwd: sessionCwd,
211
+ threadId: storedThreadId,
212
+ codexBin: typeof args['codex-bin'] === 'string' ? args['codex-bin'] : 'codex',
213
+ model: sessionModel ?? null,
214
+ sandbox: (typeof args.sandbox === 'string' ? args.sandbox : null),
215
+ approvalPolicy: (typeof args['ask-for-approval'] === 'string'
216
+ ? args['ask-for-approval']
217
+ : null),
218
+ codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
219
+ addDirs: args['add-dir'] ?? [],
220
+ configOverrides: args.config ?? [],
221
+ fullAuto: Boolean(args['full-auto']),
222
+ bypassApprovalsAndSandbox: Boolean(args['dangerously-bypass-approvals-and-sandbox']),
223
+ }),
224
+ queue: [],
225
+ running: false,
226
+ state: {
227
+ model: sessionModel,
228
+ state: 'idle',
229
+ },
230
+ lastActivity: Date.now(),
231
+ closed: false,
232
+ };
233
+ sessions.set(conversationId, session);
234
+ writeState(session);
235
+ return session;
236
+ })();
237
+ pendingSessionCreations.set(conversationId, creation);
238
+ try {
239
+ return await creation;
240
+ }
241
+ finally {
242
+ pendingSessionCreations.delete(conversationId);
243
+ }
244
+ }
245
+ function enqueuePrompt(session, prompt) {
246
+ session.queue.push(prompt);
247
+ session.lastActivity = Date.now();
248
+ void runNextTurn(session);
249
+ }
250
+ async function enqueueInboundMessage(input) {
251
+ const content = renderInboundContent(input.message);
252
+ console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}"`);
253
+ const session = await getOrCreateSession(input.conversationId);
254
+ enqueuePrompt(session, buildCanonPrompt({
255
+ content,
256
+ conversationId: input.conversationId,
257
+ isOwner: input.isOwner,
258
+ senderName: input.senderName,
259
+ }));
260
+ }
261
+ async function runNextTurn(session) {
262
+ if (session.running || session.closed)
263
+ return;
264
+ const prompt = session.queue.shift();
265
+ if (!prompt)
266
+ return;
267
+ session.running = true;
268
+ session.state.state = 'running';
269
+ session.lastActivity = Date.now();
270
+ writeState(session);
271
+ client.setTyping(session.conversationId, true, 'thinking').catch(() => { });
272
+ rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
273
+ text: 'Thinking…',
274
+ status: 'thinking',
275
+ updatedAt: { '.sv': 'timestamp' },
276
+ }).catch(() => { });
277
+ try {
278
+ const result = await session.adapter.runTurn(prompt, (event) => {
279
+ session.lastActivity = Date.now();
280
+ if (event.type === 'thread.started') {
281
+ saveStoredThreadId(agentId, session.conversationId, session.cwd, event.threadId);
282
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Thread ${event.threadId}`);
283
+ return;
284
+ }
285
+ if (event.type === 'message') {
286
+ rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
287
+ text: event.text,
288
+ status: 'streaming',
289
+ updatedAt: { '.sv': 'timestamp' },
290
+ }).catch(() => { });
291
+ return;
292
+ }
293
+ if (event.type === 'command.started') {
294
+ client.setTyping(session.conversationId, false).catch(() => { });
295
+ rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
296
+ text: summarizeCommand(event.command),
297
+ status: 'tool',
298
+ updatedAt: { '.sv': 'timestamp' },
299
+ }).catch(() => { });
300
+ return;
301
+ }
302
+ if (event.type === 'turn.completed') {
303
+ writeState(session);
304
+ }
305
+ }, (line) => {
306
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] ${line}`);
307
+ });
308
+ if (result.threadId) {
309
+ saveStoredThreadId(agentId, session.conversationId, session.cwd, result.threadId);
310
+ }
311
+ clearStreaming(session.conversationId);
312
+ client.setTyping(session.conversationId, false).catch(() => { });
313
+ if (!result.interrupted && result.finalMessage) {
314
+ await client.sendMessage(session.conversationId, result.finalMessage);
315
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${result.finalMessage.length} chars)`);
316
+ }
317
+ else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
318
+ if (result.errorText) {
319
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
320
+ }
321
+ await client.sendMessage(session.conversationId, 'The Codex session stopped unexpectedly before sending a final reply.');
322
+ }
323
+ else if (result.interrupted) {
324
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn interrupted`);
325
+ }
326
+ }
327
+ catch (error) {
328
+ clearStreaming(session.conversationId);
329
+ client.setTyping(session.conversationId, false).catch(() => { });
330
+ await client.sendMessage(session.conversationId, `The Codex host failed to start a turn: ${error instanceof Error ? error.message : String(error)}`).catch(() => { });
331
+ clearStoredThreadId(agentId, session.conversationId);
332
+ console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
333
+ }
334
+ finally {
335
+ session.running = false;
336
+ session.state.state = 'idle';
337
+ session.lastActivity = Date.now();
338
+ writeState(session);
339
+ if (session.queue.length > 0) {
340
+ void runNextTurn(session);
341
+ }
342
+ }
343
+ }
344
+ const stream = new CanonStream({
345
+ apiKey,
346
+ agentId,
347
+ handler: {
348
+ onMessage: (payload) => {
349
+ const message = payload.message;
350
+ if (message.senderId === agentId)
351
+ return;
352
+ void enqueueInboundMessage({
353
+ conversationId: payload.conversationId,
354
+ message,
355
+ senderName: message.senderName || message.senderId,
356
+ isOwner: Boolean(message.isOwner),
357
+ });
358
+ },
359
+ onConnected: () => console.error('[canon-codex] SSE connected'),
360
+ onDisconnected: () => console.error('[canon-codex] SSE disconnected'),
361
+ onError: (error) => console.error(`[canon-codex] SSE error: ${error.message}`),
362
+ },
363
+ });
364
+ try {
365
+ await publishAgentRuntime(agentId, {
366
+ defaultWorkspaceId: workspaceOptions[0]?.id,
367
+ ...(typeof args.model === 'string' ? { defaultModel: args.model } : {}),
368
+ availableWorkspaces: toPublicWorkspaceOptions(),
369
+ });
370
+ }
371
+ catch (error) {
372
+ console.error('[canon-codex] Failed to publish agent runtime:', error);
373
+ }
374
+ try {
375
+ const conversations = await client.getConversations();
376
+ for (const conversation of conversations) {
377
+ clearStreaming(conversation.id);
378
+ clearSessionState(conversation.id, agentId).catch(() => { });
379
+ }
380
+ for (const conversation of conversations) {
381
+ if (!conversation.lastMessage || conversation.lastMessage.senderId === agentId)
382
+ continue;
383
+ const latestMessages = await client.getMessages(conversation.id, 1);
384
+ const latestMessage = latestMessages[0];
385
+ if (!latestMessage || latestMessage.senderId === agentId)
386
+ continue;
387
+ console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Recovering latest inbound message on startup`);
388
+ await enqueueInboundMessage({
389
+ conversationId: conversation.id,
390
+ message: latestMessage,
391
+ senderName: latestMessage.senderId,
392
+ isOwner: ownerId != null && latestMessage.senderId === ownerId,
393
+ });
394
+ }
395
+ }
396
+ catch (error) {
397
+ console.error('[canon-codex] Failed to load startup conversations:', error);
398
+ }
399
+ await stream.start().catch((error) => {
400
+ console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
401
+ });
402
+ let controlStopped = false;
403
+ const lastSeenControl = new Map();
404
+ const lastSeenSignal = new Map();
405
+ const pollControl = async () => {
406
+ while (!controlStopped) {
407
+ for (const conversationId of [...sessions.keys()]) {
408
+ try {
409
+ const controlRaw = await rtdbRead(`/control/${conversationId}/${agentId}/session`);
410
+ if (controlRaw && typeof controlRaw === 'object') {
411
+ const control = controlRaw;
412
+ const timestamp = control.updatedAt ?? 0;
413
+ if (timestamp > (lastSeenControl.get(conversationId) ?? 0)) {
414
+ lastSeenControl.set(conversationId, timestamp);
415
+ const session = sessions.get(conversationId);
416
+ if (session && !session.closed) {
417
+ if (control.model && control.model !== session.state.model) {
418
+ session.adapter.setModel(control.model);
419
+ session.state.model = control.model;
420
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Model set for next turn -> ${control.model}`);
421
+ writeState(session);
422
+ }
423
+ if (control.permissionMode) {
424
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] permissionMode control is not mapped yet (${control.permissionMode})`);
425
+ }
426
+ if (control.effort) {
427
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] effort control is not mapped yet (${control.effort})`);
428
+ }
429
+ }
430
+ }
431
+ }
432
+ const raw = await rtdbRead(`/control/${conversationId}/${agentId}/signal`);
433
+ if (!raw || typeof raw !== 'object')
434
+ continue;
435
+ const signal = raw;
436
+ const timestamp = signal.updatedAt ?? 0;
437
+ if (signal.type !== 'interrupt' || timestamp <= (lastSeenSignal.get(conversationId) ?? 0)) {
438
+ continue;
439
+ }
440
+ lastSeenSignal.set(conversationId, timestamp);
441
+ const session = sessions.get(conversationId);
442
+ if (!session || session.closed)
443
+ continue;
444
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Interrupt signal`);
445
+ await session.adapter.interrupt();
446
+ session.queue.length = 0;
447
+ clearStreaming(conversationId);
448
+ client.setTyping(conversationId, false).catch(() => { });
449
+ }
450
+ catch {
451
+ // Ignore transient RTDB failures.
452
+ }
453
+ }
454
+ await new Promise((resolve) => setTimeout(resolve, CONTROL_POLL_MS));
455
+ }
456
+ };
457
+ void pollControl();
458
+ const heartbeat = setInterval(() => {
459
+ for (const session of sessions.values()) {
460
+ writeState(session);
461
+ }
462
+ }, HEARTBEAT_MS);
463
+ const idleCheck = setInterval(() => {
464
+ const now = Date.now();
465
+ for (const conversationId of [...sessions.keys()]) {
466
+ const session = sessions.get(conversationId);
467
+ if (!session || session.running)
468
+ continue;
469
+ if (now - session.lastActivity > IDLE_TIMEOUT_MS) {
470
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Idle timeout`);
471
+ closeSession(conversationId);
472
+ }
473
+ }
474
+ }, IDLE_CHECK_MS);
475
+ const shutdown = async () => {
476
+ console.error('[canon-codex] Shutting down...');
477
+ controlStopped = true;
478
+ clearInterval(heartbeat);
479
+ clearInterval(idleCheck);
480
+ stream.stop();
481
+ for (const session of [...sessions.values()]) {
482
+ await session.adapter.interrupt().catch(() => { });
483
+ closeSession(session.conversationId);
484
+ }
485
+ const activeProfile = getActiveProfile();
486
+ if (activeProfile)
487
+ releaseLock(activeProfile);
488
+ process.exit(0);
489
+ };
490
+ process.on('SIGINT', shutdown);
491
+ process.on('SIGTERM', shutdown);
492
+ console.error('[canon-codex] Ready — sessions created on demand');
493
+ await new Promise(() => { });
494
+ }
495
+ main().catch((error) => {
496
+ console.error('[canon-codex] Fatal error:', error);
497
+ const activeProfile = getActiveProfile();
498
+ if (activeProfile)
499
+ releaseLock(activeProfile);
500
+ process.exit(1);
501
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ import { setDefaultResultOrder } from 'node:dns';
3
+ setDefaultResultOrder('ipv4first');
4
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { parseArgs } from 'node:util';
8
+ import { registerAndWaitForApproval } from '@canonmsg/core';
9
+ const CANON_DIR = join(homedir(), '.canon');
10
+ const AGENTS_PATH = join(CANON_DIR, 'agents.json');
11
+ const { values } = parseArgs({
12
+ options: {
13
+ name: { type: 'string' },
14
+ description: { type: 'string' },
15
+ phone: { type: 'string' },
16
+ profile: { type: 'string' },
17
+ 'base-url': { type: 'string' },
18
+ },
19
+ strict: true,
20
+ });
21
+ if (!values.name || !values.description || !values.phone) {
22
+ console.error('Usage: canon-codex-register --name "Agent Name" --description "Description" --phone "+15551234567" [--profile "my-agent"]');
23
+ process.exit(1);
24
+ }
25
+ const profileName = values.profile || values.name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
26
+ console.log(`Registering Codex agent "${values.name}" (profile: ${profileName})...`);
27
+ const result = await registerAndWaitForApproval({
28
+ name: values.name,
29
+ description: values.description,
30
+ ownerPhone: values.phone,
31
+ developerInfo: 'Codex host plugin',
32
+ clientType: 'codex',
33
+ baseUrl: values['base-url'],
34
+ }, {
35
+ onSubmitted: (requestId) => {
36
+ console.log(`Registration submitted (request ID: ${requestId}).`);
37
+ console.log('Waiting for approval in Canon app...');
38
+ },
39
+ onPollUpdate: () => {
40
+ process.stdout.write('.');
41
+ },
42
+ });
43
+ console.log('');
44
+ switch (result.status) {
45
+ case 'approved': {
46
+ mkdirSync(CANON_DIR, { recursive: true });
47
+ let profiles = {};
48
+ try {
49
+ profiles = JSON.parse(readFileSync(AGENTS_PATH, 'utf-8'));
50
+ }
51
+ catch {
52
+ // File does not exist yet.
53
+ }
54
+ profiles[profileName] = {
55
+ apiKey: result.apiKey,
56
+ agentId: result.agentId,
57
+ agentName: result.agentName,
58
+ registeredAt: new Date().toISOString(),
59
+ };
60
+ writeFileSync(AGENTS_PATH, JSON.stringify(profiles, null, 2));
61
+ console.log(`Approved! Agent: ${result.agentName} (${result.agentId})`);
62
+ console.log(`Saved as profile "${profileName}" in ~/.canon/agents.json`);
63
+ console.log('Start it with: canon-codex --cwd /path/to/project');
64
+ break;
65
+ }
66
+ case 'rejected':
67
+ console.log('Registration was rejected.');
68
+ process.exit(1);
69
+ break;
70
+ case 'timeout':
71
+ console.log('Registration timed out (5 minutes). Try again later.');
72
+ process.exit(1);
73
+ break;
74
+ }
@@ -0,0 +1,3 @@
1
+ export declare function loadStoredThreadId(agentId: string, conversationId: string, cwd: string): string | null;
2
+ export declare function saveStoredThreadId(agentId: string, conversationId: string, cwd: string, threadId: string): void;
3
+ export declare function clearStoredThreadId(agentId: string, conversationId: string): void;
@@ -0,0 +1,43 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { CANON_DIR } from '@canonmsg/core';
4
+ const STORE_PATH = join(CANON_DIR, 'codex-sessions.json');
5
+ function loadStore() {
6
+ try {
7
+ return JSON.parse(readFileSync(STORE_PATH, 'utf-8'));
8
+ }
9
+ catch {
10
+ return { agents: {} };
11
+ }
12
+ }
13
+ function saveStore(store) {
14
+ mkdirSync(CANON_DIR, { recursive: true });
15
+ writeFileSync(STORE_PATH, JSON.stringify(store, null, 2));
16
+ }
17
+ export function loadStoredThreadId(agentId, conversationId, cwd) {
18
+ const store = loadStore();
19
+ const record = store.agents[agentId]?.[conversationId];
20
+ if (!record || record.cwd !== cwd)
21
+ return null;
22
+ return record.threadId;
23
+ }
24
+ export function saveStoredThreadId(agentId, conversationId, cwd, threadId) {
25
+ const store = loadStore();
26
+ store.agents[agentId] ??= {};
27
+ store.agents[agentId][conversationId] = {
28
+ threadId,
29
+ cwd,
30
+ updatedAt: new Date().toISOString(),
31
+ };
32
+ saveStore(store);
33
+ }
34
+ export function clearStoredThreadId(agentId, conversationId) {
35
+ const store = loadStore();
36
+ if (!store.agents[agentId]?.[conversationId])
37
+ return;
38
+ delete store.agents[agentId][conversationId];
39
+ if (Object.keys(store.agents[agentId]).length === 0) {
40
+ delete store.agents[agentId];
41
+ }
42
+ saveStore(store);
43
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/setup.js ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process';
3
+ console.log('Canon Codex Plugin Setup');
4
+ console.log('========================\n');
5
+ const version = spawnSync('codex', ['--version'], { encoding: 'utf-8' });
6
+ if (version.status === 0) {
7
+ console.log(`Detected Codex CLI: ${version.stdout.trim()}\n`);
8
+ }
9
+ else {
10
+ console.log('Codex CLI was not detected on PATH.');
11
+ console.log('Install Codex first, then rerun this setup.\n');
12
+ }
13
+ console.log('Next steps:');
14
+ console.log(' 1. Register your agent');
15
+ console.log(' canon-codex-register --name "My Codex" --description "My local coding agent" --phone "+15551234567"');
16
+ console.log('');
17
+ console.log(' 2. Start the host in a project directory');
18
+ console.log(' canon-codex --cwd /path/to/project');
19
+ console.log('');
20
+ console.log('Optional flags:');
21
+ console.log(' --model gpt-5.4');
22
+ console.log(' --sandbox workspace-write');
23
+ console.log(' --ask-for-approval never');
24
+ console.log(' --full-auto');
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@canonmsg/codex-plugin",
3
+ "version": "0.1.0",
4
+ "description": "Canon host integration for Codex CLI",
5
+ "type": "module",
6
+ "main": "dist/host.js",
7
+ "types": "dist/host.d.ts",
8
+ "bin": {
9
+ "canon-codex": "dist/host.js",
10
+ "canon-codex-register": "dist/register.js",
11
+ "canon-codex-setup": "dist/setup.js"
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "dev": "tsc --watch",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "dependencies": {
22
+ "@canonmsg/core": "^0.2.2"
23
+ },
24
+ "engines": {
25
+ "node": ">=18.0.0"
26
+ },
27
+ "keywords": [
28
+ "canon",
29
+ "codex",
30
+ "coding-agent",
31
+ "messaging",
32
+ "host-mode"
33
+ ],
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/HeyBobChan/canon",
37
+ "directory": "packages/codex-plugin"
38
+ },
39
+ "homepage": "https://github.com/HeyBobChan/canon/tree/main/packages/codex-plugin",
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^22.0.0",
45
+ "typescript": "~5.7.0"
46
+ },
47
+ "license": "MIT"
48
+ }