@canonmsg/codex-plugin 0.29.3 → 0.30.1
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 +151 -1
- package/dist/adapter.d.ts +2 -0
- package/dist/app-server-adapter.d.ts +5 -0
- package/dist/app-server-adapter.js +26 -7
- package/dist/attach-options.d.ts +21 -0
- package/dist/attach-options.js +71 -0
- package/dist/attach.d.ts +2 -0
- package/dist/attach.js +101 -0
- package/dist/attached-session-adapter.d.ts +112 -0
- package/dist/attached-session-adapter.js +769 -0
- package/dist/host.d.ts +63 -3
- package/dist/host.js +42 -10
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/setup.js +5 -0
- package/dist/work-session-cli.d.ts +2 -0
- package/dist/work-session-cli.js +75 -0
- package/dist/work-session-options.d.ts +33 -0
- package/dist/work-session-options.js +100 -0
- package/dist/work-session-provider.d.ts +40 -0
- package/dist/work-session-provider.js +234 -0
- package/package.json +14 -13
- package/scripts/smoke-attach.mjs +222 -0
- package/scripts/smoke-work-sessions.mjs +218 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Canon Plugin for Codex
|
|
2
2
|
|
|
3
|
-
Connect the local Codex CLI to [Canon](https://
|
|
3
|
+
Connect the local Codex CLI to [Canon](https://canonmail.com/agents) so a Canon user can message your coding agent from the app.
|
|
4
4
|
|
|
5
5
|
The plugin uses the local user's existing Codex authentication by default. That means Canon follows whatever plan or login mode the user has configured in Codex itself, instead of asking for a separate Canon-side OpenAI credential.
|
|
6
6
|
|
|
@@ -46,6 +46,156 @@ Public docs: <https://canonmail.com/agents/integrations>. Coding-host concepts:
|
|
|
46
46
|
|
|
47
47
|
You do not need a git repo for host mode. Any readable working directory is valid.
|
|
48
48
|
|
|
49
|
+
## Start or add work sessions from Canon (experimental)
|
|
50
|
+
|
|
51
|
+
One shared Codex server and one Canon host can serve several conversations under
|
|
52
|
+
the same Canon account. In Canon, choose **Start or add work session**, then
|
|
53
|
+
**New session** to choose a permitted project, model, reasoning effort, permissions,
|
|
54
|
+
and execution mode, or **Existing session** to select a loaded native thread.
|
|
55
|
+
The existing thread keeps its native settings. Each native thread has one Canon
|
|
56
|
+
conversation audience at a time.
|
|
57
|
+
|
|
58
|
+
Register the account once using the quick start above. Stop any existing
|
|
59
|
+
`canon-codex` or `canon-codex-attach` process for that account, then run:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Terminal 1: explicitly start the native shared server (Codex 0.154 or newer).
|
|
63
|
+
cd /path/to/project
|
|
64
|
+
codex app-server --listen ws://127.0.0.1:4500
|
|
65
|
+
|
|
66
|
+
# Terminal 2: publish the permitted projects and native model catalog to Canon.
|
|
67
|
+
CANON_AGENT=my-codex canon-codex \
|
|
68
|
+
--shared-server ws://127.0.0.1:4500 \
|
|
69
|
+
--cwd /path/to/project \
|
|
70
|
+
--workspace-root /path/to/approved-projects
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The catalog is private to the account owner and contains project labels and choice
|
|
74
|
+
IDs, not local paths or conversation transcripts. `--cwd`, repeated `--workspace`,
|
|
75
|
+
and repeated `--workspace-root` define projects that Canon may use for creation.
|
|
76
|
+
Choose **Project folder** for a non-Git project; the default **Git worktree** option
|
|
77
|
+
requires successful isolation and fails if the worktree cannot be created.
|
|
78
|
+
Detachment never deletes a native worktree.
|
|
79
|
+
|
|
80
|
+
New sessions default to **Workspace write** with Codex's `on-request` approval
|
|
81
|
+
policy. Command/file/permission approvals and supported questions are mirrored to
|
|
82
|
+
the account owner in Canon, so the first Canon turn works before a native UI is
|
|
83
|
+
open. A native client can answer the same request first; that cancels its Canon
|
|
84
|
+
mirror. Unsupported native requests require attention in a native client.
|
|
85
|
+
This mode does not enable unrestricted execution or install Canon dynamic tools.
|
|
86
|
+
|
|
87
|
+
Local policy can narrow the choices. For example, a host that only offers read-only
|
|
88
|
+
access in the selected project folders can use:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
CANON_AGENT=my-codex canon-codex \
|
|
92
|
+
--shared-server ws://127.0.0.1:4500 --cwd /path/to/project \
|
|
93
|
+
--permission-mode readonly --default-permission-mode readonly \
|
|
94
|
+
--execution-mode locked --default-execution-mode locked
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Use `--model` and `--reasoning-effort` for defaults supported by the native model
|
|
98
|
+
catalog. Existing loaded sessions may use settings outside the creation choices;
|
|
99
|
+
adding them never changes those settings. Managed-host options such as `--config`,
|
|
100
|
+
`--full-auto`, and bypass flags are rejected in shared-server mode.
|
|
101
|
+
|
|
102
|
+
Keep using the same session from the native CLI:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# Discover exact loaded thread IDs without contacting Canon.
|
|
106
|
+
canon-codex-attach --list --server ws://127.0.0.1:4500
|
|
107
|
+
|
|
108
|
+
# Continue the chosen thread in the native UI.
|
|
109
|
+
codex resume --remote ws://127.0.0.1:4500 <native-thread-id>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
A newly created empty thread must receive its first real Canon message before a
|
|
113
|
+
second native client can resume it. The host retains the creating connection for
|
|
114
|
+
that input; it does not inject a warmup turn. To add a pre-existing saved thread,
|
|
115
|
+
resume it in the native CLI on this shared server first, then select it in Canon.
|
|
116
|
+
The current Codex Desktop app's private server remains unsupported.
|
|
117
|
+
|
|
118
|
+
Sharing includes new completed local user and assistant text. Earlier private
|
|
119
|
+
history, tool output, and reasoning are excluded. Canon input can join an active
|
|
120
|
+
native turn. Responses use Canon's proactive delivery policy because a turn can
|
|
121
|
+
combine input from both surfaces; rejected delivery remains visible.
|
|
122
|
+
|
|
123
|
+
Ctrl-C stops the Canon host and leaves the native server and its sessions running.
|
|
124
|
+
Restart the same command to recover using the installation identity and journal
|
|
125
|
+
under `~/.canon/work-session-hosts`, plus per-conversation delivery state under
|
|
126
|
+
`~/.canon/attached-sessions`. Retain those files when recovering an interrupted or
|
|
127
|
+
uncertain creation; the host does not blindly create another native session.
|
|
128
|
+
Normal detachment excludes private work done while detached; crash recovery may
|
|
129
|
+
deliver outstanding output from the previously shared period. Only one local
|
|
130
|
+
runtime mode may own the same Canon account at once, including profile aliases.
|
|
131
|
+
|
|
132
|
+
The provider contract and native interaction types live in `@canonmsg/core`.
|
|
133
|
+
`@canonmsg/agent-sdk` owns the shared Canon connection, broker, room observers,
|
|
134
|
+
approval routing, and durable coordination. Other runtimes can implement the same
|
|
135
|
+
provider interface.
|
|
136
|
+
|
|
137
|
+
## Share one native session with a command (experimental)
|
|
138
|
+
|
|
139
|
+
`canon-codex-attach` makes one existing native thread available in a Canon
|
|
140
|
+
conversation while you continue using it in the Codex CLI. It uses the same Canon
|
|
141
|
+
account profile. Register once using the steps above and add that account to the
|
|
142
|
+
intended conversation before attaching.
|
|
143
|
+
|
|
144
|
+
Use Codex **0.154 or newer**, with app-server WebSocket transport and CLI `--remote`
|
|
145
|
+
support. Older servers reconstruct message IDs differently between live events
|
|
146
|
+
and history and are rejected before attachment:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
# Keep a shared server running in the project's directory.
|
|
150
|
+
cd /path/to/project
|
|
151
|
+
codex app-server --listen ws://127.0.0.1:4500
|
|
152
|
+
|
|
153
|
+
# In another terminal, use the native Codex CLI through that server.
|
|
154
|
+
codex --remote ws://127.0.0.1:4500
|
|
155
|
+
|
|
156
|
+
# Find its exact thread ID (metadata only; does not contact Canon).
|
|
157
|
+
canon-codex-attach --list --server ws://127.0.0.1:4500
|
|
158
|
+
|
|
159
|
+
# In another terminal, attach the exact loaded thread to its Canon audience.
|
|
160
|
+
canon-codex-attach \
|
|
161
|
+
--profile my-codex \
|
|
162
|
+
--conversation <canon-conversation-id> \
|
|
163
|
+
--thread <native-thread-id> \
|
|
164
|
+
--server ws://127.0.0.1:4500
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
To continue a known thread through the shared server, use
|
|
168
|
+
`codex resume --remote ws://127.0.0.1:4500 <native-thread-id>` before attaching.
|
|
169
|
+
The attach command refuses a thread that is not already loaded on that server.
|
|
170
|
+
It never starts a replacement or modifies the native workspace, model, or
|
|
171
|
+
permission policy. Keep a native UI connected to handle approvals and questions.
|
|
172
|
+
|
|
173
|
+
After attachment, completed local user and assistant text is shared. Earlier
|
|
174
|
+
private history, reasoning, and tool output are excluded. Local human text is
|
|
175
|
+
labelled as agent-reported context. Canon text input is added to the same native
|
|
176
|
+
conversation and can join an active turn, as ordinary Codex input does. Native
|
|
177
|
+
output uses Canon's proactive messaging policy because one response may combine
|
|
178
|
+
local and Canon input. A policy denial remains a visible delivery problem.
|
|
179
|
+
|
|
180
|
+
Ctrl-C detaches Canon and leaves Codex running. Graceful detachment excludes later
|
|
181
|
+
private work when sharing resumes; a lost connection instead retains recovery
|
|
182
|
+
state. Saved completed output retries with stable message IDs. An uncertain input
|
|
183
|
+
submission is reconciled from native history, never blindly submitted again.
|
|
184
|
+
History is paginated; a single native item over 8 MiB still exceeds the connection
|
|
185
|
+
limit and stops attachment with an error.
|
|
186
|
+
|
|
187
|
+
This mode currently supports one attachment process per profile and one native
|
|
188
|
+
thread per Canon conversation. It does not create separate group members for
|
|
189
|
+
multiple sessions of the same account. The current Codex Desktop app's private
|
|
190
|
+
server has no supported attachment endpoint; this command supports explicitly
|
|
191
|
+
shared local CLI servers. Shared-server mode above supports Canon-created sessions
|
|
192
|
+
and multiple room attachments. The existing `canon-codex` managed host retains
|
|
193
|
+
its queued turns, dynamic tools, and richer runtime controls.
|
|
194
|
+
|
|
195
|
+
The reusable controller, journal, and adapter contract live in `@canonmsg/core`;
|
|
196
|
+
`@canonmsg/agent-sdk` exports the Canon connection and publishing composition.
|
|
197
|
+
Other runtimes can implement that contract without depending on this package.
|
|
198
|
+
|
|
49
199
|
## What the host supports
|
|
50
200
|
|
|
51
201
|
- Canon messages routed into Codex turns
|
package/dist/adapter.d.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface CodexServerRequest {
|
|
|
45
45
|
params: Record<string, unknown>;
|
|
46
46
|
}
|
|
47
47
|
export interface CodexRunTurnOptions {
|
|
48
|
+
/** Original human text, before Canon adds conversation/reply context. */
|
|
49
|
+
skillInvocationText?: string;
|
|
48
50
|
planMode?: boolean;
|
|
49
51
|
onServerRequest?: (request: CodexServerRequest) => Promise<unknown>;
|
|
50
52
|
}
|
|
@@ -36,6 +36,7 @@ export declare class CodexAppServerAdapter {
|
|
|
36
36
|
private threadId;
|
|
37
37
|
private loadedThreadId;
|
|
38
38
|
private resolvedModel;
|
|
39
|
+
private observedSettings;
|
|
39
40
|
private resolvedReasoningEffort;
|
|
40
41
|
private currentTurnId;
|
|
41
42
|
private requestSeq;
|
|
@@ -67,6 +68,10 @@ export declare class CodexAppServerAdapter {
|
|
|
67
68
|
});
|
|
68
69
|
getThreadId(): string | null;
|
|
69
70
|
getResolvedModel(): string | null;
|
|
71
|
+
getObservedSettings(): Readonly<{
|
|
72
|
+
model?: string;
|
|
73
|
+
effort?: string;
|
|
74
|
+
}>;
|
|
70
75
|
getResolvedReasoningEffort(): string | null;
|
|
71
76
|
clearThreadId(): void;
|
|
72
77
|
setModel(model: string | null): void;
|
|
@@ -17,6 +17,8 @@ export class CodexAppServerAdapter {
|
|
|
17
17
|
threadId;
|
|
18
18
|
loadedThreadId = null;
|
|
19
19
|
resolvedModel = null;
|
|
20
|
+
// Public observations never inherit requested settings or fallback defaults.
|
|
21
|
+
observedSettings = {};
|
|
20
22
|
resolvedReasoningEffort;
|
|
21
23
|
currentTurnId = null;
|
|
22
24
|
requestSeq = 1;
|
|
@@ -53,16 +55,21 @@ export class CodexAppServerAdapter {
|
|
|
53
55
|
getResolvedModel() {
|
|
54
56
|
return this.resolvedModel ?? this.model;
|
|
55
57
|
}
|
|
58
|
+
getObservedSettings() {
|
|
59
|
+
return { ...this.observedSettings };
|
|
60
|
+
}
|
|
56
61
|
getResolvedReasoningEffort() {
|
|
57
62
|
return this.resolvedReasoningEffort ?? this.reasoningEffort;
|
|
58
63
|
}
|
|
59
64
|
clearThreadId() {
|
|
65
|
+
this.observedSettings = {};
|
|
60
66
|
this.threadId = null;
|
|
61
67
|
this.loadedThreadId = null;
|
|
62
68
|
this.resolvedModel = null;
|
|
63
69
|
this.resolvedReasoningEffort = this.reasoningEffort;
|
|
64
70
|
}
|
|
65
71
|
setModel(model) {
|
|
72
|
+
this.observedSettings = {};
|
|
66
73
|
this.model = model;
|
|
67
74
|
this.resolvedModel = model;
|
|
68
75
|
}
|
|
@@ -78,6 +85,7 @@ export class CodexAppServerAdapter {
|
|
|
78
85
|
const next = effort && effort.trim() ? effort.trim() : null;
|
|
79
86
|
if (next === this.reasoningEffort && next === this.resolvedReasoningEffort)
|
|
80
87
|
return;
|
|
88
|
+
this.observedSettings = { model: this.observedSettings.model };
|
|
81
89
|
this.reasoningEffort = next;
|
|
82
90
|
this.resolvedReasoningEffort = next;
|
|
83
91
|
if (this.threadId && this.loadedThreadId === this.threadId) {
|
|
@@ -110,6 +118,7 @@ export class CodexAppServerAdapter {
|
|
|
110
118
|
return await this.sendRequest(method, params);
|
|
111
119
|
}
|
|
112
120
|
close() {
|
|
121
|
+
this.observedSettings = {};
|
|
113
122
|
this.child?.kill('SIGTERM');
|
|
114
123
|
this.child = null;
|
|
115
124
|
this.initialized = false;
|
|
@@ -171,7 +180,7 @@ export class CodexAppServerAdapter {
|
|
|
171
180
|
turnPromise.catch(() => { });
|
|
172
181
|
const turnStarted = await this.sendRequest('turn/start', {
|
|
173
182
|
threadId: this.threadId,
|
|
174
|
-
input: await this.buildTurnInput(prompt, imagePaths),
|
|
183
|
+
input: await this.buildTurnInput(prompt, imagePaths, options.skillInvocationText),
|
|
175
184
|
...(this.model ? { model: this.model } : {}),
|
|
176
185
|
...this.sandboxPolicyPayload(_extraAddDirs),
|
|
177
186
|
collaborationMode: {
|
|
@@ -216,7 +225,9 @@ export class CodexAppServerAdapter {
|
|
|
216
225
|
}
|
|
217
226
|
rememberResolvedModel(result) {
|
|
218
227
|
const thread = result.thread;
|
|
219
|
-
|
|
228
|
+
const model = readString(thread, 'model') ?? readString(result, 'model');
|
|
229
|
+
this.observedSettings = { ...this.observedSettings, model: model ?? undefined };
|
|
230
|
+
this.resolvedModel = model ?? this.resolvedModel;
|
|
220
231
|
}
|
|
221
232
|
sandboxPolicyPayload(extraAddDirs) {
|
|
222
233
|
if (this.bypassApprovalsAndSandbox || this.sandbox === 'danger-full-access') {
|
|
@@ -290,22 +301,27 @@ export class CodexAppServerAdapter {
|
|
|
290
301
|
} while (cursor);
|
|
291
302
|
return models;
|
|
292
303
|
}
|
|
293
|
-
async buildTurnInput(prompt, imagePaths) {
|
|
294
|
-
const skillPrompt = parseSkillSlashPrompt(prompt);
|
|
304
|
+
async buildTurnInput(prompt, imagePaths, skillInvocationText) {
|
|
305
|
+
const skillPrompt = parseSkillSlashPrompt(skillInvocationText ?? prompt);
|
|
295
306
|
const input = [];
|
|
296
307
|
if (skillPrompt) {
|
|
297
308
|
const skills = await this.listSkills().catch(() => []);
|
|
298
309
|
const selected = skills.find((skill) => skill.name.toLowerCase() === skillPrompt.name.toLowerCase());
|
|
299
310
|
if (selected) {
|
|
300
311
|
input.push({ type: 'skill', name: selected.name, path: selected.path });
|
|
301
|
-
|
|
302
|
-
|
|
312
|
+
// Resolve the native skill from the actual message, retaining the
|
|
313
|
+
// framed prompt as context instead of parsing quoted/history text.
|
|
314
|
+
const text = skillInvocationText === undefined ? skillPrompt.prompt : prompt;
|
|
315
|
+
if (text.trim()) {
|
|
316
|
+
input.push({ type: 'text', text, text_elements: [] });
|
|
303
317
|
}
|
|
304
318
|
}
|
|
305
319
|
else {
|
|
306
320
|
input.push({
|
|
307
321
|
type: 'text',
|
|
308
|
-
text:
|
|
322
|
+
text: skillInvocationText === undefined
|
|
323
|
+
? `$${skillPrompt.name}${skillPrompt.prompt.trim() ? ` ${skillPrompt.prompt.trim()}` : ''}`
|
|
324
|
+
: prompt,
|
|
309
325
|
text_elements: [],
|
|
310
326
|
});
|
|
311
327
|
}
|
|
@@ -333,6 +349,7 @@ export class CodexAppServerAdapter {
|
|
|
333
349
|
this.currentOnLog?.(trimmed);
|
|
334
350
|
});
|
|
335
351
|
child.on('close', (code) => {
|
|
352
|
+
this.observedSettings = {};
|
|
336
353
|
const message = `Codex app-server exited${code === null ? '' : ` with code ${code}`}`;
|
|
337
354
|
this.initialized = false;
|
|
338
355
|
this.child = null;
|
|
@@ -428,6 +445,7 @@ export class CodexAppServerAdapter {
|
|
|
428
445
|
const settings = isRecord(params.threadSettings) ? params.threadSettings : {};
|
|
429
446
|
const model = readString(settings, 'model') ?? null;
|
|
430
447
|
const effort = readString(settings, 'effort') ?? null;
|
|
448
|
+
this.observedSettings = { model: model ?? undefined, effort: effort ?? undefined };
|
|
431
449
|
this.resolvedModel = model ?? this.resolvedModel;
|
|
432
450
|
this.resolvedReasoningEffort = effort;
|
|
433
451
|
this.currentOnEvent?.({ type: 'settings.updated', model, effort });
|
|
@@ -435,6 +453,7 @@ export class CodexAppServerAdapter {
|
|
|
435
453
|
}
|
|
436
454
|
if (method === 'model/rerouted') {
|
|
437
455
|
const model = readString(params, 'toModel') ?? null;
|
|
456
|
+
this.observedSettings = { model: model ?? undefined };
|
|
438
457
|
this.resolvedModel = model ?? this.resolvedModel;
|
|
439
458
|
this.currentOnEvent?.({
|
|
440
459
|
type: 'settings.updated',
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface CodexAttachOptions {
|
|
2
|
+
list?: false;
|
|
3
|
+
profile: string;
|
|
4
|
+
conversationId: string;
|
|
5
|
+
threadId: string;
|
|
6
|
+
serverUrl: string;
|
|
7
|
+
}
|
|
8
|
+
export type CodexAttachCommand = CodexAttachOptions | {
|
|
9
|
+
list: true;
|
|
10
|
+
serverUrl: string;
|
|
11
|
+
};
|
|
12
|
+
export declare const CODEX_ATTACH_HELP = "canon-codex-attach \u2014 share an existing native Codex thread with Canon\n\nUSAGE\n canon-codex-attach --profile <name> --conversation <id> --thread <id> --server <url>\n canon-codex-attach --list --server <url>\n\nREQUIRED FLAGS\n --profile Existing Canon Codex account profile\n --conversation Canon conversation this account already belongs to\n --thread Exact native thread ID, already loaded on the shared server\n --server Explicit loopback WebSocket app-server URL (experimental)\n\nOTHER FLAGS\n --list List exact loaded thread IDs and names without contacting Canon\n --help, -h Show this help\n --version, -V Show package version\n\nKeep using the same thread through a Codex CLI connected with --remote.\nThe shared server must be Codex 0.154 or newer, with stable native message IDs.\nNew local user and assistant text is shared; private earlier history is excluded.\nCanon input may join a turn already in progress. Shared output follows the\naccount's proactive messaging policy. Native permissions and approvals stay local.\n\nCtrl-C detaches Canon without stopping the native server. Restart with the same\narguments to reuse saved delivery state. Only one attachment process per profile\nis supported. This command cannot attach to a private Codex Desktop server.";
|
|
13
|
+
export declare function parseCodexAttachOptions(argv: string[]): CodexAttachCommand;
|
|
14
|
+
/** The account and audience, never a bearer credential, identify persisted state. */
|
|
15
|
+
export declare function attachedSessionStatePath(directory: string, binding: {
|
|
16
|
+
environmentId: string;
|
|
17
|
+
agentId: string;
|
|
18
|
+
conversationId: string;
|
|
19
|
+
provider: string;
|
|
20
|
+
nativeSessionId: string;
|
|
21
|
+
}): string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
export const CODEX_ATTACH_HELP = `canon-codex-attach — share an existing native Codex thread with Canon
|
|
5
|
+
|
|
6
|
+
USAGE
|
|
7
|
+
canon-codex-attach --profile <name> --conversation <id> --thread <id> --server <url>
|
|
8
|
+
canon-codex-attach --list --server <url>
|
|
9
|
+
|
|
10
|
+
REQUIRED FLAGS
|
|
11
|
+
--profile Existing Canon Codex account profile
|
|
12
|
+
--conversation Canon conversation this account already belongs to
|
|
13
|
+
--thread Exact native thread ID, already loaded on the shared server
|
|
14
|
+
--server Explicit loopback WebSocket app-server URL (experimental)
|
|
15
|
+
|
|
16
|
+
OTHER FLAGS
|
|
17
|
+
--list List exact loaded thread IDs and names without contacting Canon
|
|
18
|
+
--help, -h Show this help
|
|
19
|
+
--version, -V Show package version
|
|
20
|
+
|
|
21
|
+
Keep using the same thread through a Codex CLI connected with --remote.
|
|
22
|
+
The shared server must be Codex 0.154 or newer, with stable native message IDs.
|
|
23
|
+
New local user and assistant text is shared; private earlier history is excluded.
|
|
24
|
+
Canon input may join a turn already in progress. Shared output follows the
|
|
25
|
+
account's proactive messaging policy. Native permissions and approvals stay local.
|
|
26
|
+
|
|
27
|
+
Ctrl-C detaches Canon without stopping the native server. Restart with the same
|
|
28
|
+
arguments to reuse saved delivery state. Only one attachment process per profile
|
|
29
|
+
is supported. This command cannot attach to a private Codex Desktop server.`;
|
|
30
|
+
export function parseCodexAttachOptions(argv) {
|
|
31
|
+
const { values } = parseArgs({
|
|
32
|
+
args: argv,
|
|
33
|
+
options: {
|
|
34
|
+
profile: { type: 'string' },
|
|
35
|
+
conversation: { type: 'string' },
|
|
36
|
+
thread: { type: 'string' },
|
|
37
|
+
server: { type: 'string' },
|
|
38
|
+
list: { type: 'boolean' },
|
|
39
|
+
},
|
|
40
|
+
strict: true,
|
|
41
|
+
allowPositionals: false,
|
|
42
|
+
});
|
|
43
|
+
function required(name) {
|
|
44
|
+
const raw = values[name];
|
|
45
|
+
const value = typeof raw === 'string' ? raw.trim() : '';
|
|
46
|
+
if (!value || /[\u0000-\u001f\u007f]/.test(value)) {
|
|
47
|
+
throw new Error(`--${name} must be an explicit non-empty value without control characters`);
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
if (values.list) {
|
|
52
|
+
if (values.profile || values.conversation || values.thread) {
|
|
53
|
+
throw new Error('--list takes only --server; omit attachment account and conversation flags');
|
|
54
|
+
}
|
|
55
|
+
return { list: true, serverUrl: required('server') };
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
profile: required('profile'),
|
|
59
|
+
conversationId: required('conversation'),
|
|
60
|
+
threadId: required('thread'),
|
|
61
|
+
serverUrl: required('server'),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** The account and audience, never a bearer credential, identify persisted state. */
|
|
65
|
+
export function attachedSessionStatePath(directory, binding) {
|
|
66
|
+
const key = createHash('sha256').update(JSON.stringify([
|
|
67
|
+
binding.environmentId, binding.agentId, binding.conversationId,
|
|
68
|
+
binding.provider, binding.nativeSessionId,
|
|
69
|
+
])).digest('hex');
|
|
70
|
+
return join(directory, 'attached-sessions', `${key}.json`);
|
|
71
|
+
}
|
package/dist/attach.d.ts
ADDED
package/dist/attach.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { CANON_DIR, acquireLock, createFileAttachedSessionStore, resolveCanonProfile, runCli, } from '@canonmsg/core';
|
|
3
|
+
import { createCanonAttachedSession } from '@canonmsg/agent-sdk';
|
|
4
|
+
import { CodexAttachedSessionAdapter, listLoadedCodexSessions, validateAttachedSessionEndpoint } from './attached-session-adapter.js';
|
|
5
|
+
import { attachedSessionStatePath, CODEX_ATTACH_HELP, parseCodexAttachOptions, } from './attach-options.js';
|
|
6
|
+
import { codexAccountLockKey, codexNativeSessionLockKey } from './work-session-options.js';
|
|
7
|
+
export async function main() {
|
|
8
|
+
const args = parseCodexAttachOptions(process.argv.slice(2));
|
|
9
|
+
// Validate configuration before claiming a profile or contacting either service.
|
|
10
|
+
const endpoint = validateAttachedSessionEndpoint(args.serverUrl);
|
|
11
|
+
if (args.list) {
|
|
12
|
+
console.log(JSON.stringify(await listLoadedCodexSessions({ endpoint }), null, 2));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const profile = resolveCanonProfile(args.profile, {
|
|
16
|
+
lock: true,
|
|
17
|
+
expectedClientType: 'codex',
|
|
18
|
+
logPrefix: '[canon-codex-attach]',
|
|
19
|
+
});
|
|
20
|
+
let accountLock;
|
|
21
|
+
let nativeLock;
|
|
22
|
+
let attachment;
|
|
23
|
+
let adapter;
|
|
24
|
+
let requestedStop;
|
|
25
|
+
let shutdownRequested = false;
|
|
26
|
+
let signalShutdown = () => { };
|
|
27
|
+
const shutdown = new Promise((resolve) => { signalShutdown = resolve; });
|
|
28
|
+
const requestShutdown = () => {
|
|
29
|
+
if (shutdownRequested)
|
|
30
|
+
return;
|
|
31
|
+
shutdownRequested = true;
|
|
32
|
+
requestedStop = attachment?.stop();
|
|
33
|
+
// Cleanup awaits this promise; handle rejection immediately during startup.
|
|
34
|
+
void requestedStop?.catch(() => { });
|
|
35
|
+
signalShutdown();
|
|
36
|
+
};
|
|
37
|
+
process.once('SIGINT', requestShutdown);
|
|
38
|
+
process.once('SIGTERM', requestShutdown);
|
|
39
|
+
try {
|
|
40
|
+
if (!profile.agentId)
|
|
41
|
+
throw new Error('The selected profile has no Canon agent ID.');
|
|
42
|
+
// Aliases of the same account must not create competing attached runtimes.
|
|
43
|
+
accountLock = acquireLock(codexAccountLockKey({ environmentId: profile.environmentId, agentId: profile.agentId }));
|
|
44
|
+
nativeLock = acquireLock(codexNativeSessionLockKey(args.threadId));
|
|
45
|
+
const binding = {
|
|
46
|
+
environmentId: profile.environmentId,
|
|
47
|
+
agentId: profile.agentId,
|
|
48
|
+
conversationId: args.conversationId,
|
|
49
|
+
provider: 'codex',
|
|
50
|
+
nativeSessionId: args.threadId,
|
|
51
|
+
};
|
|
52
|
+
adapter = new CodexAttachedSessionAdapter({ endpoint, threadId: args.threadId });
|
|
53
|
+
attachment = createCanonAttachedSession({
|
|
54
|
+
connection: { ...profile, agentId: profile.agentId },
|
|
55
|
+
binding,
|
|
56
|
+
native: adapter,
|
|
57
|
+
store: createFileAttachedSessionStore(attachedSessionStatePath(CANON_DIR, binding)),
|
|
58
|
+
onStatus: (status) => {
|
|
59
|
+
console.error('[canon-codex-attach]', status);
|
|
60
|
+
if (status.status === 'stopped') {
|
|
61
|
+
shutdownRequested = true;
|
|
62
|
+
signalShutdown();
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
onError: (error) => {
|
|
66
|
+
console.error('[canon-codex-attach]', error instanceof Error ? error.message : String(error));
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
await attachment.start();
|
|
70
|
+
if (!shutdownRequested) {
|
|
71
|
+
console.error(`[canon-codex-attach] Sharing thread ${args.threadId} with Canon conversation ${args.conversationId} as ${args.profile}.`);
|
|
72
|
+
console.error('[canon-codex-attach] Keep using this thread in Codex. Ctrl-C detaches Canon.');
|
|
73
|
+
await shutdown;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
process.removeListener('SIGINT', requestShutdown);
|
|
78
|
+
process.removeListener('SIGTERM', requestShutdown);
|
|
79
|
+
try {
|
|
80
|
+
await requestedStop;
|
|
81
|
+
await attachment?.stop();
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
try {
|
|
85
|
+
await adapter?.close();
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
nativeLock?.release();
|
|
89
|
+
accountLock?.release();
|
|
90
|
+
profile.lockHandle?.release();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
runCli(import.meta.url, main, (error) => {
|
|
96
|
+
console.error('[canon-codex-attach]', error instanceof Error ? error.message : String(error));
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
}, {
|
|
99
|
+
name: 'canon-codex-attach',
|
|
100
|
+
help: CODEX_ATTACH_HELP,
|
|
101
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { AttachedNativeSessionAdapter, NativeSessionEvent, NativeSessionSnapshot, NativeSessionInteractionBridge } from '@canonmsg/core';
|
|
2
|
+
type JsonObject = Record<string, unknown>;
|
|
3
|
+
/** The small part of ws used here; also permits an in-memory transport in tests. */
|
|
4
|
+
export interface AttachedSessionSocket {
|
|
5
|
+
readonly readyState: number;
|
|
6
|
+
on(event: string, listener: (...args: any[]) => void): this;
|
|
7
|
+
send(data: string, callback?: (error?: Error) => void): void;
|
|
8
|
+
close(code?: number, reason?: string): void;
|
|
9
|
+
terminate(): void;
|
|
10
|
+
}
|
|
11
|
+
export interface CodexSharedServerOptions {
|
|
12
|
+
endpoint: string;
|
|
13
|
+
requestTimeoutMs?: number;
|
|
14
|
+
onError?: (error: Error) => void;
|
|
15
|
+
createSocket?: (endpoint: string, timeoutMs: number) => AttachedSessionSocket;
|
|
16
|
+
}
|
|
17
|
+
export interface CodexAttachedSessionOptions extends CodexSharedServerOptions {
|
|
18
|
+
threadId: string;
|
|
19
|
+
/** Borrow a provider-owned connection; closing an observer leaves it alive. */
|
|
20
|
+
sharedConnection?: CodexSharedConnection;
|
|
21
|
+
interactionBridge?: NativeSessionInteractionBridge;
|
|
22
|
+
}
|
|
23
|
+
export declare class CodexAttachedSessionError extends Error {
|
|
24
|
+
readonly submissionUncertain: boolean;
|
|
25
|
+
constructor(message: string, submissionUncertain?: boolean);
|
|
26
|
+
}
|
|
27
|
+
export declare class CodexSharedRpcError extends Error {
|
|
28
|
+
readonly code: number | null;
|
|
29
|
+
constructor(message: string, code: number | null);
|
|
30
|
+
}
|
|
31
|
+
/** Explicit local endpoints only. Never discover or fall back to a private runner. */
|
|
32
|
+
export declare function validateAttachedSessionEndpoint(value: string): string;
|
|
33
|
+
export declare class CodexSharedConnection {
|
|
34
|
+
protected readonly options: CodexSharedServerOptions;
|
|
35
|
+
private readonly shared?;
|
|
36
|
+
private readonly endpoint;
|
|
37
|
+
private readonly timeoutMs;
|
|
38
|
+
private readonly pending;
|
|
39
|
+
private socket;
|
|
40
|
+
private connecting;
|
|
41
|
+
private rejectOpen;
|
|
42
|
+
private sequence;
|
|
43
|
+
private closed;
|
|
44
|
+
private fault;
|
|
45
|
+
private readonly observers;
|
|
46
|
+
private readonly createdThreads;
|
|
47
|
+
private readonly pendingNativeRequests;
|
|
48
|
+
constructor(options: CodexSharedServerOptions, shared?: CodexSharedConnection | undefined);
|
|
49
|
+
connect(): Promise<void>;
|
|
50
|
+
close(): Promise<void>;
|
|
51
|
+
private open;
|
|
52
|
+
assertAvailable(): void;
|
|
53
|
+
request(method: string, params: unknown, submission?: boolean): Promise<unknown>;
|
|
54
|
+
createThread(params: JsonObject): Promise<unknown>;
|
|
55
|
+
hasCreatedThread(threadId: string): boolean;
|
|
56
|
+
isPristineCreatedThread(threadId: string): boolean;
|
|
57
|
+
protected respond(id: string | number, result: unknown): void;
|
|
58
|
+
protected replayPendingRequests(threadId: string): void;
|
|
59
|
+
private write;
|
|
60
|
+
private handleMessage;
|
|
61
|
+
protected onNotification(_method: string, _params: JsonObject): void;
|
|
62
|
+
protected onServerRequest(_id: string | number, _method: string, _params: JsonObject): void;
|
|
63
|
+
protected onDisconnected(_error: Error): void;
|
|
64
|
+
private rejectAll;
|
|
65
|
+
protected fail(error: Error): void;
|
|
66
|
+
protected report(error: Error): void;
|
|
67
|
+
}
|
|
68
|
+
export interface LoadedCodexSession {
|
|
69
|
+
threadId: string;
|
|
70
|
+
name?: string;
|
|
71
|
+
cwd?: string;
|
|
72
|
+
status?: 'idle' | 'running';
|
|
73
|
+
model?: string;
|
|
74
|
+
reasoningEffort?: string;
|
|
75
|
+
}
|
|
76
|
+
/** Read only live-thread metadata. No transcript, resume, or Canon credentials. */
|
|
77
|
+
export declare function listLoadedCodexSessions(options: CodexSharedServerOptions, sharedConnection?: CodexSharedConnection): Promise<LoadedCodexSession[]>;
|
|
78
|
+
/**
|
|
79
|
+
* Experimental second client of an explicitly shared, already-running server.
|
|
80
|
+
* It observes a native thread without owning its process, settings, or approvals.
|
|
81
|
+
*/
|
|
82
|
+
export declare class CodexAttachedSessionAdapter extends CodexSharedConnection implements AttachedNativeSessionAdapter {
|
|
83
|
+
private readonly threadId;
|
|
84
|
+
private readonly listeners;
|
|
85
|
+
private attachment;
|
|
86
|
+
private submitting;
|
|
87
|
+
private inspecting;
|
|
88
|
+
private readonly terminalTurns;
|
|
89
|
+
private turnOrder;
|
|
90
|
+
private historyRevision;
|
|
91
|
+
private eventRevision;
|
|
92
|
+
private readonly interactions;
|
|
93
|
+
private readonly interactionBridge?;
|
|
94
|
+
constructor(options: CodexAttachedSessionOptions);
|
|
95
|
+
subscribe(listener: (event: NativeSessionEvent) => void): Promise<void>;
|
|
96
|
+
inspect(): Promise<NativeSessionSnapshot>;
|
|
97
|
+
submit(text: string): ReturnType<AttachedNativeSessionAdapter['submit']>;
|
|
98
|
+
close(): Promise<void>;
|
|
99
|
+
private attach;
|
|
100
|
+
private ready;
|
|
101
|
+
private readThread;
|
|
102
|
+
private snapshot;
|
|
103
|
+
private readTurnManifest;
|
|
104
|
+
private readTurnItems;
|
|
105
|
+
private pages;
|
|
106
|
+
protected onNotification(method: string, params: JsonObject): void;
|
|
107
|
+
private emit;
|
|
108
|
+
protected onDisconnected(error: Error): void;
|
|
109
|
+
private abortInteractions;
|
|
110
|
+
protected onServerRequest(id: string | number, method: string, params: JsonObject): void;
|
|
111
|
+
}
|
|
112
|
+
export {};
|