@linkshell/wire 0.2.7 → 0.2.9

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/peer.ts CHANGED
@@ -91,13 +91,21 @@ export class RpcPeer {
91
91
  receive(text: string): void {
92
92
  for (const line of text.split("\n")) {
93
93
  if (!line.trim()) continue;
94
- let message: Record<string, unknown>;
94
+ let message: Record<string, unknown> | Record<string, unknown>[];
95
95
  try {
96
- message = JSON.parse(line) as Record<string, unknown>;
96
+ message = JSON.parse(line) as typeof message;
97
97
  } catch {
98
98
  continue;
99
99
  }
100
- this.dispatch(message);
100
+ if (Array.isArray(message)) {
101
+ if (!message.length || message.length > 128) {
102
+ this.write({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "invalid JSON-RPC batch" } });
103
+ continue;
104
+ }
105
+ const responses: Record<string, unknown>[] = [];
106
+ void Promise.all(message.map((entry) => Promise.resolve(this.dispatch(entry, (response) => { responses.push(response); }))))
107
+ .then(() => { if (responses.length) this.write(responses); });
108
+ } else void this.dispatch(message);
101
109
  }
102
110
  }
103
111
 
@@ -116,7 +124,13 @@ export class RpcPeer {
116
124
  return this.closed;
117
125
  }
118
126
 
119
- private dispatch(message: Record<string, unknown>): void {
127
+ hasPendingRequest(id: RpcId): boolean { return this.pending.has(id); }
128
+
129
+ private dispatch(message: Record<string, unknown>, respond = (response: Record<string, unknown>) => this.write(response)): void | Promise<void> {
130
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
131
+ respond({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "invalid JSON-RPC request" } });
132
+ return;
133
+ }
120
134
  const id = message.id as RpcId | undefined;
121
135
  const method = typeof message.method === "string" ? message.method : undefined;
122
136
 
@@ -139,27 +153,27 @@ export class RpcPeer {
139
153
 
140
154
  const handler = this.options.onRequest;
141
155
  if (!handler) {
142
- this.write({ jsonrpc: "2.0", id, error: { code: RPC_METHOD_NOT_FOUND, message: `no handler for ${method}` } });
156
+ respond({ jsonrpc: "2.0", id, error: { code: RPC_METHOD_NOT_FOUND, message: `no handler for ${method}` } });
143
157
  return;
144
158
  }
145
- Promise.resolve()
159
+ return Promise.resolve()
146
160
  .then(() => handler(method, message.params, id))
147
161
  .then(
148
162
  (result) => {
149
163
  if (result === ABANDON) return;
150
- this.write({ jsonrpc: "2.0", id, result: result ?? {} });
164
+ respond({ jsonrpc: "2.0", id, result: result ?? {} });
151
165
  },
152
166
  (error: unknown) => {
153
167
  const rpcError =
154
168
  error instanceof RpcError
155
169
  ? error
156
170
  : new RpcError(RPC_APP_ERROR, error instanceof Error ? error.message : String(error), { code: "internal" });
157
- this.write({ jsonrpc: "2.0", id, error: rpcError.toObject() });
171
+ respond({ jsonrpc: "2.0", id, error: rpcError.toObject() });
158
172
  },
159
173
  );
160
174
  }
161
175
 
162
- private write(message: Record<string, unknown>): void {
176
+ private write(message: Record<string, unknown> | Record<string, unknown>[]): void {
163
177
  if (this.closed) return;
164
178
  try {
165
179
  this.options.send(JSON.stringify(message));
@@ -0,0 +1,39 @@
1
+ import type { Question, QuestionAnswer } from "./model.js";
2
+
3
+ /** Shared validation keeps the phone's feedback and the host's acceptance identical. */
4
+ export function questionAnswerError(question: Question, answer: QuestionAnswer | undefined): string | undefined {
5
+ const label = question.header ?? question.text;
6
+ const values = answer?.values ?? [];
7
+ const c = question.constraints;
8
+ if (values.length > 1 && question.kind !== "choices") return `${label}只能填写一个答案`;
9
+ if (question.options && values.some((value) => !question.options!.some((option) => option.value === value))) return `${label}包含无效选项`;
10
+ const present = c?.type === "array" ? answer !== undefined : c?.type === "string" ? values[0] !== undefined || !!answer?.other?.trim() : values.some((value) => value !== "") || !!answer?.other?.trim();
11
+ if (!present) return question.required ? `还没回答:${label}` : undefined;
12
+ if (!c) return;
13
+ const value = values[0] ?? "";
14
+ if (c.type === "number" || c.type === "integer") {
15
+ const number = Number(value);
16
+ if (!value.trim() || !Number.isFinite(number) || (c.type === "integer" && !Number.isInteger(number))) return `${label}需要填写${c.type === "integer" ? "整数" : "数字"}`;
17
+ if (c.minimum !== undefined && number < c.minimum) return `${label}不能小于 ${c.minimum}`;
18
+ if (c.maximum !== undefined && number > c.maximum) return `${label}不能大于 ${c.maximum}`;
19
+ }
20
+ if (c.type === "array") {
21
+ if (new Set(values).size !== values.length) return `${label}不能重复选择`;
22
+ if (c.minItems !== undefined && values.length < c.minItems) return `${label}至少选择 ${c.minItems} 项`;
23
+ if (c.maxItems !== undefined && values.length > c.maxItems) return `${label}最多选择 ${c.maxItems} 项`;
24
+ }
25
+ if (c.type === "string") {
26
+ const length = [...value].length;
27
+ if (c.minLength !== undefined && length < c.minLength) return `${label}至少需要 ${c.minLength} 个字符`;
28
+ if (c.maxLength !== undefined && length > c.maxLength) return `${label}最多允许 ${c.maxLength} 个字符`;
29
+ if (c.pattern) {
30
+ try { if (!new RegExp(c.pattern, "u").test(value)) return `${label}格式不符合要求`; }
31
+ catch { return `${label}的格式规则无效`; }
32
+ }
33
+ if (c.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return `${label}需要有效的邮箱地址`;
34
+ if (c.format === "uri") { try { new URL(value); } catch { return `${label}需要完整的网址`; } }
35
+ if (c.format === "date" && (!/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString().slice(0, 10) !== value)) return `${label}需要有效日期(YYYY-MM-DD)`;
36
+ if (c.format === "date-time" && (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/i.test(value) || !Number.isFinite(Date.parse(value)))) return `${label}需要包含时区的有效日期时间`;
37
+ }
38
+ return;
39
+ }
package/src/rpc.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { goalChangeSchema, sessionGoalSchema } from "./goal.js";
3
+ import { acpAgentSettingsSchema, acpFeaturesSchema, acpRemoteAgentSchema, acpProviderSchema } from "./acp.js";
4
+ import { editorRequestSchema, editorResultSchema } from "./editor.js";
3
5
  import {
4
6
  backgroundTaskSchema,
7
+ agentInfoSchema,
5
8
  gatewayStatusSchema,
6
9
  machineInfoSchema,
7
10
  portInfoSchema,
@@ -13,8 +16,9 @@ import {
13
16
  gitInfoSchema,
14
17
  worktreeEntrySchema,
15
18
  questionAnswerSchema,
19
+ pendingPermissionSchema,
16
20
  } from "./model.js";
17
- import { contentBlockSchema, sessionEventSchema } from "./updates.js";
21
+ import { contentBlockSchema, sessionEventSchema, sessionNoticeSchema } from "./updates.js";
18
22
 
19
23
  // ── JSON-RPC 2.0 framing ─────────────────────────────────────────────
20
24
 
@@ -72,7 +76,36 @@ export const directoryEntrySchema = z.object({
72
76
  });
73
77
  export type DirectoryEntry = z.infer<typeof directoryEntrySchema>;
74
78
 
75
- export const methods = {
79
+ const acpMethods = {
80
+ "agents.editor": { params: editorRequestSchema, result: editorResultSchema },
81
+ "agents.acp": {
82
+ params: z.object({ agent: z.string().min(1), sessionId: z.string().optional() }),
83
+ result: z.object({ features: acpFeaturesSchema, settings: acpAgentSettingsSchema, interactions: z.array(pendingPermissionSchema) }),
84
+ },
85
+ "agents.pending": { params: empty, result: z.object({ interactions: z.array(z.object({ agent: z.string(), request: pendingPermissionSchema })) }) },
86
+ "agents.configure": {
87
+ params: z.object({ agent: z.string().min(1), settings: acpAgentSettingsSchema, sessionId: z.string().optional() }),
88
+ result: empty,
89
+ },
90
+ "agents.authenticate": {
91
+ params: z.object({ agent: z.string().min(1), methodId: z.string().optional(), logout: z.boolean().optional() }),
92
+ result: z.object({ terminalId: z.string().optional() }),
93
+ },
94
+ "agents.respond": {
95
+ params: z.object({ agent: z.string().min(1), requestId: z.string().min(1), optionId: z.string().optional(), answers: z.array(questionAnswerSchema).optional() }),
96
+ result: empty,
97
+ },
98
+ "agents.providers": {
99
+ params: z.object({ agent: z.string().min(1), operation: z.enum(["list", "set", "disable"]), config: z.object({ providerId: z.string(), apiType: z.string().optional(), baseUrl: z.string().url().optional(), headers: z.record(z.string()).optional() }).optional() }),
100
+ result: z.object({ providers: z.array(acpProviderSchema) }),
101
+ },
102
+ "agents.custom": {
103
+ params: z.object({ save: acpRemoteAgentSchema.optional(), remove: z.string().optional() }),
104
+ result: z.object({ agents: z.array(acpRemoteAgentSchema) }),
105
+ },
106
+ } as const;
107
+
108
+ const coreMethods = {
76
109
  "machine.info": {
77
110
  params: empty,
78
111
  result: machineInfoSchema,
@@ -152,6 +185,10 @@ export const methods = {
152
185
  params: z.object({ sessionId: z.string().min(1) }),
153
186
  result: z.object({ subagents: z.array(subagentInfoSchema) }),
154
187
  },
188
+ "sessions.cancelSubagent": {
189
+ params: z.object({ sessionId: z.string().min(1), nativeSessionId: z.string().min(1) }),
190
+ result: empty,
191
+ },
155
192
  /**
156
193
  * One sub-agent's conversation: the call that started it and the events
157
194
  * under it (the latest 2000), oldest first. Later ones arrive on the
@@ -575,6 +612,8 @@ export const methods = {
575
612
  },
576
613
  } as const;
577
614
 
615
+ export const methods: typeof coreMethods & typeof acpMethods = { ...coreMethods, ...acpMethods };
616
+
578
617
  export type MethodName = keyof typeof methods;
579
618
  export type MethodParams<M extends MethodName> = z.input<(typeof methods)[M]["params"]>;
580
619
  export type MethodResult<M extends MethodName> = z.infer<(typeof methods)[M]["result"]>;
@@ -586,6 +625,9 @@ export function isMethodName(name: string): name is MethodName {
586
625
  // ── Notifications (host → client) ────────────────────────────────────
587
626
 
588
627
  export const notifications = {
628
+ "agent.changed": z.object({ agent: agentInfoSchema }),
629
+ "agent.interaction": z.object({ agent: z.string(), request: z.union([pendingPermissionSchema, z.object({ requestId: z.string(), resolved: z.literal(true) })]) }),
630
+ "session.notice": z.object({ sessionId: z.string(), notice: sessionNoticeSchema }),
589
631
  "session.event": sessionEventSchema,
590
632
  /**
591
633
  * Sent before a subscription's backlog when it doesn't continue from the
package/src/updates.ts CHANGED
@@ -1,54 +1,15 @@
1
1
  import { z } from "zod";
2
+ import { asyncQuestionSchema } from "./async-questions.js";
2
3
  import { sessionGoalSchema } from "./goal.js";
3
4
  import { backgroundTaskSchema, permissionOptionSchema, questionAnswerSchema, questionSchema, sessionDriverSchema, sessionStateSchema, toolKindSchema } from "./model.js";
5
+ import { contentBlockSchema, toolCallContentSchema, toolCallStatusSchema } from "./content.js";
6
+ export * from "./content.js";
4
7
  import { workflowAgentStateSchema, workflowSchema } from "./workflow.js";
5
8
 
6
9
  // Session updates mirror ACP's `SessionUpdate` shapes (discriminated by
7
10
  // `sessionUpdate`) so one renderer works for every agent. LinkShell-specific
8
11
  // updates use the `ls_` prefix.
9
12
 
10
- export const contentBlockSchema = z.discriminatedUnion("type", [
11
- z.object({ type: z.literal("text"), text: z.string() }),
12
- z.object({
13
- type: z.literal("image"),
14
- mimeType: z.string(),
15
- data: z.string().optional(),
16
- uri: z.string().optional(),
17
- }),
18
- z.object({
19
- type: z.literal("resource_link"),
20
- uri: z.string(),
21
- name: z.string(),
22
- /** What the link names: a file (default), a skill the user invoked, or another agent. */
23
- kind: z.enum(["file", "skill", "agent"]).optional(),
24
- }),
25
- ]);
26
- export type ContentBlock = z.infer<typeof contentBlockSchema>;
27
-
28
- export const toolCallStatusSchema = z.enum(["pending", "in_progress", "completed", "failed"]);
29
- export type ToolCallStatus = z.infer<typeof toolCallStatusSchema>;
30
-
31
- export const toolCallContentSchema = z.discriminatedUnion("type", [
32
- z.object({ type: z.literal("content"), content: contentBlockSchema }),
33
- /** ACP diff: full before/after text. */
34
- z.object({
35
- type: z.literal("diff"),
36
- path: z.string(),
37
- oldText: z.string().nullable().optional(),
38
- newText: z.string(),
39
- }),
40
- /** Unified diff, for agents (Codex) that report patches rather than file text. */
41
- z.object({
42
- type: z.literal("patch"),
43
- path: z.string(),
44
- change: z.enum(["add", "delete", "update"]),
45
- movePath: z.string().optional(),
46
- diff: z.string(),
47
- }),
48
- z.object({ type: z.literal("terminal"), terminalId: z.string() }),
49
- ]);
50
- export type ToolCallContent = z.infer<typeof toolCallContentSchema>;
51
-
52
13
  export const toolCallLocationSchema = z.object({
53
14
  path: z.string(),
54
15
  line: z.number().int().optional(),
@@ -71,6 +32,8 @@ export const toolDetailSchema = z.discriminatedUnion("type", [
71
32
  agentType: z.string().optional(),
72
33
  /** A named agent's address, distinct from its role. */
73
34
  name: z.string().optional(),
35
+ nativeSessionId: z.string().optional(),
36
+ canCancel: z.boolean().optional(),
74
37
  model: z.string().optional(),
75
38
  /** Agent outcome when a tool's coarse completed/failed state is insufficient. */
76
39
  state: workflowAgentStateSchema.optional(),
@@ -94,20 +57,30 @@ export const planEntrySchema = z.object({
94
57
  });
95
58
  export type PlanEntry = z.infer<typeof planEntrySchema>;
96
59
 
97
- export const stopReasonSchema = z.enum(["end_turn", "cancelled", "error", "max_tokens", "refusal"]);
60
+ export const stopReasonSchema = z.enum(["end_turn", "cancelled", "error", "max_tokens", "max_turn_requests", "refusal", "unknown"]);
98
61
  export type StopReason = z.infer<typeof stopReasonSchema>;
99
62
 
100
63
  export const sessionConfigOptionSchema = z.object({
101
64
  /** Agent-defined id; "model" and "mode" are the common ones. */
102
65
  id: z.string(),
103
66
  name: z.string(),
104
- category: z.enum(["model", "mode", "effort", "other"]).default("other"),
67
+ description: z.string().optional(),
68
+ type: z.enum(["select", "boolean"]).optional(),
69
+ category: z.enum(["model", "model_config", "mode", "effort", "other"]).default("other"),
105
70
  current: z.string(),
106
- values: z.array(z.object({ value: z.string(), name: z.string(), description: z.string().optional() })),
71
+ values: z.array(z.object({ value: z.string(), name: z.string(), description: z.string().optional(), group: z.string().optional() })),
107
72
  });
108
73
  export type SessionConfigOption = z.infer<typeof sessionConfigOptionSchema>;
109
74
 
110
75
  export const sessionUpdateSchema = z.discriminatedUnion("sessionUpdate", [
76
+ z.object({
77
+ sessionUpdate: z.literal("ls_message"),
78
+ messageId: z.string(), role: z.enum(["user", "agent", "thought", "session"]),
79
+ parentToolCallId: z.string().optional(),
80
+ content: z.array(contentBlockSchema).optional(),
81
+ append: z.boolean().optional(),
82
+ senderSessionId: z.string().optional(), recipientSessionId: z.string().optional(),
83
+ }),
111
84
  z.object({
112
85
  sessionUpdate: z.literal("user_message_chunk"),
113
86
  messageId: z.string().optional(),
@@ -131,9 +104,11 @@ export const sessionUpdateSchema = z.discriminatedUnion("sessionUpdate", [
131
104
  /** Set when a sub-agent made this call: the sub-agent's own (spawn) tool call. */
132
105
  parentToolCallId: z.string().optional(),
133
106
  title: z.string(),
107
+ name: z.string().optional(),
134
108
  kind: toolKindSchema,
135
109
  status: toolCallStatusSchema,
136
110
  rawInput: z.unknown().optional(),
111
+ rawOutput: z.unknown().optional(),
137
112
  content: z.array(toolCallContentSchema).optional(),
138
113
  locations: z.array(toolCallLocationSchema).optional(),
139
114
  detail: toolDetailSchema.optional(),
@@ -144,16 +119,28 @@ export const sessionUpdateSchema = z.discriminatedUnion("sessionUpdate", [
144
119
  parentToolCallId: z.string().optional(),
145
120
  status: toolCallStatusSchema.optional(),
146
121
  title: z.string().optional(),
122
+ name: z.string().optional(),
123
+ kind: toolKindSchema.optional(),
124
+ rawInput: z.unknown().optional(),
125
+ locations: z.array(toolCallLocationSchema).optional(),
147
126
  detail: toolDetailSchema.optional(),
148
127
  /** Replaces the tool call's content when present. */
149
128
  content: z.array(toolCallContentSchema).optional(),
150
129
  /** Streamed output appended to the tool call's running output. */
151
130
  appendOutput: z.string().optional(),
131
+ replaceOutput: z.string().optional(),
132
+ /** ACP v2 null/empty content clears every previous diff and attachment. */
133
+ replaceContent: z.boolean().optional(),
152
134
  rawOutput: z.unknown().optional(),
153
135
  }),
154
136
  z.object({
155
137
  sessionUpdate: z.literal("plan"),
156
138
  entries: z.array(planEntrySchema),
139
+ planId: z.string().optional(),
140
+ title: z.string().optional(),
141
+ markdown: z.string().nullable().optional(),
142
+ path: z.string().nullable().optional(),
143
+ removed: z.boolean().optional(),
157
144
  }),
158
145
  z.object({
159
146
  sessionUpdate: z.literal("ls_goal"),
@@ -171,15 +158,22 @@ export const sessionUpdateSchema = z.discriminatedUnion("sessionUpdate", [
171
158
  }),
172
159
  z.object({
173
160
  sessionUpdate: z.literal("session_info_update"),
174
- title: z.string().optional(),
161
+ title: z.string().nullable().optional(),
162
+ updatedAt: z.string().nullable().optional(),
175
163
  model: z.string().optional(),
176
164
  }),
177
165
  z.object({
178
166
  sessionUpdate: z.literal("usage_update"),
179
167
  usedTokens: z.number().optional(),
180
168
  contextWindow: z.number().optional(),
169
+ cost: z.object({ amount: z.number(), currency: z.string() }).nullable().optional(),
170
+ tokens: z.object({ totalTokens: z.number(), inputTokens: z.number(), outputTokens: z.number(), thoughtTokens: z.number().nullish(), cachedReadTokens: z.number().nullish(), cachedWriteTokens: z.number().nullish() }).optional(),
181
171
  }),
182
172
  // ── LinkShell extensions ──
173
+ z.object({
174
+ sessionUpdate: z.literal("ls_async_questions"),
175
+ questions: z.array(asyncQuestionSchema),
176
+ }),
183
177
  z.object({
184
178
  sessionUpdate: z.literal("ls_message_done"),
185
179
  parentToolCallId: z.string().optional(),
@@ -190,7 +184,7 @@ export const sessionUpdateSchema = z.discriminatedUnion("sessionUpdate", [
190
184
  * `request_user_input_async`): answered by an ordinary message that quotes
191
185
  * them (`asyncQuestionReply`). An app that predates them shows the text only.
192
186
  */
193
- questions: z.array(z.object({ id: z.string(), title: z.string(), options: z.array(z.string()) })).optional(),
187
+ questions: z.array(asyncQuestionSchema).optional(),
194
188
  }),
195
189
  /** Selectable session settings (model, permission mode, effort…) as the agent reports them. */
196
190
  z.object({
@@ -214,9 +208,13 @@ export const sessionUpdateSchema = z.discriminatedUnion("sessionUpdate", [
214
208
  z.object({
215
209
  sessionUpdate: z.literal("ls_permission"),
216
210
  requestId: z.string(),
211
+ childSessionId: z.string().optional(),
217
212
  toolCallId: z.string().optional(),
218
213
  title: z.string(),
219
214
  detail: z.string().optional(),
215
+ tool: z.object({ title: z.string().optional(), kind: toolKindSchema.optional(), rawInput: z.unknown().optional(), content: z.array(toolCallContentSchema).optional(), locations: z.array(toolCallLocationSchema).optional() }).optional(),
216
+ /** URL interactions stay out of form answers and are opened only after consent. */
217
+ url: z.object({ url: z.string(), elicitationId: z.string() }).optional(),
220
218
  options: z.array(permissionOptionSchema).min(1),
221
219
  /**
222
220
  * The agent is asking the user something: answer with `sessions.answer`.
@@ -240,6 +238,7 @@ export const sessionUpdateSchema = z.discriminatedUnion("sessionUpdate", [
240
238
  z.object({
241
239
  sessionUpdate: z.literal("ls_status"),
242
240
  state: sessionStateSchema,
241
+ turnActive: z.boolean().optional(),
243
242
  }),
244
243
  /** A background task as it is now (the whole record each time: the latest for its id holds). */
245
244
  z.object({
@@ -256,6 +255,10 @@ export const sessionUpdateSchema = z.discriminatedUnion("sessionUpdate", [
256
255
  export type SessionUpdate = z.infer<typeof sessionUpdateSchema>;
257
256
  export type SessionUpdateKind = SessionUpdate["sessionUpdate"];
258
257
 
258
+ /** Live ACP notices are not session history and never change execution state. */
259
+ export const sessionNoticeSchema = z.object({ severity: z.string(), title: z.string(), description: z.string().optional() });
260
+ export type SessionNotice = z.infer<typeof sessionNoticeSchema>;
261
+
259
262
  /** One entry in a session's append-only event log. */
260
263
  export const sessionEventSchema = z.object({
261
264
  sessionId: z.string(),