@linkshell/wire 0.2.20 → 0.2.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/envelopes.d.ts +53 -0
- package/dist/envelopes.js +251 -0
- package/dist/envelopes.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/live-activity.d.ts +6 -6
- package/dist/model.d.ts +54 -0
- package/dist/model.js +28 -0
- package/dist/model.js.map +1 -1
- package/dist/rpc-mcp.d.ts +199 -0
- package/dist/rpc-mcp.js +78 -0
- package/dist/rpc-mcp.js.map +1 -0
- package/dist/rpc.d.ts +823 -6
- package/dist/rpc.js +64 -1
- package/dist/rpc.js.map +1 -1
- package/dist/side.d.ts +83 -0
- package/dist/side.js +26 -0
- package/dist/side.js.map +1 -0
- package/dist/updates.d.ts +211 -0
- package/dist/updates.js +14 -1
- package/dist/updates.js.map +1 -1
- package/dist/watch.d.ts +16 -16
- package/package.json +1 -1
- package/src/envelopes.ts +278 -0
- package/src/index.ts +3 -0
- package/src/model.ts +28 -0
- package/src/rpc-mcp.ts +81 -0
- package/src/rpc.ts +65 -1
- package/src/side.ts +30 -0
- package/src/updates.ts +15 -1
package/src/envelopes.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// What agents and their desktop clients wrap around a person's words. Claude Code adds reminders
|
|
2
|
+
// and IDE state, the Claude app adds session events and peer mail, Codex Desktop adds the files,
|
|
3
|
+
// selection and browser state it attached: context for the model, not what the person typed.
|
|
4
|
+
// Readers show the words, and the context as a chip or an event line where it means something to
|
|
5
|
+
// them, the way Claude's and Codex's own apps do. Shared by the host (titles, previews, what it
|
|
6
|
+
// keeps from Claude's transcripts) and every client.
|
|
7
|
+
|
|
8
|
+
/** Tags whose whole content is for the model only: the Claude app's own list, then Codex's. */
|
|
9
|
+
const HIDDEN_TAGS = [
|
|
10
|
+
"system-reminder",
|
|
11
|
+
"task-notification",
|
|
12
|
+
"slack_context",
|
|
13
|
+
"uploaded_files",
|
|
14
|
+
"available-deferred-tools",
|
|
15
|
+
"new-diagnostics",
|
|
16
|
+
"critical_user_preferences_reminder",
|
|
17
|
+
"persisted-output",
|
|
18
|
+
"local-command-caveat",
|
|
19
|
+
"tick",
|
|
20
|
+
"goal",
|
|
21
|
+
"mcp-resource",
|
|
22
|
+
"mcp-resource-update",
|
|
23
|
+
"mcp-polling-update",
|
|
24
|
+
"teammate-message",
|
|
25
|
+
"cross-session-message",
|
|
26
|
+
"launch-selected-element",
|
|
27
|
+
"connected_office_files",
|
|
28
|
+
"thinking",
|
|
29
|
+
"ide_opened_file",
|
|
30
|
+
"ide_selection",
|
|
31
|
+
"ide_diagnostics",
|
|
32
|
+
"browser_instruction",
|
|
33
|
+
"cell",
|
|
34
|
+
"cell_type",
|
|
35
|
+
"language",
|
|
36
|
+
"command-message",
|
|
37
|
+
"command-args",
|
|
38
|
+
"no-action-needed",
|
|
39
|
+
"comprehensive-mode-instructions",
|
|
40
|
+
"cu_window_hints",
|
|
41
|
+
"widget_context_hint",
|
|
42
|
+
"preview-annotation-context",
|
|
43
|
+
"terminal-selection",
|
|
44
|
+
"environment_context",
|
|
45
|
+
"user_instructions",
|
|
46
|
+
"turn_aborted",
|
|
47
|
+
"subagent_notification",
|
|
48
|
+
"external_codex_apps_open_page",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const hiddenPattern = (tags: string[]) => new RegExp(`<(${tags.join("|")})(?:\\s[^>]*)?>[\\s\\S]*?</\\1>`, "gi");
|
|
52
|
+
const HIDDEN = hiddenPattern(HIDDEN_TAGS);
|
|
53
|
+
/** Mail between sessions and agents: hidden in a person's bubble, but a message of its own when it is the whole message. */
|
|
54
|
+
const PEER_TAGS = ["teammate-message", "cross-session-message"];
|
|
55
|
+
const HIDDEN_BUT_PEERS = hiddenPattern(HIDDEN_TAGS.filter((tag) => !PEER_TAGS.includes(tag)));
|
|
56
|
+
|
|
57
|
+
/** Something attached to a message, shown as a chip beside the words. */
|
|
58
|
+
export interface UserContext {
|
|
59
|
+
kind: "file" | "image" | "pasted" | "selection" | "comments" | "checks" | "annotations" | "browser" | "review" | "pull-request" | "merge" | "history" | "other";
|
|
60
|
+
label: string;
|
|
61
|
+
/** A path, or the start of the attached text. */
|
|
62
|
+
detail?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A message that is an event, not something said: one line in the conversation. */
|
|
66
|
+
export type UserEvent =
|
|
67
|
+
| { kind: "ci"; count: number }
|
|
68
|
+
| { kind: "github"; count: number }
|
|
69
|
+
| { kind: "sessions"; sessions: { id?: string; title: string }[] }
|
|
70
|
+
| { kind: "scheduled"; name: string; detail?: string };
|
|
71
|
+
|
|
72
|
+
export interface UserTextView {
|
|
73
|
+
/** The words to show; empty when the message is only context, an event or a command's output. */
|
|
74
|
+
text: string;
|
|
75
|
+
context: UserContext[];
|
|
76
|
+
/** A shell command run from the prompt (Claude's `!` mode), and what it printed. */
|
|
77
|
+
shell?: { command?: string; stdout?: string; stderr?: string };
|
|
78
|
+
/** What a local slash command printed (`/cost`, `/context`). */
|
|
79
|
+
output?: string;
|
|
80
|
+
event?: UserEvent;
|
|
81
|
+
/** Nothing in it is for a reader. */
|
|
82
|
+
hidden: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The text without what is for the model only; `keepPeers` keeps mail from other sessions and agents. */
|
|
86
|
+
export function stripHiddenContext(text: string, { keepPeers = false }: { keepPeers?: boolean } = {}): string {
|
|
87
|
+
return text
|
|
88
|
+
.replace(/\r\n/g, "\n")
|
|
89
|
+
.replace(keepPeers ? HIDDEN_BUT_PEERS : HIDDEN, "")
|
|
90
|
+
.replace(/<\/?tool_use_error>/g, "")
|
|
91
|
+
// Claude's pointers to a background task's files, for itself.
|
|
92
|
+
.replace(/^(?:Full transcript available at|Read the output file to retrieve the result):.*$/gm, "")
|
|
93
|
+
.replace(/\s*\[event:[\da-f-]{36}\]\s*$/i, "")
|
|
94
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
95
|
+
.trim();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const ANSI = /\u001b\[[\d;?]*[ -/]*[@-~]/g;
|
|
99
|
+
|
|
100
|
+
function tagText(source: string, tag: string): string | undefined {
|
|
101
|
+
const match = new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)</${tag}>`, "i").exec(source);
|
|
102
|
+
return match ? match[1]! : undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function attribute(attrs: string, name: string): string | undefined {
|
|
106
|
+
const match = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i").exec(attrs);
|
|
107
|
+
return match ? (match[1] ?? match[2]) : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const EVENT_TAGS = ["side-sessions-event", "ci-monitor-event", "github-webhook-activity"] as const;
|
|
111
|
+
const EVENT_PATTERNS = new Map(EVENT_TAGS.map((tag) => [tag, new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?</${tag}>`, "gi")]));
|
|
112
|
+
|
|
113
|
+
/** A message made only of one kind of tag, repeated: the count, else undefined. */
|
|
114
|
+
function only(text: string, tag: (typeof EVENT_TAGS)[number]): number | undefined {
|
|
115
|
+
if (!text.startsWith(`<${tag}`)) return undefined;
|
|
116
|
+
const pattern = EVENT_PATTERNS.get(tag)!;
|
|
117
|
+
const all = text.match(pattern);
|
|
118
|
+
if (!all) return undefined;
|
|
119
|
+
return text.replace(pattern, "").trim() ? undefined : all.length;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Codex Desktop's sections before "## My request:", by heading. */
|
|
123
|
+
const CONTEXT_SECTIONS: Record<string, UserContext["kind"]> = {
|
|
124
|
+
"Files mentioned by the user": "file",
|
|
125
|
+
"Files pasted by the user": "pasted",
|
|
126
|
+
"Selected text": "selection",
|
|
127
|
+
"Diff comments": "comments",
|
|
128
|
+
"Browser comments": "comments",
|
|
129
|
+
"Failing PR checks": "checks",
|
|
130
|
+
"Response annotations": "annotations",
|
|
131
|
+
};
|
|
132
|
+
const MODES: Record<string, [UserContext["kind"], string]> = {
|
|
133
|
+
"Code review guidelines": ["review", "审查模式"],
|
|
134
|
+
"Pull request fix": ["pull-request", "PR 修复"],
|
|
135
|
+
"Auto resolve merge": ["merge", "自动解决冲突"],
|
|
136
|
+
"Prior conversation with Codex": ["history", "参考历史对话"],
|
|
137
|
+
};
|
|
138
|
+
const SECTION_LABELS: Record<string, string> = {
|
|
139
|
+
"Selected text": "选中的文本",
|
|
140
|
+
"Diff comments": "差异评论",
|
|
141
|
+
"Browser comments": "浏览器评论",
|
|
142
|
+
"Failing PR checks": "失败的 PR 检查",
|
|
143
|
+
"Response annotations": "回复批注",
|
|
144
|
+
};
|
|
145
|
+
const REQUEST = /^## My request(?: for Codex)?:[ \t]*$/gm;
|
|
146
|
+
|
|
147
|
+
const baseName = (path: string) => path.split(/[\\/]/).filter(Boolean).at(-1) ?? path;
|
|
148
|
+
|
|
149
|
+
function codexContext(text: string, images: number): { text: string; context: UserContext[] } | undefined {
|
|
150
|
+
const start = /^(#{1,2}) ([^\n:]+):[ \t]*$/m.exec(text);
|
|
151
|
+
// Only an envelope Codex wrote: it opens the message with one of its own headings.
|
|
152
|
+
if (!start || start.index !== 0) return undefined;
|
|
153
|
+
const heading = start[2]!;
|
|
154
|
+
if (!(start[1] === "#" ? heading in CONTEXT_SECTIONS : heading in MODES || /^My request(?: for Codex)?$/.test(heading))) return undefined;
|
|
155
|
+
let cut = -1;
|
|
156
|
+
let after = 0;
|
|
157
|
+
for (const match of text.matchAll(REQUEST)) {
|
|
158
|
+
cut = match.index!;
|
|
159
|
+
after = match.index! + match[0].length;
|
|
160
|
+
}
|
|
161
|
+
const preface = cut < 0 ? text : text.slice(0, cut);
|
|
162
|
+
const request = cut < 0 ? "" : text.slice(after).trim();
|
|
163
|
+
const context: UserContext[] = [];
|
|
164
|
+
let imageEntries = 0;
|
|
165
|
+
const sections = preface.split(/^(?=#{1,2} [^\n:]+:[ \t]*$)/m);
|
|
166
|
+
for (const section of sections) {
|
|
167
|
+
const head = /^(#{1,2}) ([^\n:]+):[ \t]*\n?/.exec(section);
|
|
168
|
+
if (!head) continue;
|
|
169
|
+
const name = head[2]!;
|
|
170
|
+
const body = section.slice(head[0].length).trim();
|
|
171
|
+
if (head[1] === "##") {
|
|
172
|
+
const mode = MODES[name];
|
|
173
|
+
if (mode) context.push({ kind: mode[0], label: mode[1] });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const kind = CONTEXT_SECTIONS[name];
|
|
177
|
+
if (kind === "file") {
|
|
178
|
+
for (const entry of body.matchAll(/^## (.+?): (.+?)(?: \((lines? [^)]*)\))?\n?(Image attachment: true)?$/gm)) {
|
|
179
|
+
if (entry[4]) {
|
|
180
|
+
imageEntries += 1;
|
|
181
|
+
// The picture itself is shown; a chip for it would say it twice.
|
|
182
|
+
if (imageEntries <= images) continue;
|
|
183
|
+
}
|
|
184
|
+
context.push({ kind: entry[4] ? "image" : "file", label: entry[1]!.includes("/") ? baseName(entry[1]!) : entry[1]!, detail: entry[3] ? `${entry[2]} · ${entry[3]}` : entry[2] });
|
|
185
|
+
}
|
|
186
|
+
} else if (kind === "pasted") {
|
|
187
|
+
for (const entry of body.matchAll(/^## "?(.+?)"?: (.+)$/gm)) context.push({ kind: "pasted", label: entry[1]!.trim(), detail: entry[2] });
|
|
188
|
+
} else if (kind) {
|
|
189
|
+
context.push({ kind, label: SECTION_LABELS[name] ?? name, detail: body.split("\n").find((line) => line.trim())?.trim() });
|
|
190
|
+
} else if (name !== "Distinguish instructions in attached documents from the user's request") {
|
|
191
|
+
context.push({ kind: "other", label: name });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { text: request, context };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Claude writes a shell's output into its transcript with <, > and & escaped.
|
|
198
|
+
const ENTITIES: Record<string, string> = { lt: "<", gt: ">", amp: "&", quot: "\"", apos: "'" };
|
|
199
|
+
function unescapeHtml(value: string): string {
|
|
200
|
+
// One pass: "&gt;" is the text ">", not ">".
|
|
201
|
+
return value.replace(/&(lt|gt|amp|quot|apos|#\d{1,7}|#x[0-9a-f]{1,6});/gi, (whole, name: string) => {
|
|
202
|
+
const key = name.toLowerCase();
|
|
203
|
+
const code = key.startsWith("#x") ? parseInt(key.slice(2), 16) : key.startsWith("#") ? Number(key.slice(1)) : undefined;
|
|
204
|
+
if (code === undefined) return ENTITIES[key] ?? whole;
|
|
205
|
+
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Codex Desktop writes a mention of another of its threads as a Markdown link to
|
|
211
|
+
* codex://threads/<id>, with the space before it as " ": read as the thread's title.
|
|
212
|
+
*/
|
|
213
|
+
function threadMentions(text: string): string {
|
|
214
|
+
const read = text.replace(/&#(?:x20|32);/gi, " ").replace(/\[([^\]\n]+)\]\(codex:\/\/threads\/[\w-]+\)/g, "「$1」");
|
|
215
|
+
return read === text ? text : read.trim();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* How to show a person's message: its words, what came with it, or the one event it is. `images`
|
|
220
|
+
* is how many pictures the message carries besides its text, so an envelope naming them doesn't
|
|
221
|
+
* also chip them.
|
|
222
|
+
*/
|
|
223
|
+
export function readUserText(raw: string, { images = 0 }: { images?: number } = {}): UserTextView {
|
|
224
|
+
let text = raw.replace(/\r\n/g, "\n");
|
|
225
|
+
const context: UserContext[] = [];
|
|
226
|
+
const view = (rest: Partial<UserTextView>): UserTextView => ({ text: "", context, hidden: false, ...rest });
|
|
227
|
+
|
|
228
|
+
// The Claude app tells a session about the sessions it started in a reminder beside the event.
|
|
229
|
+
const sessions = [...text.matchAll(/The session "([^"\n]+)" you started \((local_[\w-]+)\)/g)].map((match) => ({ title: match[1]!, id: match[2] }));
|
|
230
|
+
const browser = /<in-app-browser-context\b[^>]*>[\s\S]*?<\/in-app-browser-context>/gi;
|
|
231
|
+
const withoutBrowser = text.replace(browser, "");
|
|
232
|
+
if (withoutBrowser !== text) {
|
|
233
|
+
context.push({ kind: "browser", label: "浏览器上下文" });
|
|
234
|
+
text = withoutBrowser;
|
|
235
|
+
}
|
|
236
|
+
text = stripHiddenContext(text);
|
|
237
|
+
|
|
238
|
+
if (only(text, "side-sessions-event")) return view({ event: { kind: "sessions", sessions } });
|
|
239
|
+
if (!text) return view({ hidden: images === 0 && context.length === 0 });
|
|
240
|
+
if (/^\[Request interrupted by user(?: for tool use)?\]$/.test(text)) return view({ hidden: true });
|
|
241
|
+
const ci = only(text, "ci-monitor-event");
|
|
242
|
+
if (ci) return view({ event: { kind: "ci", count: ci } });
|
|
243
|
+
const github = only(text, "github-webhook-activity");
|
|
244
|
+
if (github) return view({ event: { kind: "github", count: github } });
|
|
245
|
+
const scheduled = /^<scheduled-task\b([^>]*)>([\s\S]*?)<\/scheduled-task>$/i.exec(text);
|
|
246
|
+
if (scheduled) {
|
|
247
|
+
const name = attribute(scheduled[1]!, "name") ?? baseName(attribute(scheduled[1]!, "file") ?? "").replace(/\.md$/i, "");
|
|
248
|
+
return view({ event: { kind: "scheduled", name: name || "定时任务", detail: scheduled[2]!.trim() || undefined } });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (/^<bash-(?:input|stdout|stderr)>/.test(text)) {
|
|
252
|
+
const clean = (value?: string) => (value === undefined ? undefined : unescapeHtml(value).replace(ANSI, "").replace(/\n+$/, "")) || undefined;
|
|
253
|
+
const command = clean(tagText(text, "bash-input"))?.trim();
|
|
254
|
+
const stdout = clean(tagText(text, "bash-stdout"));
|
|
255
|
+
const stderr = clean(tagText(text, "bash-stderr"));
|
|
256
|
+
const rest = text.replace(/<(bash-(?:input|stdout|stderr|exit-code))>[\s\S]*?<\/\1>/g, "").trim();
|
|
257
|
+
return view({ shell: { command, stdout, stderr }, text: rest, hidden: !command && !stdout && !stderr && !rest });
|
|
258
|
+
}
|
|
259
|
+
if (/^<local-command-(?:stdout|stderr)>/.test(text)) {
|
|
260
|
+
const raw = [tagText(text, "local-command-stdout"), tagText(text, "local-command-stderr")]
|
|
261
|
+
.filter((part): part is string => !!part?.trim())
|
|
262
|
+
.join("\n")
|
|
263
|
+
.replace(ANSI, "");
|
|
264
|
+
const output = unescapeHtml(raw).trim();
|
|
265
|
+
// /model's confirmation: the Claude app doesn't show the switch as a line either.
|
|
266
|
+
return view({ output: output || undefined, hidden: !output || /^Set model to\b/.test(output) });
|
|
267
|
+
}
|
|
268
|
+
// Slash-command plumbing the host couldn't read as a command, and notifications it didn't match.
|
|
269
|
+
if (/^<(?:command-name|local-command-|task-notification)/.test(text)) return view({ hidden: true });
|
|
270
|
+
|
|
271
|
+
const codex = codexContext(text, images);
|
|
272
|
+
if (codex) {
|
|
273
|
+
context.push(...codex.context);
|
|
274
|
+
const words = threadMentions(codex.text);
|
|
275
|
+
return view({ text: words, hidden: !words && images === 0 && context.length === 0 });
|
|
276
|
+
}
|
|
277
|
+
return view({ text: threadMentions(text) });
|
|
278
|
+
}
|
package/src/index.ts
CHANGED
package/src/model.ts
CHANGED
|
@@ -33,6 +33,8 @@ export const agentCapabilitiesSchema = z.object({
|
|
|
33
33
|
* shows the conversation and its agent is told it as text.
|
|
34
34
|
*/
|
|
35
35
|
fork: z.boolean(),
|
|
36
|
+
/** The agent has a side conversation of its own (`sessions.side`). Absent from older hosts. */
|
|
37
|
+
side: z.boolean().optional(),
|
|
36
38
|
models: z.boolean(),
|
|
37
39
|
modes: z.boolean(),
|
|
38
40
|
});
|
|
@@ -252,6 +254,25 @@ export const sessionSummarySchema = z.object({
|
|
|
252
254
|
* (the project the session belongs to).
|
|
253
255
|
*/
|
|
254
256
|
worktree: z.object({ branch: z.string(), source: z.string() }).optional(),
|
|
257
|
+
/**
|
|
258
|
+
* The LinkShell session that started this one (e.g. a Claude desktop
|
|
259
|
+
* session's start_session or hand-off): the sidebar nests it there. Set
|
|
260
|
+
* only when this host has that session too. Absent from older hosts.
|
|
261
|
+
*/
|
|
262
|
+
parentId: z.string().optional(),
|
|
263
|
+
/**
|
|
264
|
+
* The LinkShell session this one was forked from (made here with
|
|
265
|
+
* `sessions.fork`, or recorded by the agent), when this host has it. Kept
|
|
266
|
+
* apart from `parentId`: a fork is a copy, not something that session
|
|
267
|
+
* started, so whether it nests is the list's choice.
|
|
268
|
+
*/
|
|
269
|
+
forkedFrom: z.string().optional(),
|
|
270
|
+
/**
|
|
271
|
+
* The id an app of the agent's knows the session by (the Claude desktop
|
|
272
|
+
* app's `local_…`): mail between its sessions names the sender by it, so a
|
|
273
|
+
* client can open the sender. Absent from older hosts.
|
|
274
|
+
*/
|
|
275
|
+
appId: z.string().optional(),
|
|
255
276
|
/** Live only, for sessions that started sub-agents: how many, and how many are working now (`sessions.subagents` lists them). */
|
|
256
277
|
subagents: z.object({ total: z.number().int(), running: z.number().int() }).optional(),
|
|
257
278
|
/** Live only, for sessions that left commands running: how many, and how many still run (`sessions.tasks` lists them). */
|
|
@@ -314,6 +335,8 @@ export const machineInfoSchema = z.object({
|
|
|
314
335
|
hostname: z.string(),
|
|
315
336
|
platform: z.string(),
|
|
316
337
|
hostVersion: z.string(),
|
|
338
|
+
/** The host's RPC revision (`HOST_PROTOCOL`); absent from hosts older than it. */
|
|
339
|
+
protocol: z.number().int().nonnegative().optional(),
|
|
317
340
|
/** The host user's home directory, so apps can show paths as `~/…`. */
|
|
318
341
|
home: z.string().optional(),
|
|
319
342
|
agents: z.array(agentInfoSchema),
|
|
@@ -359,6 +382,11 @@ export const terminalInfoSchema = z.object({
|
|
|
359
382
|
exitCode: z.number().int().nullable().optional(),
|
|
360
383
|
/** Ended without an exit code: the host stopped while it ran. */
|
|
361
384
|
interrupted: z.boolean().optional(),
|
|
385
|
+
/**
|
|
386
|
+
* The session it was opened for. Such a terminal belongs to the session: it closes when its
|
|
387
|
+
* shell exits or the session is archived or deleted, and isn't kept across a host restart.
|
|
388
|
+
*/
|
|
389
|
+
sessionId: z.string().optional(),
|
|
362
390
|
});
|
|
363
391
|
export type TerminalInfo = z.infer<typeof terminalInfoSchema>;
|
|
364
392
|
|
package/src/rpc-mcp.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { mcpAppDisplayModeSchema } from "./updates.js";
|
|
3
|
+
|
|
4
|
+
// MCP Apps: an MCP tool can declare a page of its own (a `ui://` HTML
|
|
5
|
+
// resource) that the client shows in a sandboxed frame, and that page can call
|
|
6
|
+
// its server's tools back. Codex sessions only: Codex is the agent that tells
|
|
7
|
+
// the host which tools have pages and lets a client read and call them. Other
|
|
8
|
+
// agents' sessions are refused with `not_supported`; a Codex that isn't
|
|
9
|
+
// running with `agent_unavailable`. Hosts older than these methods answer
|
|
10
|
+
// "method not found".
|
|
11
|
+
|
|
12
|
+
/** A tool Codex offers as an app of the thread (what its "插件和 MCP" menu lists). */
|
|
13
|
+
export const mcpAppSchema = z.object({
|
|
14
|
+
server: z.string(),
|
|
15
|
+
tool: z.string(),
|
|
16
|
+
/** The plugin's or connector's name where there is one, else the tool's. */
|
|
17
|
+
title: z.string(),
|
|
18
|
+
description: z.string().optional(),
|
|
19
|
+
/** Always `ui://…`. */
|
|
20
|
+
resourceUri: z.string(),
|
|
21
|
+
/** Where Codex offers it (`openai/ui.entrypoints[].type`): always includes "thread". */
|
|
22
|
+
entrypoints: z.array(z.string()),
|
|
23
|
+
/** Who may call the tool (`ui.visibility`: "model", "app"); absent: both. */
|
|
24
|
+
visibility: z.array(z.string()).optional(),
|
|
25
|
+
displayMode: mcpAppDisplayModeSchema.optional(),
|
|
26
|
+
/** The Codex connector (server `codex_apps`): `mcp.call` needs it there. */
|
|
27
|
+
connector: z.string().optional(),
|
|
28
|
+
});
|
|
29
|
+
export type McpApp = z.infer<typeof mcpAppSchema>;
|
|
30
|
+
|
|
31
|
+
export const mcpMethods = {
|
|
32
|
+
/** The apps the session's Codex can open in the thread. */
|
|
33
|
+
"mcp.apps": {
|
|
34
|
+
params: z.object({ sessionId: z.string().min(1) }),
|
|
35
|
+
result: z.object({ apps: z.array(mcpAppSchema) }),
|
|
36
|
+
},
|
|
37
|
+
/**
|
|
38
|
+
* A page's HTML (its first content), as the MCP server gives it. Only `ui://`
|
|
39
|
+
* URIs (`invalid_params` otherwise); one over 4 MB is refused (`too_large`).
|
|
40
|
+
* `meta` is the content's `_meta`: CSP domains, display modes, border.
|
|
41
|
+
*/
|
|
42
|
+
"mcp.resource": {
|
|
43
|
+
params: z.object({
|
|
44
|
+
sessionId: z.string().min(1),
|
|
45
|
+
server: z.string().min(1),
|
|
46
|
+
uri: z.string().min(1),
|
|
47
|
+
/** The page's connector (`detail.app.connector`, `McpApp.connector`), for server `codex_apps`. */
|
|
48
|
+
connector: z.string().optional(),
|
|
49
|
+
}),
|
|
50
|
+
result: z.object({
|
|
51
|
+
mimeType: z.string().optional(),
|
|
52
|
+
text: z.string().optional(),
|
|
53
|
+
blob: z.string().optional(),
|
|
54
|
+
meta: z.unknown().optional(),
|
|
55
|
+
}),
|
|
56
|
+
},
|
|
57
|
+
/**
|
|
58
|
+
* A page calls one of its server's tools, in the session's thread. Refused
|
|
59
|
+
* (`forbidden`) for a server without a page, a tool the page may not call
|
|
60
|
+
* (`ui.visibility` without "app", `openai/fileParams`), or, on `codex_apps`, a
|
|
61
|
+
* tool of another connector than `connector` (required there:
|
|
62
|
+
* `invalid_params` without it); `not_found` for a tool the server doesn't
|
|
63
|
+
* have. Codex's own failure is `agent_error`; a tool that ran and failed is
|
|
64
|
+
* `isError` in the result.
|
|
65
|
+
*/
|
|
66
|
+
"mcp.call": {
|
|
67
|
+
params: z.object({
|
|
68
|
+
sessionId: z.string().min(1),
|
|
69
|
+
server: z.string().min(1),
|
|
70
|
+
tool: z.string().min(1),
|
|
71
|
+
arguments: z.record(z.unknown()).optional(),
|
|
72
|
+
connector: z.string().optional(),
|
|
73
|
+
}),
|
|
74
|
+
result: z.object({
|
|
75
|
+
content: z.array(z.unknown()),
|
|
76
|
+
structuredContent: z.unknown().optional(),
|
|
77
|
+
isError: z.boolean().optional(),
|
|
78
|
+
meta: z.unknown().optional(),
|
|
79
|
+
}),
|
|
80
|
+
},
|
|
81
|
+
} as const;
|
package/src/rpc.ts
CHANGED
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
pendingPermissionSchema,
|
|
22
22
|
} from "./model.js";
|
|
23
23
|
import { contentBlockSchema, sessionEventSchema, sessionNoticeSchema } from "./updates.js";
|
|
24
|
+
import { sideChatSchema } from "./side.js";
|
|
25
|
+
import { mcpMethods } from "./rpc-mcp.js";
|
|
24
26
|
|
|
25
27
|
// ── JSON-RPC 2.0 framing ─────────────────────────────────────────────
|
|
26
28
|
|
|
@@ -67,6 +69,15 @@ export const RPC_APP_ERROR = -32000;
|
|
|
67
69
|
|
|
68
70
|
// ── Methods (client → host) ──────────────────────────────────────────
|
|
69
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The host's RPC revision, in `machine.info`. Goes up when the host gains
|
|
74
|
+
* methods or notifications an app may rely on, so an app can tell the
|
|
75
|
+
* computer is older than itself and say so (an absent revision is 0).
|
|
76
|
+
* 1: side conversations (`sessions.side`), `machine.info.protocol` itself.
|
|
77
|
+
* 2: session terminals (`terminals.create` `sessionId`, `TerminalInfo.sessionId`).
|
|
78
|
+
*/
|
|
79
|
+
export const HOST_PROTOCOL = 2;
|
|
80
|
+
|
|
70
81
|
const empty = z.object({}).passthrough();
|
|
71
82
|
|
|
72
83
|
export const directoryEntrySchema = z.object({
|
|
@@ -140,12 +151,36 @@ const coreMethods = {
|
|
|
140
151
|
nextBefore: z.number().optional(),
|
|
141
152
|
}),
|
|
142
153
|
},
|
|
154
|
+
/**
|
|
155
|
+
* The models a new session of `agent` can start on, for choosing one before
|
|
156
|
+
* there is a session (whose own list comes later, in its `ls_config`).
|
|
157
|
+
* `current` is the one the agent takes when `sessions.create` names none:
|
|
158
|
+
* its own default, which a new session keeps unless another is picked.
|
|
159
|
+
* Agents that tell their models only to a session give what they said in
|
|
160
|
+
* the latest one opened on this computer; never asked by starting one, so
|
|
161
|
+
* empty until there has been one (`current` too may be missing then).
|
|
162
|
+
* With an empty list `sessions.create` takes no model. Hosts older than
|
|
163
|
+
* this method answer "method not found".
|
|
164
|
+
*/
|
|
165
|
+
"agents.models": {
|
|
166
|
+
params: z.object({
|
|
167
|
+
agent: z.string().min(1),
|
|
168
|
+
/** Where the session will work: an agent's project settings can choose another model. */
|
|
169
|
+
cwd: z.string().optional(),
|
|
170
|
+
}),
|
|
171
|
+
result: z.object({
|
|
172
|
+
/** As a session's model option lists them (`group`: the heading some agents put models under). */
|
|
173
|
+
models: z.array(z.object({ value: z.string(), name: z.string(), description: z.string().optional(), group: z.string().optional() })),
|
|
174
|
+
current: z.string().optional(),
|
|
175
|
+
}),
|
|
176
|
+
},
|
|
143
177
|
"sessions.create": {
|
|
144
178
|
params: z.object({
|
|
145
179
|
agent: z.string().min(1),
|
|
146
180
|
cwd: z.string().min(1),
|
|
147
181
|
prompt: z.array(contentBlockSchema).optional(),
|
|
148
182
|
clientMessageId: z.string().optional(),
|
|
183
|
+
/** One of `agents.models`' values; absent: the agent's own default. */
|
|
149
184
|
model: z.string().optional(),
|
|
150
185
|
/**
|
|
151
186
|
* Start in a new git worktree of `cwd`'s repository (its own branch, from
|
|
@@ -218,6 +253,31 @@ const coreMethods = {
|
|
|
218
253
|
params: z.object({ sessionId: z.string().min(1), toolCallId: z.string().min(1), lazyImages: z.boolean().optional() }),
|
|
219
254
|
result: z.object({ events: z.array(sessionEventSchema) }),
|
|
220
255
|
},
|
|
256
|
+
/**
|
|
257
|
+
* Asks in a side conversation of the session (a new one without `sideId`):
|
|
258
|
+
* answered from what the session knows, never added to it. Returns once the
|
|
259
|
+
* question is on its way; the answer comes in `session.side`. Only for an
|
|
260
|
+
* agent whose capabilities say `side`.
|
|
261
|
+
*/
|
|
262
|
+
"sessions.side": {
|
|
263
|
+
params: z.object({ sessionId: z.string().min(1), sideId: z.string().min(1).optional(), text: z.string().min(1).max(20_000) }),
|
|
264
|
+
result: z.object({ side: sideChatSchema }),
|
|
265
|
+
},
|
|
266
|
+
/** The session's open side conversations, oldest first. */
|
|
267
|
+
"sessions.sides": {
|
|
268
|
+
params: z.object({ sessionId: z.string().min(1) }),
|
|
269
|
+
result: z.object({ sides: z.array(sideChatSchema) }),
|
|
270
|
+
},
|
|
271
|
+
/** Stops the answer under way. */
|
|
272
|
+
"sessions.sideCancel": {
|
|
273
|
+
params: z.object({ sessionId: z.string().min(1), sideId: z.string().min(1) }),
|
|
274
|
+
result: empty,
|
|
275
|
+
},
|
|
276
|
+
/** Ends a side conversation: it is forgotten, and whatever the agent kept for it let go. */
|
|
277
|
+
"sessions.sideClose": {
|
|
278
|
+
params: z.object({ sessionId: z.string().min(1), sideId: z.string().min(1) }),
|
|
279
|
+
result: empty,
|
|
280
|
+
},
|
|
221
281
|
/** The commands this session left running in the background, and the ones that have ended; newest first. */
|
|
222
282
|
"sessions.tasks": {
|
|
223
283
|
params: z.object({ sessionId: z.string().min(1) }),
|
|
@@ -532,6 +592,8 @@ const coreMethods = {
|
|
|
532
592
|
"terminals.create": {
|
|
533
593
|
params: z.object({
|
|
534
594
|
cwd: z.string().optional(),
|
|
595
|
+
/** Open it for this session, in its directory unless `cwd` says otherwise (see `TerminalInfo.sessionId`). */
|
|
596
|
+
sessionId: z.string().optional(),
|
|
535
597
|
/** Run this in the shell right away (the shell stays open after it). */
|
|
536
598
|
command: z.string().max(4000).optional(),
|
|
537
599
|
cols: z.number().int().min(10).max(1000).default(80),
|
|
@@ -666,7 +728,7 @@ const coreMethods = {
|
|
|
666
728
|
},
|
|
667
729
|
} as const;
|
|
668
730
|
|
|
669
|
-
export const methods: typeof coreMethods & typeof acpMethods = { ...coreMethods, ...acpMethods };
|
|
731
|
+
export const methods: typeof coreMethods & typeof acpMethods & typeof mcpMethods = { ...coreMethods, ...acpMethods, ...mcpMethods };
|
|
670
732
|
|
|
671
733
|
export type MethodName = keyof typeof methods;
|
|
672
734
|
export type MethodParams<M extends MethodName> = z.input<(typeof methods)[M]["params"]>;
|
|
@@ -690,6 +752,8 @@ export const notifications = {
|
|
|
690
752
|
*/
|
|
691
753
|
"session.window": z.object({ sessionId: z.string(), startSeq: z.number().int().nonnegative() }),
|
|
692
754
|
"session.summary": z.object({ session: sessionSummarySchema }),
|
|
755
|
+
/** A side conversation changed (to every client), or ended (`closed`). */
|
|
756
|
+
"session.side": z.object({ sessionId: z.string(), side: sideChatSchema, closed: z.boolean().optional() }),
|
|
693
757
|
/** A session was deleted (from any device, or natively by the agent). */
|
|
694
758
|
"session.removed": z.object({ sessionId: z.string() }),
|
|
695
759
|
"session.preview.show": z.object({ sessionId: z.string() }),
|
package/src/side.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A side conversation: questions about a session that are answered from what
|
|
5
|
+
* it knows but don't enter it, the agent's own feature (Codex's `/side`,
|
|
6
|
+
* Claude's and Grok's `/btw`). It lives on the computer only while it is
|
|
7
|
+
* open; nothing of it is in the session's history.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const sideMessageSchema = z.object({
|
|
11
|
+
id: z.string(),
|
|
12
|
+
role: z.enum(["user", "assistant"]),
|
|
13
|
+
text: z.string(),
|
|
14
|
+
/** What the agent did on the way to an answer (a file it read, a command it ran), one line each. */
|
|
15
|
+
activity: z.array(z.string()).optional(),
|
|
16
|
+
/** The answer didn't come: why. */
|
|
17
|
+
error: z.string().optional(),
|
|
18
|
+
});
|
|
19
|
+
export type SideMessage = z.infer<typeof sideMessageSchema>;
|
|
20
|
+
|
|
21
|
+
export const sideChatSchema = z.object({
|
|
22
|
+
sideId: z.string(),
|
|
23
|
+
createdAt: z.number(),
|
|
24
|
+
/** Answering the last question. */
|
|
25
|
+
running: z.boolean(),
|
|
26
|
+
/** Goes up with every change: a client keeps the copy with the highest. */
|
|
27
|
+
revision: z.number().int().nonnegative(),
|
|
28
|
+
messages: z.array(sideMessageSchema),
|
|
29
|
+
});
|
|
30
|
+
export type SideChat = z.infer<typeof sideChatSchema>;
|
package/src/updates.ts
CHANGED
|
@@ -21,8 +21,22 @@ export type ToolCallLocation = z.infer<typeof toolCallLocationSchema>;
|
|
|
21
21
|
* Host drivers fill it from each agent's own tool names; clients render it the
|
|
22
22
|
* same way for every agent and fall back to `title` + `kind` without it.
|
|
23
23
|
*/
|
|
24
|
+
export const mcpAppDisplayModeSchema = z.enum(["inline", "fullscreen"]);
|
|
25
|
+
|
|
24
26
|
export const toolDetailSchema = z.discriminatedUnion("type", [
|
|
25
|
-
z.object({
|
|
27
|
+
z.object({
|
|
28
|
+
type: z.literal("mcp"),
|
|
29
|
+
server: z.string(),
|
|
30
|
+
tool: z.string(),
|
|
31
|
+
/**
|
|
32
|
+
* The tool has a page of its own (an MCP App), which `mcp.resource` reads.
|
|
33
|
+
* Codex sessions only. It may arrive in a later `tool_call_update` than the
|
|
34
|
+
* call's card, when the host had to look the tool up first. `connector`:
|
|
35
|
+
* the Codex connector (server `codex_apps`) the page belongs to, which
|
|
36
|
+
* `mcp.call` needs for that server.
|
|
37
|
+
*/
|
|
38
|
+
app: z.object({ resourceUri: z.string(), displayMode: mcpAppDisplayModeSchema.optional(), connector: z.string().optional() }).optional(),
|
|
39
|
+
}),
|
|
26
40
|
z.object({
|
|
27
41
|
type: z.literal("subagent"),
|
|
28
42
|
/** spawn starts a sub-agent (its work nests under this call); the rest talk to one. */
|