@nexus-code/remote-protocol 0.0.0-stage → 0.2.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/src/sealed.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * What a sealed message carries, which only the account's devices can open.
3
+ *
4
+ * Sealing (v1): XChaCha20-Poly1305 with the room key, a fresh random 24-byte
5
+ * nonce per message, and the frame's routing as associated data (aadOf), so
6
+ * the relay can neither read a message nor pass it off as another's. The
7
+ * room key comes from the account key (see the account key design); every
8
+ * device of the account holds it. The bytes sealed are the message's JSON
9
+ * in UTF-8.
10
+ */
11
+ import { z } from "zod";
12
+ import { type DeviceId, DeviceIdSchema } from "./relay.js";
13
+ import { RpcMessageSchema } from "./rpc.js";
14
+
15
+ /** The version of what is sealed; a device tells it in its hello. */
16
+ export const PROTOCOL_VERSION = 1;
17
+
18
+ /**
19
+ * This package's version, which a device tells in its hello (`schema`):
20
+ * the methods a device serves are those of its version, so another can
21
+ * leave out what an older one does not have. Kept equal to package.json's.
22
+ */
23
+ export const SCHEMA_VERSION = "0.2.0";
24
+
25
+ /** The HKDF info that makes the room key from the account key. */
26
+ export const ROOM_KEY_INFO = "nexus-remote/room-key/v1";
27
+
28
+ /**
29
+ * The associated data a message is sealed with: its routing, as the relay
30
+ * delivers it. A message the relay sends on to another device, or with
31
+ * another seq, does not open.
32
+ */
33
+ export function aadOf(frame: { from: DeviceId; to: string; seq: number; kid: number }): string {
34
+ return `nexus-remote/v${PROTOCOL_VERSION}|${frame.from}|${frame.to}|${frame.seq}|${frame.kid}`;
35
+ }
36
+
37
+ /**
38
+ * What a device can do for another, as it says in its hello. The other
39
+ * side asks only for what is listed; a newer device lists more.
40
+ */
41
+ export const CAPABILITIES = [
42
+ /** sessions.list and sessions.watch, profiles.list. */
43
+ "sessions",
44
+ /** Reading a chat: chat.read and chat.watch, its settings, commands, panel, images, tool calls; history.list. */
45
+ "chat.read",
46
+ /** Acting in a chat: sending, answering, stopping, settings, commands, opening, resuming, renaming, closing. */
47
+ "chat.control",
48
+ /** terminal.read and terminal.watch: a terminal's screen, as text. */
49
+ "terminal.read",
50
+ /** Typing into a terminal, opening, renaming and closing one. */
51
+ "terminal.control",
52
+ /** files.list and files.read, in a workspace's folders. */
53
+ "files",
54
+ /** git.status, git.diff and git.log of a workspace. */
55
+ "git",
56
+ /**
57
+ * Takes agents' requests (`from`: nexus on another device) to act in its
58
+ * chats and terminals, as the user set it (Settings › Remote: ask or
59
+ * allow). Without it, a request with `from` is refused, whatever
60
+ * chat.control and terminal.control say: those are for people.
61
+ */
62
+ "agents",
63
+ ] as const;
64
+ export const CapabilitySchema = z.string();
65
+ export type Capability = (typeof CAPABILITIES)[number];
66
+
67
+ /**
68
+ * A device introduces itself: to all the room as it joins, and to one that
69
+ * just joined. What it serves of its own sessions is `serves`; a device
70
+ * that only watches others (a phone) serves nothing.
71
+ */
72
+ export const HelloSchema = z.object({
73
+ type: z.literal("hello"),
74
+ protocol: z.number().int().positive(),
75
+ device: z.object({
76
+ id: DeviceIdSchema,
77
+ name: z.string(),
78
+ platform: z.string(),
79
+ }),
80
+ app: z.object({
81
+ name: z.string(),
82
+ version: z.string(),
83
+ /** The app's color theme now, by its id ("github-dark"): a phone may follow it. */
84
+ theme: z.string().optional(),
85
+ }),
86
+ serves: z.array(CapabilitySchema),
87
+ /**
88
+ * The protocol package's version it speaks (SCHEMA_VERSION, "0.2.0");
89
+ * absent before 0.2.0. A method it does not have is answered MethodNotFound.
90
+ */
91
+ schema: z.string().max(40).optional(),
92
+ /** Whether it wants the hello of devices already there: true as it joins. */
93
+ reply: z.boolean(),
94
+ });
95
+ export type Hello = z.infer<typeof HelloSchema>;
96
+
97
+ /** A sealed message's content. */
98
+ export const SealedMessageSchema = z.union([HelloSchema, RpcMessageSchema]);
99
+ export type SealedMessage = z.infer<typeof SealedMessageSchema>;
package/src/view.ts ADDED
@@ -0,0 +1,297 @@
1
+ /**
2
+ * A chat as another device draws it: the conversation the serving device's
3
+ * app already made of its program's log, trimmed of what only that app
4
+ * uses. The shapes follow the desktop app's chat model, so its chat view can
5
+ * draw a remote one as it draws its own; Claude Code's and Codex's own
6
+ * formats stay on the serving device, which keeps up with their versions.
7
+ *
8
+ * An item or call of a kind this version does not know stays parseable
9
+ * (UnknownItemSchema): a newer device may send more, and an older one skips it.
10
+ */
11
+ import { z } from "zod";
12
+
13
+ /** An image a message or a tool result holds. Its bytes stay on the serving device in v1. */
14
+ export const ImageRefSchema = z.object({
15
+ mediaType: z.string(),
16
+ /** How many bytes the image takes. */
17
+ bytes: z.number().int().nonnegative(),
18
+ });
19
+ export type ImageRef = z.infer<typeof ImageRefSchema>;
20
+
21
+ const at = z.number().optional();
22
+
23
+ export const UserItemSchema = z.object({
24
+ kind: z.literal("user"),
25
+ key: z.string(),
26
+ text: z.string(),
27
+ images: z.array(ImageRefSchema),
28
+ /** sent: written to the program; taken: the program read it. */
29
+ state: z.enum(["sent", "taken"]),
30
+ /** Written during a turn and not read yet: it waits above the composer. */
31
+ queued: z.boolean().optional(),
32
+ at,
33
+ });
34
+
35
+ export const AssistantBlockSchema = z.discriminatedUnion("type", [
36
+ z.object({ type: z.literal("text"), text: z.string() }),
37
+ z.object({ type: z.literal("thinking"), text: z.string() }),
38
+ z.object({ type: z.literal("tool"), toolUseId: z.string() }),
39
+ ]);
40
+
41
+ export const AssistantItemSchema = z.object({
42
+ kind: z.literal("assistant"),
43
+ key: z.string(),
44
+ messageId: z.string(),
45
+ blocks: z.array(AssistantBlockSchema),
46
+ at,
47
+ });
48
+ export type ViewAssistantItem = z.infer<typeof AssistantItemSchema>;
49
+
50
+ export const NoticeItemSchema = z.object({
51
+ kind: z.literal("notice"),
52
+ key: z.string(),
53
+ /** interrupted, exited, restarted, compacted, cleared, error…: a code the app names. */
54
+ code: z.string(),
55
+ detail: z.string().optional(),
56
+ at,
57
+ });
58
+
59
+ /**
60
+ * Where an agent that sent a message can be answered: its tab, on the
61
+ * device `deviceId` (absent: the device this chat runs on). `nexus chat
62
+ * send` takes it as it is.
63
+ */
64
+ export const ReplyAddressSchema = z.object({
65
+ deviceId: z.string().optional(),
66
+ workspaceId: z.string(),
67
+ tabId: z.string(),
68
+ });
69
+ export type ReplyAddress = z.infer<typeof ReplyAddressSchema>;
70
+
71
+ /** A message the program took that the user did not write: another session's, a channel's. */
72
+ export const ReceivedItemSchema = z.object({
73
+ kind: z.literal("received"),
74
+ key: z.string(),
75
+ from: z.object({
76
+ kind: z.string(),
77
+ name: z.string().optional(),
78
+ agent: z.string().optional(),
79
+ /** Sent from another device of the account: which one, as it names itself. */
80
+ device: z.object({ id: z.string(), name: z.string() }).optional(),
81
+ /** Where the sender takes an answer. */
82
+ replyTo: ReplyAddressSchema.optional(),
83
+ }),
84
+ text: z.string(),
85
+ at,
86
+ });
87
+
88
+ export const TaskEndItemSchema = z.object({
89
+ kind: z.literal("task_end"),
90
+ key: z.string(),
91
+ taskId: z.string(),
92
+ status: z.string(),
93
+ description: z.string().optional(),
94
+ taskType: z.string().optional(),
95
+ summary: z.string().optional(),
96
+ toolUseId: z.string().optional(),
97
+ wakes: z.boolean().optional(),
98
+ at,
99
+ });
100
+
101
+ export const ShellItemSchema = z.object({
102
+ kind: z.literal("shell"),
103
+ key: z.string(),
104
+ command: z.string(),
105
+ stdout: z.string().optional(),
106
+ stderr: z.string().optional(),
107
+ at,
108
+ });
109
+
110
+ /** The item kinds this version knows. */
111
+ export const ITEM_KINDS: ReadonlySet<string> = new Set([
112
+ "user",
113
+ "assistant",
114
+ "notice",
115
+ "received",
116
+ "task_end",
117
+ "shell",
118
+ ]);
119
+
120
+ /**
121
+ * An item of a kind this version does not know: kept so the list stays
122
+ * whole, and not drawn. A known kind that does not fit its shape is no such item.
123
+ */
124
+ export const UnknownItemSchema = z
125
+ .object({ kind: z.string().refine((kind) => !ITEM_KINDS.has(kind)), key: z.string() })
126
+ .passthrough();
127
+
128
+ export const ViewItemSchema = z.union([
129
+ UserItemSchema,
130
+ AssistantItemSchema,
131
+ NoticeItemSchema,
132
+ ReceivedItemSchema,
133
+ TaskEndItemSchema,
134
+ ShellItemSchema,
135
+ UnknownItemSchema,
136
+ ]);
137
+ export type ViewItem = z.infer<typeof ViewItemSchema>;
138
+
139
+ /** Most characters of a tool's result text sent; the rest is cut (`truncated`). */
140
+ export const MAX_RESULT_TEXT = 64 * 1024;
141
+
142
+ export const ViewToolCallSchema = z.object({
143
+ id: z.string(),
144
+ name: z.string(),
145
+ displayName: z.string().optional(),
146
+ input: z.unknown(),
147
+ /** streaming, running, done, error, denied, stopped. */
148
+ status: z.string(),
149
+ result: z
150
+ .object({
151
+ text: z.string(),
152
+ isError: z.boolean(),
153
+ /** The program's structured result: a run's stdout, an edit's patch. */
154
+ structured: z.unknown().optional(),
155
+ images: z.array(ImageRefSchema),
156
+ /** The text was longer than MAX_RESULT_TEXT and is cut there. */
157
+ truncated: z.boolean().optional(),
158
+ })
159
+ .optional(),
160
+ /** The agent call it runs under; null on the main thread. */
161
+ parentToolUseId: z.string().nullable(),
162
+ });
163
+ export type ViewToolCall = z.infer<typeof ViewToolCallSchema>;
164
+
165
+ /** A tool the program asks to run, which the user allows or denies. */
166
+ export const ViewPermissionSchema = z.object({
167
+ requestId: z.string(),
168
+ toolName: z.string(),
169
+ displayName: z.string().optional(),
170
+ input: z.record(z.unknown()),
171
+ description: z.string().optional(),
172
+ decisionReason: z.string().optional(),
173
+ toolUseId: z.string().optional(),
174
+ /** A subagent's request. */
175
+ agentId: z.string().optional(),
176
+ /** The call as the card previews it, when the input alone does not tell it. */
177
+ preview: ViewToolCallSchema.optional(),
178
+ /**
179
+ * What "always allow" would add, one choice each, in the serving
180
+ * device's words ("Allow Bash(npm test:*) in this folder"): chat.answer
181
+ * picks them by index.
182
+ */
183
+ suggestions: z.array(z.object({ label: z.string() })).optional(),
184
+ });
185
+ export type ViewPermission = z.infer<typeof ViewPermissionSchema>;
186
+
187
+ /** A task the program runs beside the turn: a background run, a subagent. */
188
+ export const ViewTaskSchema = z.object({
189
+ taskId: z.string(),
190
+ type: z.string(),
191
+ description: z.string(),
192
+ status: z.string(),
193
+ toolUseId: z.string().optional(),
194
+ startedAt: z.number().optional(),
195
+ endedAt: z.number().optional(),
196
+ summary: z.string().optional(),
197
+ activity: z.string().optional(),
198
+ });
199
+ export type ViewTask = z.infer<typeof ViewTaskSchema>;
200
+
201
+ /** The reply being written: what the program streamed of its next message so far. */
202
+ export const ViewStreamingSchema = z.object({
203
+ messageId: z.string(),
204
+ blocks: z.array(
205
+ z.object({
206
+ type: z.enum(["text", "thinking", "tool_use"]),
207
+ text: z.string(),
208
+ toolUseId: z.string().optional(),
209
+ }),
210
+ ),
211
+ });
212
+ export type ViewStreaming = z.infer<typeof ViewStreamingSchema>;
213
+
214
+ /** Where a chat stands, apart from its messages. */
215
+ export const ChatStatusSchema = z.object({
216
+ agent: z.enum(["claude", "codex"]),
217
+ title: z.string(),
218
+ /** The model it runs, as its program names it. */
219
+ model: z.string().optional(),
220
+ effort: z.string().optional(),
221
+ permissionMode: z.string().optional(),
222
+ /** A turn is under way. */
223
+ running: z.boolean(),
224
+ process: z.enum(["none", "running", "exited"]),
225
+ turn: z
226
+ .object({
227
+ startedAt: z.number().optional(),
228
+ outputTokens: z.number(),
229
+ /** requesting, compacting… */
230
+ status: z.string().optional(),
231
+ })
232
+ .nullable(),
233
+ streaming: ViewStreamingSchema.nullable(),
234
+ permissions: z.array(ViewPermissionSchema),
235
+ });
236
+ export type ChatStatus = z.infer<typeof ChatStatusSchema>;
237
+
238
+ /** One step of the agent's to-do list. */
239
+ export const ViewTodoSchema = z.object({
240
+ content: z.string(),
241
+ /** pending, in_progress or completed. */
242
+ status: z.string(),
243
+ });
244
+
245
+ /** A subagent the turn called (an Agent tool call). */
246
+ export const ViewAgentRowSchema = z.object({
247
+ toolUseId: z.string(),
248
+ description: z.string(),
249
+ /** running, completed, failed or stopped. */
250
+ status: z.string(),
251
+ startedAt: z.number().optional(),
252
+ endedAt: z.number().optional(),
253
+ });
254
+
255
+ /** A goal the chat works toward (/goal), and where it stands. */
256
+ export const ViewGoalSchema = z.object({
257
+ condition: z.string(),
258
+ /** active, paused, met or failed. */
259
+ status: z.string(),
260
+ setAt: z.number().optional(),
261
+ /** How many checks found it unmet so far. */
262
+ checks: z.number().int().nonnegative(),
263
+ /** Once it ended: why. */
264
+ reason: z.string().optional(),
265
+ });
266
+ export type ViewGoal = z.infer<typeof ViewGoalSchema>;
267
+
268
+ /**
269
+ * What the chat's side panel shows beside the conversation: the to-do
270
+ * list, the tasks and subagents, the files it changed, the context it
271
+ * fills, what it cost, and its goal.
272
+ */
273
+ export const ChatPanelSchema = z.object({
274
+ todos: z.array(ViewTodoSchema),
275
+ /** Background runs and the like, by the program's task ids (chat.stopTask stops one). */
276
+ tasks: z.array(ViewTaskSchema),
277
+ agents: z.array(ViewAgentRowSchema),
278
+ changedFiles: z.array(
279
+ z.object({
280
+ /** Absolute, on the serving device, as the chat's tool calls name it. */
281
+ path: z.string(),
282
+ added: z.number().int().nonnegative().optional(),
283
+ removed: z.number().int().nonnegative().optional(),
284
+ }),
285
+ ),
286
+ /** The context window: tokens in it, and how many it takes; null before the program said. */
287
+ context: z
288
+ .object({
289
+ usedTokens: z.number().int().nonnegative(),
290
+ maxTokens: z.number().int().positive(),
291
+ })
292
+ .nullable(),
293
+ /** What the conversation cost so far, in US dollars, when the program tells it. */
294
+ costUsd: z.number().nonnegative().optional(),
295
+ goal: ViewGoalSchema.nullable(),
296
+ });
297
+ export type ChatPanel = z.infer<typeof ChatPanelSchema>;
package/src/voice.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The voice socket: what a device says goes to the server as audio and
3
+ * comes back as text while it speaks (streaming speech to text).
4
+ *
5
+ * GET /voice?v=1&lang=<language> upgrades to a WebSocket, signed in as
6
+ * /relay is (a Bearer header, or ?ticket= from POST /relay/ticket). Then:
7
+ * - server: `ready`, naming the audio format and sample rate to send, and
8
+ * the most seconds this session may take;
9
+ * - client: binary frames of audio in that format, about 100 ms each, then
10
+ * `end` when the user stops;
11
+ * - server, as they come: `partial` (the words so far of the segment being
12
+ * spoken; each replaces the last) and `final` (a finished segment, to
13
+ * append; it clears the partial). Every partial and final after the
14
+ * session's first final starts with the space its language puts between
15
+ * segments (none for ja or zh): the text is the finals joined as they
16
+ * came, then the partial, with nothing trimmed or added between;
17
+ * - server: `limit` if the audio ran past what the session or the
18
+ * account's quota allows (audio past it is dropped; finals still come);
19
+ * - server: `done` with the seconds counted, once every final is out; then
20
+ * it closes (1000). An `error` ends the session; it closes after.
21
+ */
22
+ import { z } from "zod";
23
+
24
+ export const VOICE_VERSION = 1;
25
+
26
+ /** Most bytes in one audio frame (2 s of 16 kHz 16-bit mono); an even count. */
27
+ export const MAX_AUDIO_FRAME_BYTES = 64 * 1024;
28
+
29
+ /** The language to hear, BCP 47-ish: "ko", "en", "en-US", or "multi" to detect it. */
30
+ export const VoiceLanguageSchema = z.string().regex(/^([a-z]{2,3}(-[A-Z]{2})?|multi)$/);
31
+
32
+ /** What a device sends besides audio: that the user stopped speaking. */
33
+ export const VoiceClientMessageSchema = z.object({ t: z.literal("end") });
34
+ export type VoiceClientMessage = z.infer<typeof VoiceClientMessageSchema>;
35
+
36
+ export const VoiceReadySchema = z.object({
37
+ t: z.literal("ready"),
38
+ format: z.literal("pcm_s16le"),
39
+ /** The rate to send at; it may change with the provider, so it is not to be assumed. */
40
+ sampleRate: z.number().int().positive(),
41
+ /** The fewer of the session's limit and what is left of the month's quota. */
42
+ maxSeconds: z.number().nonnegative(),
43
+ });
44
+
45
+ export const VoiceServerMessageSchema = z.discriminatedUnion("t", [
46
+ VoiceReadySchema,
47
+ z.object({ t: z.literal("partial"), text: z.string() }),
48
+ z.object({ t: z.literal("final"), text: z.string() }),
49
+ z.object({ t: z.literal("limit"), reason: z.enum(["session", "quota"]) }),
50
+ z.object({ t: z.literal("done"), seconds: z.number().nonnegative() }),
51
+ z.object({
52
+ t: z.literal("error"),
53
+ /** unauthorized: the device's sign-in ended (the socket closes 4001 after it). */
54
+ code: z.enum(["bad-frame", "provider-error", "unauthorized"]),
55
+ message: z.string(),
56
+ }),
57
+ ]);
58
+ export type VoiceServerMessage = z.infer<typeof VoiceServerMessageSchema>;
59
+
60
+ /** GET /voice/usage: this month's seconds, used and allowed. */
61
+ export const VoiceUsageSchema = z.object({
62
+ /** The month, as YYYY-MM. */
63
+ period: z.string(),
64
+ usedSeconds: z.number().nonnegative(),
65
+ limitSeconds: z.number().nonnegative(),
66
+ });
67
+ export type VoiceUsage = z.infer<typeof VoiceUsageSchema>;