@co0ontty/wand 1.30.3 → 1.31.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.
@@ -144,7 +144,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
144
144
  });
145
145
  app.post("/api/structured-sessions", express.json(), async (req, res) => {
146
146
  const body = req.body;
147
- console.log("[WAND] POST /api/structured-sessions body:", JSON.stringify({ cwd: body.cwd, mode: body.mode, runner: body.runner, provider: body.provider, worktreeEnabled: body.worktreeEnabled === true, hasPrompt: !!body.prompt, model: body.model }));
147
+ console.log("[WAND] POST /api/structured-sessions body:", JSON.stringify({ cwd: body.cwd, mode: body.mode, runner: body.runner, provider: body.provider, worktreeEnabled: body.worktreeEnabled === true, hasPrompt: !!body.prompt, model: body.model, thinkingEffort: body.thinkingEffort }));
148
148
  try {
149
149
  if (body.provider && body.provider !== "claude" && body.provider !== "codex") {
150
150
  res.status(400).json({ error: "结构化会话当前仅支持 Claude 或 Codex provider。" });
@@ -158,6 +158,9 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
158
158
  runner: body.runner ?? (provider === "codex" ? "codex-cli-exec" : "claude-cli-print"),
159
159
  worktreeEnabled: body.worktreeEnabled === true,
160
160
  model: typeof body.model === "string" ? body.model.trim() : undefined,
161
+ thinkingEffort: typeof body.thinkingEffort === "string"
162
+ ? body.thinkingEffort
163
+ : undefined,
161
164
  });
162
165
  console.log("[WAND] structured session created:", JSON.stringify({ id: snapshot.id, sessionKind: snapshot.sessionKind, runner: snapshot.runner, status: snapshot.status }));
163
166
  onSessionCreated?.(body.cwd ?? snapshot.cwd);
@@ -196,6 +199,29 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
196
199
  res.status(400).json({ error: getErrorMessage(error, "切换模型失败。") });
197
200
  }
198
201
  });
202
+ // 思考深度切换:与 /model 路由对称。结构化会话立即影响下一条 prompt(CLI/SDK 各自接入
203
+ // applyThinkingEffortToPrompt / thinking budget),PTY 会话仅影响通过 chat 视图发送的输入。
204
+ app.post("/api/sessions/:id/thinking-effort", express.json(), (req, res) => {
205
+ const body = req.body;
206
+ const raw = typeof body?.thinkingEffort === "string" ? body.thinkingEffort : null;
207
+ const id = req.params.id;
208
+ try {
209
+ if (structured.get(id)) {
210
+ const updated = structured.setSessionThinkingEffort(id, raw);
211
+ res.json(updated);
212
+ return;
213
+ }
214
+ if (!processes.get(id)) {
215
+ res.status(404).json({ error: "未找到该会话。" });
216
+ return;
217
+ }
218
+ const updated = processes.setSessionThinkingEffort(id, raw);
219
+ res.json(updated);
220
+ }
221
+ catch (error) {
222
+ res.status(400).json({ error: getErrorMessage(error, "切换思考深度失败。") });
223
+ }
224
+ });
199
225
  app.get("/api/structured-sessions/:id/messages", (req, res) => {
200
226
  const snapshot = structured.get(req.params.id);
201
227
  if (!snapshot) {
@@ -207,10 +233,13 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
207
233
  app.post("/api/structured-sessions/:id/messages", express.json(), async (req, res) => {
208
234
  const input = String(req.body?.input ?? "");
209
235
  const interrupt = !!req.body?.interrupt;
236
+ // preserveQueue: 仅在 interrupt 路径有意义。排队条「立即」会带这个 flag,
237
+ // 让退出 handler 不要把剩余 queuedMessages 清空(默认行为是清空)。
238
+ const preserveQueue = !!req.body?.preserveQueue;
210
239
  const idempotencyKey = typeof req.body?.idempotencyKey === "string" ? req.body.idempotencyKey : undefined;
211
- console.log("[WAND] POST /api/structured-sessions/:id/messages id:", req.params.id, "input:", input.substring(0, 50), "interrupt:", interrupt, "idempotencyKey:", idempotencyKey);
240
+ console.log("[WAND] POST /api/structured-sessions/:id/messages id:", req.params.id, "input:", input.substring(0, 50), "interrupt:", interrupt, "preserveQueue:", preserveQueue, "idempotencyKey:", idempotencyKey);
212
241
  try {
213
- const snapshot = await structured.sendMessage(req.params.id, input, { interrupt, idempotencyKey });
242
+ const snapshot = await structured.sendMessage(req.params.id, input, { interrupt, preserveQueue, idempotencyKey });
214
243
  res.json(snapshot);
215
244
  }
216
245
  catch (error) {
@@ -222,6 +251,46 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
222
251
  });
223
252
  }
224
253
  });
254
+ // ── Structured queued-messages management ──
255
+ // 三个端点构成"排队消息条"的后端操作面:reorder(拖拽换序)、单条删除、全部清空。
256
+ // 全部走乐观更新模型,失败时前端会回滚到上一次 WS 推送的 queuedMessages。
257
+ app.patch("/api/structured-sessions/:id/queued", express.json(), (req, res) => {
258
+ const rawOrder = req.body?.order;
259
+ if (!Array.isArray(rawOrder)) {
260
+ res.status(400).json({ error: "缺少 order 数组。" });
261
+ return;
262
+ }
263
+ try {
264
+ const snapshot = structured.reorderQueuedMessages(req.params.id, rawOrder.map((v) => Number(v)));
265
+ res.json(snapshot);
266
+ }
267
+ catch (error) {
268
+ res.status(400).json({ error: getErrorMessage(error, "无法调整排队顺序。") });
269
+ }
270
+ });
271
+ app.delete("/api/structured-sessions/:id/queued/:index", (req, res) => {
272
+ const index = Number(req.params.index);
273
+ if (!Number.isFinite(index)) {
274
+ res.status(400).json({ error: "下标无效。" });
275
+ return;
276
+ }
277
+ try {
278
+ const snapshot = structured.deleteQueuedMessage(req.params.id, index);
279
+ res.json(snapshot);
280
+ }
281
+ catch (error) {
282
+ res.status(400).json({ error: getErrorMessage(error, "无法删除排队消息。") });
283
+ }
284
+ });
285
+ app.delete("/api/structured-sessions/:id/queued", (req, res) => {
286
+ try {
287
+ const snapshot = structured.clearQueuedMessages(req.params.id);
288
+ res.json(snapshot);
289
+ }
290
+ catch (error) {
291
+ res.status(400).json({ error: getErrorMessage(error, "无法清空排队消息。") });
292
+ }
293
+ });
225
294
  // ── Tool content lazy-load endpoint ──
226
295
  app.get("/api/sessions/:id/tool-content/:toolUseId", (req, res) => {
227
296
  const snapshot = getSessionById(processes, structured, req.params.id);
package/dist/server.js CHANGED
@@ -1813,6 +1813,7 @@ export async function startServer(config, configPath) {
1813
1813
  model: effectiveModel,
1814
1814
  cols: reqCols,
1815
1815
  rows: reqRows,
1816
+ thinkingEffort: body.thinkingEffort ?? undefined,
1816
1817
  });
1817
1818
  recordRecentPath(storage, body.cwd ?? snapshot.cwd);
1818
1819
  res.status(201).json(snapshot);
@@ -10,6 +10,8 @@ interface CreateStructuredSessionOptions {
10
10
  worktreeEnabled?: boolean;
11
11
  /** 用户指定的 Claude 模型(别名或完整 ID)。留空则 spawn 时不加 --model。 */
12
12
  model?: string;
13
+ /** 用户预设的思考深度。留空 / null 视为 off。 */
14
+ thinkingEffort?: SessionSnapshot["thinkingEffort"];
13
15
  }
14
16
  /**
15
17
  * 把任意外部输入收敛到合法的 thinkingEffort 枚举值。`null` / 非法值都视为
@@ -39,6 +41,14 @@ export declare class StructuredSessionManager {
39
41
  */
40
42
  private readonly pendingSdkQueries;
41
43
  private readonly interruptedWith;
44
+ /**
45
+ * Sessions where the current interrupt is a "queue promote" (用户从排队条点了「立即」
46
+ * 把队首插队到 now)。退出处理三个分支默认会把 queuedMessages 清空——因为常规的
47
+ * interrupt 语义是"算了,做这个",把队列也作废。但 queue-promote 的语义是
48
+ * "先做这条,剩下的队列还要继续",所以这里打个标记,让退出 handler 保留 queue。
49
+ * 收到后必须 delete 掉,避免下一次普通 interrupt 误带 flag。
50
+ */
51
+ private readonly preserveQueueOnInterrupt;
42
52
  /** Last wall-clock time (ms) we did a full saveSession for a streaming session. */
43
53
  private readonly lastStreamSaveAt;
44
54
  /**
@@ -69,11 +79,25 @@ export declare class StructuredSessionManager {
69
79
  sendMessage(id: string, input: string, opts?: {
70
80
  interrupt?: boolean;
71
81
  idempotencyKey?: string;
82
+ preserveQueue?: boolean;
72
83
  }): Promise<SessionSnapshot>;
73
84
  /** Approve a pending permission request. */
74
85
  approvePermission(sessionId: string): SessionSnapshot;
75
86
  /** Deny a pending permission request. */
76
87
  denyPermission(sessionId: string): SessionSnapshot;
88
+ /**
89
+ * Reorder the pending queued messages. `order` is a permutation of the current
90
+ * indices, e.g. `[2, 0, 1]` means "move the third queued message to the front,
91
+ * push the original first to position #2". Throws if the permutation is
92
+ * malformed (length mismatch / duplicate / out-of-range). 不允许在 inFlight
93
+ * 期间改"已经被 flushNextQueuedMessage 拿走的队首",但本方法只动 queue 数组
94
+ * 本身,flushNext 在另一段时序里读 sessions.get(...) 当前快照,已经天然安全。
95
+ */
96
+ reorderQueuedMessages(sessionId: string, order: number[]): SessionSnapshot;
97
+ /** Remove a single queued message by index. */
98
+ deleteQueuedMessage(sessionId: string, index: number): SessionSnapshot;
99
+ /** Clear all queued messages. No-op when queue is already empty. */
100
+ clearQueuedMessages(sessionId: string): SessionSnapshot;
77
101
  /** Update the selected model for a structured session. Takes effect on the next spawn. */
78
102
  setSessionModel(sessionId: string, model: string | null): SessionSnapshot;
79
103
  /**
@@ -390,8 +390,8 @@ function buildAppendSystemPromptParts(language, mode) {
390
390
  }
391
391
  if (trimmedLanguage) {
392
392
  parts.push(isChinese
393
- ? "请使用中文回复。所有解释、注释和对话文本都使用中文。"
394
- : `Please respond in ${trimmedLanguage}. Use ${trimmedLanguage} for all your explanations, comments, and conversational text.`);
393
+ ? "请使用中文回复。所有解释、注释和对话文本都使用中文。当你通过 Task 工具派发子代理(subagent)时,必须在传给子代理的 prompt 里明确要求它也使用中文回复,确保所有子代理的输出同样遵循该语言偏好。"
394
+ : `Please respond in ${trimmedLanguage}. Use ${trimmedLanguage} for all your explanations, comments, and conversational text. When you dispatch a subagent via the Task tool, you MUST explicitly instruct the subagent in its prompt to also respond in ${trimmedLanguage}, so every subagent's output follows the same language preference.`);
395
395
  }
396
396
  return parts;
397
397
  }
@@ -434,6 +434,14 @@ export class StructuredSessionManager {
434
434
  */
435
435
  pendingSdkQueries = new Map();
436
436
  interruptedWith = new Map();
437
+ /**
438
+ * Sessions where the current interrupt is a "queue promote" (用户从排队条点了「立即」
439
+ * 把队首插队到 now)。退出处理三个分支默认会把 queuedMessages 清空——因为常规的
440
+ * interrupt 语义是"算了,做这个",把队列也作废。但 queue-promote 的语义是
441
+ * "先做这条,剩下的队列还要继续",所以这里打个标记,让退出 handler 保留 queue。
442
+ * 收到后必须 delete 掉,避免下一次普通 interrupt 误带 flag。
443
+ */
444
+ preserveQueueOnInterrupt = new Set();
437
445
  /** Last wall-clock time (ms) we did a full saveSession for a streaming session. */
438
446
  lastStreamSaveAt = new Map();
439
447
  /**
@@ -549,6 +557,7 @@ export class StructuredSessionManager {
549
557
  ? prepareSessionWorktree({ cwd: options.cwd, sessionId: id })
550
558
  : null;
551
559
  const selectedModel = options.model?.trim() || null;
560
+ const initialThinkingEffort = normalizeThinkingEffort(options.thinkingEffort);
552
561
  const snapshot = {
553
562
  id,
554
563
  sessionKind: "structured",
@@ -585,6 +594,7 @@ export class StructuredSessionManager {
585
594
  autoApprovePermissions: shouldAutoApproveForMode(options.mode),
586
595
  approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
587
596
  selectedModel,
597
+ thinkingEffort: initialThinkingEffort,
588
598
  };
589
599
  this.sessions.set(id, snapshot);
590
600
  this.storage.saveSession(snapshot);
@@ -635,6 +645,12 @@ export class StructuredSessionManager {
635
645
  }
636
646
  else if (opts?.interrupt) {
637
647
  this.interruptedWith.set(id, prompt);
648
+ if (opts.preserveQueue) {
649
+ this.preserveQueueOnInterrupt.add(id);
650
+ }
651
+ else {
652
+ this.preserveQueueOnInterrupt.delete(id);
653
+ }
638
654
  try {
639
655
  child.kill("SIGTERM");
640
656
  }
@@ -767,6 +783,60 @@ export class StructuredSessionManager {
767
783
  denyPermission(sessionId) {
768
784
  return this.resolvePermission(sessionId, false);
769
785
  }
786
+ /**
787
+ * Reorder the pending queued messages. `order` is a permutation of the current
788
+ * indices, e.g. `[2, 0, 1]` means "move the third queued message to the front,
789
+ * push the original first to position #2". Throws if the permutation is
790
+ * malformed (length mismatch / duplicate / out-of-range). 不允许在 inFlight
791
+ * 期间改"已经被 flushNextQueuedMessage 拿走的队首",但本方法只动 queue 数组
792
+ * 本身,flushNext 在另一段时序里读 sessions.get(...) 当前快照,已经天然安全。
793
+ */
794
+ reorderQueuedMessages(sessionId, order) {
795
+ const session = this.requireSession(sessionId);
796
+ const queue = session.queuedMessages ?? [];
797
+ if (!Array.isArray(order) || order.length !== queue.length) {
798
+ throw new Error("排序长度与当前队列不一致,请刷新后重试。");
799
+ }
800
+ const seen = new Set();
801
+ for (const idx of order) {
802
+ if (!Number.isInteger(idx) || idx < 0 || idx >= queue.length || seen.has(idx)) {
803
+ throw new Error("排序参数无效。");
804
+ }
805
+ seen.add(idx);
806
+ }
807
+ const reordered = order.map((idx) => queue[idx]);
808
+ const updated = { ...session, queuedMessages: reordered };
809
+ this.sessions.set(sessionId, updated);
810
+ this.storage.saveSession(updated);
811
+ this.emitStructuredSnapshot(updated);
812
+ return updated;
813
+ }
814
+ /** Remove a single queued message by index. */
815
+ deleteQueuedMessage(sessionId, index) {
816
+ const session = this.requireSession(sessionId);
817
+ const queue = session.queuedMessages ?? [];
818
+ if (!Number.isInteger(index) || index < 0 || index >= queue.length) {
819
+ throw new Error("队列中没有该条消息(可能已被处理)。");
820
+ }
821
+ const next = queue.slice(0, index).concat(queue.slice(index + 1));
822
+ const updated = { ...session, queuedMessages: next };
823
+ this.sessions.set(sessionId, updated);
824
+ this.storage.saveSession(updated);
825
+ this.emitStructuredSnapshot(updated);
826
+ return updated;
827
+ }
828
+ /** Clear all queued messages. No-op when queue is already empty. */
829
+ clearQueuedMessages(sessionId) {
830
+ const session = this.requireSession(sessionId);
831
+ if (!session.queuedMessages || session.queuedMessages.length === 0) {
832
+ return session;
833
+ }
834
+ const updated = { ...session, queuedMessages: [] };
835
+ this.sessions.set(sessionId, updated);
836
+ this.storage.saveSession(updated);
837
+ this.emitStructuredSnapshot(updated);
838
+ return updated;
839
+ }
770
840
  /** Update the selected model for a structured session. Takes effect on the next spawn. */
771
841
  setSessionModel(sessionId, model) {
772
842
  const session = this.requireSession(sessionId);
@@ -848,6 +918,7 @@ export class StructuredSessionManager {
848
918
  stop(id) {
849
919
  const session = this.requireSession(id);
850
920
  this.interruptedWith.delete(id);
921
+ this.preserveQueueOnInterrupt.delete(id);
851
922
  const child = this.pendingChildren.get(id);
852
923
  if (child) {
853
924
  child.kill();
@@ -900,6 +971,8 @@ export class StructuredSessionManager {
900
971
  }
901
972
  this.sessions.delete(id);
902
973
  this.lastStreamSaveAt.delete(id);
974
+ this.interruptedWith.delete(id);
975
+ this.preserveQueueOnInterrupt.delete(id);
903
976
  this.storage.deleteSession(id);
904
977
  this.logger?.deleteSession(id);
905
978
  }
@@ -1043,6 +1116,11 @@ export class StructuredSessionManager {
1043
1116
  if (modelChoice && modelChoice !== "default") {
1044
1117
  args.push("--model", modelChoice);
1045
1118
  }
1119
+ // 思考深度 → --reasoning-effort(off → minimal,standard → low,deep → medium,max → high)
1120
+ const reasoningFlag = thinkingEffortToCodexFlag(session.thinkingEffort);
1121
+ if (reasoningFlag) {
1122
+ args.push("--reasoning-effort", reasoningFlag);
1123
+ }
1046
1124
  if (session.claudeSessionId) {
1047
1125
  args.push("resume", session.claudeSessionId, "-");
1048
1126
  }
@@ -1285,7 +1363,7 @@ export class StructuredSessionManager {
1285
1363
  output: turnState.result,
1286
1364
  claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
1287
1365
  messages: msgs,
1288
- queuedMessages: interruptPrompt ? [] : current.queuedMessages,
1366
+ queuedMessages: interruptPrompt && !this.preserveQueueOnInterrupt.has(sessionId) ? [] : current.queuedMessages,
1289
1367
  pendingEscalation: null,
1290
1368
  permissionBlocked: false,
1291
1369
  structuredState: {
@@ -1304,6 +1382,11 @@ export class StructuredSessionManager {
1304
1382
  }
1305
1383
  if (interruptPrompt) {
1306
1384
  this.interruptedWith.delete(sessionId);
1385
+ // 把"保留队列"标记一并清掉——不属于本次 interrupt 的后续轮次会按
1386
+ // 默认(清空 queue)行为走,避免 stale flag 影响下一次普通 interrupt。
1387
+ // 注意:被保留的 queuedMessages 不需要在这里主动 flush,重发的
1388
+ // interruptPrompt 跑完会自然触发 flushNextQueuedMessage。
1389
+ this.preserveQueueOnInterrupt.delete(sessionId);
1307
1390
  resolve();
1308
1391
  setImmediate(() => {
1309
1392
  this.sendMessage(sessionId, interruptPrompt).catch((err) => {
@@ -1384,6 +1467,10 @@ export class StructuredSessionManager {
1384
1467
  // variadic 参数贪婪吞掉(commander 的 <tools...> 会一直吃 positional 直到
1385
1468
  // 下一个 flag)。表现为 claude 报 "Input must be provided either through
1386
1469
  // stdin or as a prompt argument when using --print"。
1470
+ //
1471
+ // 思考深度通过给 prompt 前置魔法词触发(think / think hard / ultrathink)。
1472
+ // applyThinkingEffortToPrompt 自身已经做了"用户已写过就不重复加"的保护。
1473
+ const effectivePrompt = applyThinkingEffortToPrompt(prompt, session.thinkingEffort);
1387
1474
  const spawnedAt = new Date().toISOString();
1388
1475
  const child = spawn("claude", args, {
1389
1476
  cwd: session.cwd,
@@ -1396,13 +1483,13 @@ export class StructuredSessionManager {
1396
1483
  pid: child.pid ?? null,
1397
1484
  cwd: session.cwd,
1398
1485
  args,
1399
- prompt: prompt.slice(0, 2048),
1400
- promptLength: prompt.length,
1486
+ prompt: effectivePrompt.slice(0, 2048),
1487
+ promptLength: effectivePrompt.length,
1401
1488
  claudeSessionId: session.claudeSessionId,
1402
1489
  spawnedAt,
1403
1490
  });
1404
1491
  this.pendingChildren.set(sessionId, child);
1405
- child.stdin?.end(prompt);
1492
+ child.stdin?.end(effectivePrompt);
1406
1493
  const turnState = {
1407
1494
  blocks: [],
1408
1495
  result: "",
@@ -1792,7 +1879,7 @@ export class StructuredSessionManager {
1792
1879
  output: turnState.result,
1793
1880
  claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
1794
1881
  messages: msgs,
1795
- queuedMessages: interruptPrompt ? [] : current.queuedMessages,
1882
+ queuedMessages: interruptPrompt && !this.preserveQueueOnInterrupt.has(sessionId) ? [] : current.queuedMessages,
1796
1883
  pendingEscalation: null,
1797
1884
  permissionBlocked: false,
1798
1885
  structuredState: {
@@ -1817,6 +1904,11 @@ export class StructuredSessionManager {
1817
1904
  // 用户中断当前回复:保存部分回复后立即发送新消息。
1818
1905
  if (interruptPrompt) {
1819
1906
  this.interruptedWith.delete(sessionId);
1907
+ // 把"保留队列"标记一并清掉——不属于本次 interrupt 的后续轮次会按
1908
+ // 默认(清空 queue)行为走,避免 stale flag 影响下一次普通 interrupt。
1909
+ // 注意:被保留的 queuedMessages 不需要在这里主动 flush,重发的
1910
+ // interruptPrompt 跑完会自然触发 flushNextQueuedMessage。
1911
+ this.preserveQueueOnInterrupt.delete(sessionId);
1820
1912
  resolve();
1821
1913
  setImmediate(() => {
1822
1914
  this.sendMessage(sessionId, interruptPrompt).catch((err) => {
@@ -1889,6 +1981,12 @@ export class StructuredSessionManager {
1889
1981
  // SDK 默认会把整个 process.env 透传给 claude 子进程;这里显式按 inheritEnv 配置组装,
1890
1982
  // 否则关闭"继承环境变量"开关时 SDK 路径会被静默忽略。
1891
1983
  const sdkEnv = buildChildEnv(this.config.inheritEnv !== false);
1984
+ // 思考深度:off → 显式禁用 thinking,其他 → 给一个固定 budget。
1985
+ // SDK 类型用驼峰 budgetTokens(API 层是 budget_tokens,SDK 内部已做转换)。
1986
+ const sdkThinkingBudget = thinkingEffortToSdkBudget(session.thinkingEffort);
1987
+ const sdkThinking = sdkThinkingBudget > 0
1988
+ ? { type: "enabled", budgetTokens: sdkThinkingBudget }
1989
+ : { type: "disabled" };
1892
1990
  const sdkOptions = {
1893
1991
  cwd: session.cwd,
1894
1992
  abortController,
@@ -1897,6 +1995,7 @@ export class StructuredSessionManager {
1897
1995
  ...(permPolicy.permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } : {}),
1898
1996
  ...(permPolicy.allowedTools ? { allowedTools: permPolicy.allowedTools } : {}),
1899
1997
  ...(isManaged ? { disallowedTools: ["AskUserQuestion"] } : {}),
1998
+ thinking: sdkThinking,
1900
1999
  includePartialMessages: true,
1901
2000
  // 把子 agent 的 text/thinking 也转发回来,UI 才能把"被 Task 召唤来的协作者"
1902
2001
  // 渲染成独立角色的群聊消息。关掉这个开关时只会收到子 agent 的 tool_use/tool_result,
@@ -230,14 +230,13 @@ export function startAttachTui(deps) {
230
230
  layout.showToast("服务已安装,按 Shift+S 卸载", "warn", 2500);
231
231
  return;
232
232
  }
233
- const ok = await layout.confirm({
234
- title: "注册为系统服务",
235
- body: process.platform === "linux"
236
- ? "将写入 ~/.config/systemd/user/wand.service。"
237
- : process.platform === "darwin"
238
- ? "将写入 ~/Library/LaunchAgents/com.wand.web.plist。"
239
- : "当前平台暂不支持。",
240
- });
233
+ const isRoot = typeof process.getuid === "function" ? process.getuid() === 0 : false;
234
+ const body = process.platform === "linux"
235
+ ? `将写入 /etc/systemd/system/wand.service,systemctl enable --now,开机自启。\n${isRoot ? "当前是 root,可以直接装。" : "⚠ 需要 root,可以 Ctrl+C 退出 TUI 后跑 sudo wand service:install。"}`
236
+ : process.platform === "darwin"
237
+ ? `将写入 /Library/LaunchDaemons/com.wand.web.plist,launchctl load,开机自启。\n${isRoot ? "当前是 root,可以直接装。" : "⚠ 需要 root,退出 TUI 跑 sudo wand service:install。"}`
238
+ : "当前平台暂不支持。";
239
+ const ok = await layout.confirm({ title: "注册为系统服务", body });
241
240
  if (!ok)
242
241
  return;
243
242
  const r = await runOffMicrotask(() => installService({ configPath: deps.configPath }));
@@ -36,12 +36,29 @@ export declare function checkUpdate(currentVersion: string): UpdateInfo;
36
36
  export declare function installUpdate(): CommandResult;
37
37
  export declare function openInBrowser(url: string): CommandResult;
38
38
  export declare function copyToClipboard(text: string): CommandResult;
39
+ /**
40
+ * 服务安装的作用域:
41
+ * - "system" = Linux 写 /etc/systemd/system/wand.service;macOS 写 /Library/LaunchDaemons/
42
+ * 需要 root,开机自启,所有用户可用,不依赖 login session。
43
+ * - "user" = Linux 写 ~/.config/systemd/user/wand.service;macOS 写 ~/Library/LaunchAgents/
44
+ * 不要 root,登出会被回收(除非 loginctl enable-linger)。
45
+ *
46
+ * 默认 system。
47
+ */
48
+ export type ServiceScope = "system" | "user";
49
+ export declare const DEFAULT_SERVICE_SCOPE: ServiceScope;
39
50
  export interface ServiceContext {
40
51
  configPath: string;
41
52
  /** wand 可执行文件路径。优先使用 process.argv[1],回退到 which wand。 */
42
53
  wandBin?: string;
54
+ /** 显式指定作用域。不传走 DEFAULT_SERVICE_SCOPE。 */
55
+ scope?: ServiceScope;
56
+ }
57
+ export interface ServiceOpts {
58
+ /** 不传 = 自动检测已装的那个;都没装就用 default。 */
59
+ scope?: ServiceScope;
43
60
  }
44
- export declare function isServiceInstalled(): boolean;
61
+ export declare function isServiceInstalled(scope?: ServiceScope): boolean;
45
62
  export interface ServiceStatus {
46
63
  /** 是否已安装服务文件。 */
47
64
  installed: boolean;
@@ -54,11 +71,11 @@ export interface ServiceStatus {
54
71
  /** 平台。 */
55
72
  platform: NodeJS.Platform;
56
73
  }
57
- export declare function serviceStatus(): ServiceStatus;
58
- export declare function serviceStart(): CommandResult;
59
- export declare function serviceStop(): CommandResult;
60
- export declare function serviceRestart(): CommandResult;
74
+ export declare function serviceStatus(opts?: ServiceOpts): ServiceStatus;
75
+ export declare function serviceStart(opts?: ServiceOpts): CommandResult;
76
+ export declare function serviceStop(opts?: ServiceOpts): CommandResult;
77
+ export declare function serviceRestart(opts?: ServiceOpts): CommandResult;
61
78
  /** 取最近 N 行服务日志。 */
62
- export declare function serviceLogs(lines?: number): CommandResult;
79
+ export declare function serviceLogs(lines?: number, opts?: ServiceOpts): CommandResult;
63
80
  export declare function installService(ctx: ServiceContext): CommandResult;
64
- export declare function uninstallService(): CommandResult;
81
+ export declare function uninstallService(opts?: ServiceOpts): CommandResult;