@co0ontty/wand 4.63.0 → 4.64.0-beta.g3402a64

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.
Files changed (83) hide show
  1. package/dist/build-info.d.ts +18 -0
  2. package/dist/build-info.js +13 -0
  3. package/dist/build-info.json +4 -4
  4. package/dist/config.js +6 -11
  5. package/dist/env-utils.d.ts +5 -0
  6. package/dist/env-utils.js +4 -0
  7. package/dist/git-quick-commit.js +7 -11
  8. package/dist/ios-ota.js +8 -29
  9. package/dist/layout-tree.d.ts +6 -0
  10. package/dist/layout-tree.js +9 -0
  11. package/dist/missions.js +2 -2
  12. package/dist/models.js +2 -5
  13. package/dist/path-repair.js +2 -1
  14. package/dist/process-manager.js +10 -20
  15. package/dist/provider-cli-updater.js +3 -6
  16. package/dist/pty-shell-launch.js +3 -5
  17. package/dist/server-app-connect.js +11 -46
  18. package/dist/server-github-routes.js +12 -11
  19. package/dist/server-local-preview-routes.js +6 -1
  20. package/dist/server-request.d.ts +4 -2
  21. package/dist/server-request.js +25 -4
  22. package/dist/server-session-routes.js +14 -78
  23. package/dist/server-settings-routes.d.ts +2 -6
  24. package/dist/server-settings-routes.js +3 -8
  25. package/dist/server-task-routes.d.ts +9 -6
  26. package/dist/server-task-routes.js +50 -43
  27. package/dist/server-update-routes.d.ts +2 -5
  28. package/dist/server-update-routes.js +42 -9
  29. package/dist/server-workspace-routes.js +4 -12
  30. package/dist/server.js +33 -99
  31. package/dist/session-ai-context.js +6 -38
  32. package/dist/session-provider.d.ts +16 -0
  33. package/dist/session-provider.js +49 -0
  34. package/dist/session-registry.d.ts +10 -0
  35. package/dist/session-registry.js +41 -14
  36. package/dist/session-topic.js +3 -11
  37. package/dist/shell-quote.d.ts +9 -0
  38. package/dist/shell-quote.js +13 -0
  39. package/dist/signal-utils.d.ts +10 -0
  40. package/dist/signal-utils.js +38 -0
  41. package/dist/storage.js +17 -55
  42. package/dist/structured-client-protocol.js +4 -8
  43. package/dist/structured-codex-protocol.js +1 -6
  44. package/dist/structured-content.d.ts +2 -0
  45. package/dist/structured-content.js +6 -0
  46. package/dist/structured-exec-host.d.ts +0 -2
  47. package/dist/structured-exec-host.js +2 -11
  48. package/dist/structured-exec-pump.js +2 -12
  49. package/dist/structured-grok-adapter.js +1 -5
  50. package/dist/structured-opencode-adapter.js +1 -5
  51. package/dist/structured-pi-adapter.js +9 -11
  52. package/dist/structured-provider-common.d.ts +11 -3
  53. package/dist/structured-provider-common.js +16 -23
  54. package/dist/structured-qoder-adapter.js +3 -8
  55. package/dist/structured-session-manager.js +6 -17
  56. package/dist/task-title.js +4 -12
  57. package/dist/task-types.d.ts +23 -1
  58. package/dist/task-types.js +26 -0
  59. package/dist/terminal-daemon-protocol.d.ts +0 -6
  60. package/dist/terminal-daemon-server.js +1 -9
  61. package/dist/text-utils.d.ts +7 -0
  62. package/dist/text-utils.js +15 -0
  63. package/dist/tui/attach.js +17 -93
  64. package/dist/tui/commands.d.ts +2 -0
  65. package/dist/tui/commands.js +3 -2
  66. package/dist/tui/index.js +28 -115
  67. package/dist/tui/runtime-utils.d.ts +7 -0
  68. package/dist/tui/runtime-utils.js +33 -0
  69. package/dist/tui/service-actions.d.ts +31 -0
  70. package/dist/tui/service-actions.js +86 -0
  71. package/dist/tui/snapshot.js +1 -8
  72. package/dist/update-helper.js +6 -35
  73. package/dist/web-ui/content/scripts.js +397 -497
  74. package/dist/web-ui/content/styles.css +1 -1
  75. package/dist/web-ui/content/tailwind.css +7210 -0
  76. package/dist/web-ui/embedded-assets.js +2 -2
  77. package/dist/web-ui/index.js +3 -3
  78. package/dist/web-ui/provider-identity.d.ts +4 -0
  79. package/dist/web-ui/provider-identity.js +9 -0
  80. package/dist/web-ui/styles.js +24 -7
  81. package/dist/workspace-binding.d.ts +2 -0
  82. package/dist/workspace-binding.js +4 -3
  83. package/package.json +9 -12
@@ -8,10 +8,10 @@ import { expandHomePath } from "./middleware/path-safety.js";
8
8
  import { checkSessionWorktreeMergeabilityAsync, cleanupWorktreeSync, prepareSessionWorktree, resolveWorktreeTargetBranchAsync, } from "./git-worktree.js";
9
9
  import { collectSessionTopicBlocklist } from "./session-topic.js";
10
10
  import { resolveSessionDisplayTitle } from "./session-transport.js";
11
- import { GLOBAL_WORKSPACE_ID } from "./types.js";
12
- import { attachUnboundSessionsToWorkspace, backfillSessionWorkspaces, normalizeProjectCwd, projectCwdForSession, syncDirectoryNameForWorkspace, } from "./workspace-binding.js";
11
+ import { attachUnboundSessionsToWorkspace, backfillSessionWorkspaces, normalizeProjectCwd, projectCwdForSession, isGlobalWorkspace, syncDirectoryNameForWorkspace } from "./workspace-binding.js";
13
12
  import { archiveBoardTaskForWorkspaceTask, ensureBoardTaskForWorkspaceTask } from "./wand-task-sync.js";
14
- const PROVIDERS = new Set(["claude", "codex", "opencode", "grok", "qoder", "pi"]);
13
+ import { isSessionProvider } from "./session-provider.js";
14
+ import { firstLayoutTabId } from "./layout-tree.js";
15
15
  function workspaceSessionTitle(session, names = {}) {
16
16
  return resolveSessionDisplayTitle(session, collectSessionTopicBlocklist({
17
17
  taskName: names.taskName,
@@ -74,7 +74,7 @@ function rememberCreatedAt(target, iso) {
74
74
  target.createdAt = value;
75
75
  }
76
76
  function parseDefaultProvider(value) {
77
- return typeof value === "string" && PROVIDERS.has(value) ? value : undefined;
77
+ return isSessionProvider(value) ? value : undefined;
78
78
  }
79
79
  /** Resolve + validate a workspace cwd: expand home, require an existing directory. */
80
80
  function resolveWorkspaceCwd(raw) {
@@ -96,9 +96,6 @@ function deleteSessions(storage, sessions, sessionIds) {
96
96
  storage.deleteSession(sessionId);
97
97
  }
98
98
  }
99
- function isGlobalWorkspace(workspace) {
100
- return workspace.kind === "global" || workspace.id === GLOBAL_WORKSPACE_ID;
101
- }
102
99
  function taskRuntimeCwd(task, workspace) {
103
100
  return task.worktree?.path ?? task.cwd ?? workspace?.cwd ?? "";
104
101
  }
@@ -226,11 +223,6 @@ function layoutHasTab(node, tabId) {
226
223
  return node.tabs.some((tab) => tab.id === tabId);
227
224
  return layoutHasTab(node.children[0], tabId) || layoutHasTab(node.children[1], tabId);
228
225
  }
229
- function firstLayoutTabId(node) {
230
- if (node.type === "pane")
231
- return node.tabs[node.active]?.id ?? node.tabs[0]?.id;
232
- return firstLayoutTabId(node.children[0]) ?? firstLayoutTabId(node.children[1]);
233
- }
234
226
  /** 校验任务级工作窗口集合;旧版单棵布局会兼容升级成一个 window。 */
235
227
  export function sanitizeTaskLayout(value) {
236
228
  const legacy = sanitizeLayoutNode(value);
package/dist/server.js CHANGED
@@ -24,6 +24,7 @@ import { registerLocalPreviewRoutes } from "./server-local-preview-routes.js";
24
24
  import { registerSettingsRoutes } from "./server-settings-routes.js";
25
25
  import { appTokenLoginPayload, buildStructuredChatPersonaPayload, isBrowserExtensionOrigin, resolveAppConnectCode, resolveStructuredChatAvatarPath, verifyAppToken, } from "./server-app-connect.js";
26
26
  import { firstHeaderValue, sendRouteError } from "./server-request.js";
27
+ import { inferProviderFromCommand, isSessionProvider } from "./session-provider.js";
27
28
  import { registerVaultRoutes } from "./server-vault-routes.js";
28
29
  import { registerGithubRoutes } from "./server-github-routes.js";
29
30
  import { registerTaskRoutes } from "./server-task-routes.js";
@@ -41,7 +42,7 @@ import { checkPackageUpdateAsync, installPackageGloballyAsync, normalizeUpdateCh
41
42
  import { repairServiceUnitAfterUpdate } from "./service-self-repair.js";
42
43
  import { computeRelaunch } from "./relaunch.js";
43
44
  import { RuntimeConfigState } from "./runtime-config.js";
44
- import { isServiceInstalled } from "./tui/commands.js";
45
+ import { safeServiceInstalled } from "./tui/runtime-utils.js";
45
46
  import { checkManagedServiceUpdatePreflight, } from "./update-helper.js";
46
47
  import { toSessionDetailDTO } from "./session-transport.js";
47
48
  import { registerUploadRoutes } from "./upload-routes.js";
@@ -64,6 +65,16 @@ const PKG_NAME = PKG_JSON.name;
64
65
  const PKG_VERSION = PKG_JSON.version;
65
66
  const PKG_NODE_REQ = PKG_JSON.engines?.node ?? ">=22.5.0";
66
67
  const PKG_REPO_URL = "https://github.com/co0ontty/wand";
68
+ /** 结构化聊天头像允许的图片类型;未知扩展名回 415。 */
69
+ const AVATAR_CONTENT_TYPES = {
70
+ ".svg": "image/svg+xml",
71
+ ".png": "image/png",
72
+ ".jpg": "image/jpeg",
73
+ ".jpeg": "image/jpeg",
74
+ ".webp": "image/webp",
75
+ ".gif": "image/gif",
76
+ ".avif": "image/avif",
77
+ };
67
78
  // ── Update check cache ──
68
79
  const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
69
80
  const MODEL_CATALOG_AUTO_REFRESH_INTERVAL_MS = 30 * 60 * 1000;
@@ -82,6 +93,7 @@ async function checkLatestPackageVersion(channel, forceRefresh = false) {
82
93
  }
83
94
  return info;
84
95
  }
96
+ // ── Build info (构建时打入的 commit SHA) + Beta 通道 ──
85
97
  /** 读取 dist/build-info.json(由 scripts/stamp-build-info.js 在 build 时生成)。 */
86
98
  function readBuildInfo() {
87
99
  try {
@@ -184,6 +196,15 @@ function authenticateBearerAppToken(req, storage, config) {
184
196
  return null;
185
197
  }
186
198
  }
199
+ /**
200
+ * 解析 PTY 会话的 provider:显式 `provider` 字段优先,其次按命令前缀识别;
201
+ * 都识别不出时回落到 Claude,与旧版客户端行为保持一致。
202
+ */
203
+ function resolveProviderForCommand(provider, command) {
204
+ if (isSessionProvider(provider))
205
+ return provider;
206
+ return inferProviderFromCommand(command) ?? "claude";
207
+ }
187
208
  // ── Startup error handling ──
188
209
  process.on("uncaughtException", (err) => {
189
210
  wandError("服务器异常", err.message, "请检查配置是否正确,或尝试重启服务。");
@@ -261,24 +282,15 @@ export async function startServer(config, configPath, options = {}) {
261
282
  const storage = new WandStorage(resolveDatabasePath(configPath));
262
283
  const runtimeConfig = new RuntimeConfigState(config);
263
284
  const authService = new AuthService(storage);
264
- // Helper to get current default models from storage (not from startup-captured config)
265
- // This allows model preferences to update in real-time when user changes config via UI
266
- const getCurrentDefaultModels = () => {
267
- // Try to read from storage first (most up-to-date via UI settings)
268
- // Falls back to config values for deployment settings
269
- const prefStorage = storage;
270
- if (prefStorage) {
271
- return {
272
- claude: prefStorage.getPreference("pref:defaultModel", config.defaultModel ?? ""),
273
- codex: prefStorage.getPreference("pref:defaultCodexModel", config.defaultCodexModel ?? ""),
274
- opencode: prefStorage.getPreference("pref:defaultOpenCodeModel", config.defaultOpenCodeModel ?? ""),
275
- grok: prefStorage.getPreference("pref:defaultGrokModel", config.defaultGrokModel ?? ""),
276
- qoder: prefStorage.getPreference("pref:defaultQoderModel", config.defaultQoderModel ?? ""),
277
- pi: prefStorage.getPreference("pref:defaultPiModel", config.defaultPiModel ?? ""),
278
- };
279
- }
280
- return getProviderDefaultModels(config);
281
- };
285
+ // 默认模型优先读存储(UI 改设置后实时生效),未设置时由 getPreference 回落到 config。
286
+ const getCurrentDefaultModels = () => ({
287
+ claude: storage.getPreference("pref:defaultModel", config.defaultModel ?? ""),
288
+ codex: storage.getPreference("pref:defaultCodexModel", config.defaultCodexModel ?? ""),
289
+ opencode: storage.getPreference("pref:defaultOpenCodeModel", config.defaultOpenCodeModel ?? ""),
290
+ grok: storage.getPreference("pref:defaultGrokModel", config.defaultGrokModel ?? ""),
291
+ qoder: storage.getPreference("pref:defaultQoderModel", config.defaultQoderModel ?? ""),
292
+ pi: storage.getPreference("pref:defaultPiModel", config.defaultPiModel ?? ""),
293
+ });
282
294
  const getModelRefreshOptions = () => {
283
295
  const injected = options.modelRefreshOptions?.() ?? {};
284
296
  const currentDefaults = getCurrentDefaultModels();
@@ -390,20 +402,7 @@ export async function startServer(config, configPath, options = {}) {
390
402
  res.status(404).end();
391
403
  return;
392
404
  }
393
- const ext = path.extname(resolvedPath).toLowerCase();
394
- const contentType = ext === ".svg"
395
- ? "image/svg+xml"
396
- : ext === ".png"
397
- ? "image/png"
398
- : ext === ".jpg" || ext === ".jpeg"
399
- ? "image/jpeg"
400
- : ext === ".webp"
401
- ? "image/webp"
402
- : ext === ".gif"
403
- ? "image/gif"
404
- : ext === ".avif"
405
- ? "image/avif"
406
- : null;
405
+ const contentType = AVATAR_CONTENT_TYPES[path.extname(resolvedPath).toLowerCase()];
407
406
  if (!contentType) {
408
407
  res.status(415).json({ error: "不支持的头像格式。" });
409
408
  return;
@@ -715,17 +714,7 @@ export async function startServer(config, configPath, options = {}) {
715
714
  const rawCommand = body.command?.trim() ?? "";
716
715
  const provider = interactiveShell
717
716
  ? undefined
718
- : body.provider === "codex" || /^codex\b/.test(rawCommand)
719
- ? "codex"
720
- : body.provider === "opencode" || /^opencode\b/.test(rawCommand)
721
- ? "opencode"
722
- : body.provider === "grok" || /^grok\b/.test(rawCommand)
723
- ? "grok"
724
- : body.provider === "qoder" || /^qodercli\b/.test(rawCommand)
725
- ? "qoder"
726
- : body.provider === "pi" || /^pi\b/.test(rawCommand)
727
- ? "pi"
728
- : "claude";
717
+ : resolveProviderForCommand(body.provider, rawCommand);
729
718
  // Older clients used the provider id as the PTY command. Qoder's executable
730
719
  // is named qodercli, so keep those clients working while preserving custom commands.
731
720
  const command = provider === "qoder" && rawCommand === "qoder"
@@ -845,14 +834,6 @@ export async function startServer(config, configPath, options = {}) {
845
834
  // previous web process was down; fire-and-forget, failures are logged inside.
846
835
  void structuredSessions.recoverDetachedRuns();
847
836
  // ── Restart endpoint (needs server + wss in scope) ──
848
- function safeServiceInstalled() {
849
- try {
850
- return isServiceInstalled();
851
- }
852
- catch {
853
- return false;
854
- }
855
- }
856
837
  /**
857
838
  * 统一的关服 + 重启。重启方式由 computeRelaunch 决定:
858
839
  * - systemd 托管且已装服务 → 仅退出,交给 Restart=always 用(更新自修复后可能刚被
@@ -975,53 +956,6 @@ export async function startServer(config, configPath, options = {}) {
975
956
  }, MODEL_CATALOG_AUTO_REFRESH_INTERVAL_MS);
976
957
  modelCatalogRefreshTimer.unref();
977
958
  }
978
- // ── Auto-update endpoints ──
979
- app.get("/api/auto-update", requireAdmin, (_req, res) => {
980
- const web = storage.getConfigValue("autoUpdateWeb") === "true";
981
- const apk = storage.getConfigValue("autoUpdateApk") === "true";
982
- const dmg = storage.getConfigValue("autoUpdateDmg") === "true";
983
- const cli = storage.getConfigValue("autoUpdateProviderClis") === "true";
984
- res.json({ web, apk, dmg, cli });
985
- });
986
- app.post("/api/auto-update", requireAdmin, (req, res) => {
987
- const { web, apk, dmg, cli } = req.body;
988
- if (typeof web === "boolean") {
989
- storage.setConfigValue("autoUpdateWeb", String(web));
990
- }
991
- if (typeof apk === "boolean") {
992
- storage.setConfigValue("autoUpdateApk", String(apk));
993
- }
994
- if (typeof dmg === "boolean") {
995
- storage.setConfigValue("autoUpdateDmg", String(dmg));
996
- }
997
- if (typeof cli === "boolean") {
998
- storage.setConfigValue("autoUpdateProviderClis", String(cli));
999
- }
1000
- res.json({
1001
- web: storage.getConfigValue("autoUpdateWeb") === "true",
1002
- apk: storage.getConfigValue("autoUpdateApk") === "true",
1003
- dmg: storage.getConfigValue("autoUpdateDmg") === "true",
1004
- cli: storage.getConfigValue("autoUpdateProviderClis") === "true",
1005
- });
1006
- });
1007
- // ── Update channel (stable / beta) ──
1008
- app.get("/api/update-channel", requireAdmin, (_req, res) => {
1009
- res.json({
1010
- channel: getUpdateChannel(),
1011
- build: {
1012
- commit: BUILD_INFO.commit,
1013
- shortCommit: BUILD_INFO.commit ? BUILD_INFO.commit.slice(0, 7) : null,
1014
- builtAt: BUILD_INFO.builtAt,
1015
- channel: BUILD_INFO.channel,
1016
- },
1017
- });
1018
- });
1019
- app.post("/api/update-channel", requireAdmin, (req, res) => {
1020
- const body = (req.body ?? {});
1021
- const channel = body.channel === "beta" ? "beta" : "stable";
1022
- storage.setConfigValue("updateChannel", channel);
1023
- res.json({ channel });
1024
- });
1025
959
  // Express 4 does not forward rejected route promises automatically. Every
1026
960
  // async route above is wrapped with asyncRoute, and this final middleware
1027
961
  // keeps parser, synchronous middleware, and async failures JSON-shaped.
@@ -1,51 +1,19 @@
1
1
  import { getDefaultModelForProvider } from "./config.js";
2
2
  import { discoverCliSystemAiConfigs, mergeSystemAiConfigs, systemAiProfiles, } from "./system-ai.js";
3
+ import { inferProviderFromCommand, inferProviderFromRunner, isSessionProvider } from "./session-provider.js";
3
4
  /**
4
5
  * Resolve the provider from every representation used by current and legacy
5
6
  * sessions. Older persisted sessions may not have the top-level provider, but
6
7
  * still identify Codex through structuredState, runner, or command.
7
8
  */
8
9
  export function resolveSessionProvider(snapshot) {
9
- if (snapshot.provider === "claude"
10
- || snapshot.provider === "codex"
11
- || snapshot.provider === "opencode"
12
- || snapshot.provider === "grok"
13
- || snapshot.provider === "qoder"
14
- || snapshot.provider === "pi") {
10
+ if (isSessionProvider(snapshot.provider))
15
11
  return snapshot.provider;
16
- }
17
- if (snapshot.structuredState?.provider === "claude"
18
- || snapshot.structuredState?.provider === "codex"
19
- || snapshot.structuredState?.provider === "opencode"
20
- || snapshot.structuredState?.provider === "grok"
21
- || snapshot.structuredState?.provider === "qoder"
22
- || snapshot.structuredState?.provider === "pi") {
23
- return snapshot.structuredState.provider;
24
- }
12
+ const structuredProvider = snapshot.structuredState?.provider;
13
+ if (isSessionProvider(structuredProvider))
14
+ return structuredProvider;
25
15
  const runner = snapshot.runner ?? snapshot.structuredState?.runner;
26
- if (runner === "codex-cli-exec")
27
- return "codex";
28
- if (runner === "opencode-cli-run")
29
- return "opencode";
30
- if (runner === "grok-cli-headless")
31
- return "grok";
32
- if (runner === "qoder-cli-print")
33
- return "qoder";
34
- if (runner === "pi-cli-json")
35
- return "pi";
36
- if (runner === "claude-cli" || runner === "claude-cli-print" || runner === "claude-sdk")
37
- return "claude";
38
- if (/^codex\b/i.test(snapshot.command.trim()))
39
- return "codex";
40
- if (/^opencode\b/i.test(snapshot.command.trim()))
41
- return "opencode";
42
- if (/^grok\b/i.test(snapshot.command.trim()))
43
- return "grok";
44
- if (/^qodercli\b/i.test(snapshot.command.trim()))
45
- return "qoder";
46
- if (/^pi\b/i.test(snapshot.command.trim()))
47
- return "pi";
48
- return "claude";
16
+ return inferProviderFromRunner(runner) ?? inferProviderFromCommand(snapshot.command) ?? "claude";
49
17
  }
50
18
  function normalizeModel(value) {
51
19
  const model = value?.trim();
@@ -0,0 +1,16 @@
1
+ import type { SessionProvider } from "./types.js";
2
+ /** 全部受支持的会话 provider;顺序与 UI / 派发选择器一致。 */
3
+ export declare const SESSION_PROVIDERS: readonly ["claude", "codex", "opencode", "grok", "qoder", "pi"];
4
+ /** 运行时守卫:把配置、数据库、请求体里的任意值收敛成 SessionProvider。 */
5
+ export declare function isSessionProvider(value: unknown): value is SessionProvider;
6
+ /** provider 对应的 CLI 可执行文件名(Qoder 的 CLI 叫 qodercli)。 */
7
+ export declare function providerCliCommand(provider: SessionProvider): string;
8
+ /** 从 runner 名推断 provider;`pty` 等与 provider 无关的 runner 返回 undefined。 */
9
+ export declare function inferProviderFromRunner(runner: unknown): SessionProvider | undefined;
10
+ /**
11
+ * 从启动命令推断 provider;无法识别时返回 undefined,由调用方决定默认值。
12
+ *
13
+ * 识别的是命令行的第一个 token:`claude`、`npx claude`、任意路径下的 `claude`
14
+ * 都算 Claude,其余 provider 要求命令以其 CLI 名开头。
15
+ */
16
+ export declare function inferProviderFromCommand(command: string): SessionProvider | undefined;
@@ -0,0 +1,49 @@
1
+ /** 全部受支持的会话 provider;顺序与 UI / 派发选择器一致。 */
2
+ export const SESSION_PROVIDERS = ["claude", "codex", "opencode", "grok", "qoder", "pi"];
3
+ const SESSION_PROVIDER_SET = new Set(SESSION_PROVIDERS);
4
+ /** 运行时守卫:把配置、数据库、请求体里的任意值收敛成 SessionProvider。 */
5
+ export function isSessionProvider(value) {
6
+ return typeof value === "string" && SESSION_PROVIDER_SET.has(value);
7
+ }
8
+ /** provider 对应的 CLI 可执行文件名(Qoder 的 CLI 叫 qodercli)。 */
9
+ export function providerCliCommand(provider) {
10
+ return provider === "qoder" ? "qodercli" : provider;
11
+ }
12
+ /** 从 runner 名推断 provider;`pty` 等与 provider 无关的 runner 返回 undefined。 */
13
+ export function inferProviderFromRunner(runner) {
14
+ if (runner === "claude-cli" || runner === "claude-cli-print" || runner === "claude-sdk")
15
+ return "claude";
16
+ if (runner === "codex-cli-exec")
17
+ return "codex";
18
+ if (runner === "opencode-cli-run")
19
+ return "opencode";
20
+ if (runner === "grok-cli-headless")
21
+ return "grok";
22
+ if (runner === "qoder-cli-print")
23
+ return "qoder";
24
+ if (runner === "pi-cli-json")
25
+ return "pi";
26
+ return undefined;
27
+ }
28
+ /**
29
+ * 从启动命令推断 provider;无法识别时返回 undefined,由调用方决定默认值。
30
+ *
31
+ * 识别的是命令行的第一个 token:`claude`、`npx claude`、任意路径下的 `claude`
32
+ * 都算 Claude,其余 provider 要求命令以其 CLI 名开头。
33
+ */
34
+ export function inferProviderFromCommand(command) {
35
+ const value = command.trim();
36
+ if (/^(?:claude|npx\s+claude|[^\s]+\/claude)(?:\s|$)/i.test(value))
37
+ return "claude";
38
+ if (/^codex\b/i.test(value))
39
+ return "codex";
40
+ if (/^opencode\b/i.test(value))
41
+ return "opencode";
42
+ if (/^grok\b/i.test(value))
43
+ return "grok";
44
+ if (/^qodercli\b/i.test(value))
45
+ return "qoder";
46
+ if (/^pi\b/i.test(value))
47
+ return "pi";
48
+ return undefined;
49
+ }
@@ -3,6 +3,15 @@ import type { StructuredSessionManager } from "./structured-session-manager.js";
3
3
  import type { WandStorage } from "./storage.js";
4
4
  import type { ExecutionMode, SessionSnapshot } from "./types.js";
5
5
  export type SessionOwner = "structured" | "pty" | "storage";
6
+ type HiddenSessionStore = Pick<WandStorage, "getConfigValue" | "setConfigValue">;
7
+ /** 读取 `hidden_claude_session_ids`;坏数据 / 空值按空集合处理,不抛错。 */
8
+ export declare function readHiddenSessionIds(storage: Pick<HiddenSessionStore, "getConfigValue">): Set<string>;
9
+ /** 写回 `hidden_claude_session_ids`(保持既有的 JSON 数组格式)。 */
10
+ export declare function writeHiddenSessionIds(storage: Pick<HiddenSessionStore, "setConfigValue">, ids: Set<string>): void;
11
+ /** 追加若干 provider 原生 session id;无变化时不写盘。 */
12
+ export declare function addHiddenSessionIds(storage: HiddenSessionStore, ids: readonly string[]): void;
13
+ /** 移除若干 provider 原生 session id;无变化时不写盘。 */
14
+ export declare function removeHiddenSessionIds(storage: HiddenSessionStore, ids: readonly string[]): void;
6
15
  /**
7
16
  * Coordinates session ownership without merging the two runner managers.
8
17
  * Live structured state wins over PTY, and both win over a durable fallback.
@@ -25,3 +34,4 @@ export declare class SessionRegistry {
25
34
  deleteWithProviderHistory(id: string): SessionSnapshot | null;
26
35
  private updateStored;
27
36
  }
37
+ export {};
@@ -1,22 +1,52 @@
1
1
  import { cleanupWorktreeSync } from "./git-worktree.js";
2
+ import { inferProviderFromCommand } from "./session-provider.js";
2
3
  function slimSnapshot(snapshot) {
3
4
  const { output: _output, messages: _messages, ...slim } = snapshot;
4
5
  return { ...slim, output: "" };
5
6
  }
6
- function addHiddenProviderSessionId(storage, id) {
7
+ /** 读取 `hidden_claude_session_ids`;坏数据 / 空值按空集合处理,不抛错。 */
8
+ export function readHiddenSessionIds(storage) {
7
9
  const raw = storage.getConfigValue("hidden_claude_session_ids");
8
- let hidden;
10
+ if (!raw)
11
+ return new Set();
9
12
  try {
10
- const parsed = raw ? JSON.parse(raw) : [];
11
- hidden = new Set(Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : []);
13
+ const parsed = JSON.parse(raw);
14
+ return new Set(Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : []);
12
15
  }
13
16
  catch {
14
- hidden = new Set();
17
+ return new Set();
15
18
  }
16
- if (hidden.has(id))
19
+ }
20
+ /** 写回 `hidden_claude_session_ids`(保持既有的 JSON 数组格式)。 */
21
+ export function writeHiddenSessionIds(storage, ids) {
22
+ storage.setConfigValue("hidden_claude_session_ids", JSON.stringify(Array.from(ids)));
23
+ }
24
+ /** 追加若干 provider 原生 session id;无变化时不写盘。 */
25
+ export function addHiddenSessionIds(storage, ids) {
26
+ if (ids.length === 0)
27
+ return;
28
+ const hidden = readHiddenSessionIds(storage);
29
+ let changed = false;
30
+ for (const id of ids) {
31
+ if (hidden.has(id))
32
+ continue;
33
+ hidden.add(id);
34
+ changed = true;
35
+ }
36
+ if (changed)
37
+ writeHiddenSessionIds(storage, hidden);
38
+ }
39
+ /** 移除若干 provider 原生 session id;无变化时不写盘。 */
40
+ export function removeHiddenSessionIds(storage, ids) {
41
+ if (ids.length === 0)
17
42
  return;
18
- hidden.add(id);
19
- storage.setConfigValue("hidden_claude_session_ids", JSON.stringify(Array.from(hidden)));
43
+ const hidden = readHiddenSessionIds(storage);
44
+ let changed = false;
45
+ for (const id of ids) {
46
+ changed = hidden.delete(id) || changed;
47
+ }
48
+ if (changed)
49
+ writeHiddenSessionIds(storage, hidden);
20
50
  }
21
51
  /**
22
52
  * Coordinates session ownership without merging the two runner managers.
@@ -140,11 +170,8 @@ export class SessionRegistry {
140
170
  return snapshot;
141
171
  const provider = snapshot.provider
142
172
  ?? snapshot.structuredState?.provider
143
- ?? (/^codex\b/i.test(snapshot.command.trim())
144
- ? "codex"
145
- : /^opencode\b/i.test(snapshot.command.trim()) ? "opencode"
146
- : /^grok\b/i.test(snapshot.command.trim()) ? "grok"
147
- : /^qodercli\b/i.test(snapshot.command.trim()) ? "qoder" : "claude");
173
+ ?? inferProviderFromCommand(snapshot.command)
174
+ ?? "claude";
148
175
  if (provider === "claude") {
149
176
  this.processes.deleteClaudeHistoryFiles([{ claudeSessionId: providerSessionId, cwd: snapshot.cwd }]);
150
177
  }
@@ -154,7 +181,7 @@ export class SessionRegistry {
154
181
  else {
155
182
  return snapshot;
156
183
  }
157
- addHiddenProviderSessionId(this.storage, providerSessionId);
184
+ addHiddenSessionIds(this.storage, [providerSessionId]);
158
185
  return snapshot;
159
186
  }
160
187
  updateStored(id, update) {
@@ -1,5 +1,6 @@
1
1
  import { callConfiguredAiText } from "./git-quick-commit.js";
2
2
  import { skipAnsiSequence } from "./pty-text-utils.js";
3
+ import { clipAtWordBoundary } from "./text-utils.js";
3
4
  const MAX_PROMPT_LENGTH = 12_000;
4
5
  const PTY_TOPIC_DRAFT_MAX = 4_000;
5
6
  /**
@@ -140,15 +141,6 @@ export function shouldAcceptGeneratedSessionTitle(title, blockedTitles = []) {
140
141
  return false;
141
142
  return !blockedTitles.some((blocked) => isSameSessionTitle(cleaned, blocked));
142
143
  }
143
- function clipTitle(value, maxLength = TITLE_MAX_LENGTH) {
144
- if (value.length <= maxLength)
145
- return value;
146
- const sliced = value.slice(0, maxLength);
147
- const lastSpace = sliced.lastIndexOf(" ");
148
- if (lastSpace >= Math.floor(maxLength * 0.55))
149
- return sliced.slice(0, lastSpace);
150
- return sliced;
151
- }
152
144
  /** Immediate title from the user's command, without waiting for the model. */
153
145
  export function summarizeSessionTitleFromInput(input, options = {}) {
154
146
  const blockedTitles = options.blockedTitles ?? [];
@@ -156,9 +148,9 @@ export function summarizeSessionTitleFromInput(input, options = {}) {
156
148
  .split(/\r?\n/)
157
149
  .map((part) => part.replace(/^#+\s*/, "").replace(/\s+/g, " ").trim())
158
150
  .filter(Boolean);
159
- const fallback = clipTitle(lines[0] ?? "");
151
+ const fallback = clipAtWordBoundary(lines[0] ?? "", TITLE_MAX_LENGTH);
160
152
  for (const line of lines) {
161
- const title = clipTitle(line);
153
+ const title = clipAtWordBoundary(line, TITLE_MAX_LENGTH);
162
154
  if (shouldAcceptGeneratedSessionTitle(title, blockedTitles))
163
155
  return title;
164
156
  }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * POSIX shell 引用工具。
3
+ *
4
+ * 单引号是唯一在所有 POSIX shell 里都没有展开语义的引用形式;字符串里已有的
5
+ * 单引号用 `'\''` 收尾再续上,保证结果仍然是一个字面量。
6
+ */
7
+ export declare function shellQuote(value: string): string;
8
+ /** 把一组值拼成 `'a' 'b'` 形式的参数列表(用于生成 shell 脚本里的数组字面量)。 */
9
+ export declare function shellQuoteAll(values: readonly string[]): string;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * POSIX shell 引用工具。
3
+ *
4
+ * 单引号是唯一在所有 POSIX shell 里都没有展开语义的引用形式;字符串里已有的
5
+ * 单引号用 `'\''` 收尾再续上,保证结果仍然是一个字面量。
6
+ */
7
+ export function shellQuote(value) {
8
+ return `'${value.replace(/'/g, `'\\''`)}'`;
9
+ }
10
+ /** 把一组值拼成 `'a' 'b'` 形式的参数列表(用于生成 shell 脚本里的数组字面量)。 */
11
+ export function shellQuoteAll(values) {
12
+ return values.map(shellQuote).join(" ");
13
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Unix 信号名与编号之间的互转。
3
+ *
4
+ * Node 只在这些编号上给出稳定语义(Linux/macOS 一致),其余平台差异较大,
5
+ * 因此这里固定一张小表,未知编号回落 SIGTERM、未知名字回落 0。
6
+ */
7
+ /** 名字 → 编号;未知名字返回 0(与 waitpid 的"无信号"一致)。 */
8
+ export declare function signalNumberFromName(signal: string): number;
9
+ /** 编号 → 名字;0 / null 表示没有信号,未知编号回落 SIGTERM。 */
10
+ export declare function signalNameFromNumber(signal: number | null): NodeJS.Signals | null;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Unix 信号名与编号之间的互转。
3
+ *
4
+ * Node 只在这些编号上给出稳定语义(Linux/macOS 一致),其余平台差异较大,
5
+ * 因此这里固定一张小表,未知编号回落 SIGTERM、未知名字回落 0。
6
+ */
7
+ const SIGNAL_NUMBERS = {
8
+ SIGHUP: 1,
9
+ SIGINT: 2,
10
+ SIGQUIT: 3,
11
+ SIGILL: 4,
12
+ SIGTRAP: 5,
13
+ SIGABRT: 6,
14
+ SIGBUS: 7,
15
+ SIGFPE: 8,
16
+ SIGKILL: 9,
17
+ SIGUSR1: 10,
18
+ SIGSEGV: 11,
19
+ SIGUSR2: 12,
20
+ SIGPIPE: 13,
21
+ SIGALRM: 14,
22
+ SIGTERM: 15,
23
+ SIGCHLD: 17,
24
+ SIGCONT: 18,
25
+ SIGSTOP: 19,
26
+ SIGTSTP: 20,
27
+ };
28
+ const SIGNAL_NAMES = new Map(Object.entries(SIGNAL_NUMBERS).map(([name, number]) => [number, name]));
29
+ /** 名字 → 编号;未知名字返回 0(与 waitpid 的"无信号"一致)。 */
30
+ export function signalNumberFromName(signal) {
31
+ return SIGNAL_NUMBERS[signal] ?? 0;
32
+ }
33
+ /** 编号 → 名字;0 / null 表示没有信号,未知编号回落 SIGTERM。 */
34
+ export function signalNameFromNumber(signal) {
35
+ if (signal === null || signal === 0)
36
+ return null;
37
+ return SIGNAL_NAMES.get(signal) ?? "SIGTERM";
38
+ }