@rexvane/piweb 0.3.13 → 0.3.15

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 (103) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +6 -6
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/prerender-manifest.json +3 -3
  5. package/.next/react-loadable-manifest.json +1 -1
  6. package/.next/required-server-files.js +3 -1
  7. package/.next/required-server-files.json +3 -1
  8. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/server/app/_global-error.html +1 -1
  10. package/.next/server/app/_global-error.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  15. package/.next/server/app/_not-found.html +1 -1
  16. package/.next/server/app/_not-found.rsc +1 -1
  17. package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  18. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  19. package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  20. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  21. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  22. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  23. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  24. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  25. package/.next/server/app/api/models/route.js.nft.json +1 -1
  26. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  27. package/.next/server/app/api/prompts/route.js.nft.json +1 -1
  28. package/.next/server/app/api/security/route.js.nft.json +1 -1
  29. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  30. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  31. package/.next/server/app/api/sessions/import/route.js +3 -3
  32. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  33. package/.next/server/app/api/skills/route.js +2 -2
  34. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  35. package/.next/server/app/api/update/route.js +1 -1
  36. package/.next/server/app/api/update/route.js.nft.json +1 -1
  37. package/.next/server/app/api/version/route.js +1 -1
  38. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  39. package/.next/server/app/index.html +1 -1
  40. package/.next/server/app/index.rsc +3 -3
  41. package/.next/server/app/index.segments/__PAGE__.segment.rsc +3 -3
  42. package/.next/server/app/index.segments/_full.segment.rsc +3 -3
  43. package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  44. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  45. package/.next/server/app/login.html +1 -1
  46. package/.next/server/app/login.rsc +2 -2
  47. package/.next/server/app/login.segments/_full.segment.rsc +2 -2
  48. package/.next/server/app/login.segments/_tree.segment.rsc +1 -1
  49. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +2 -2
  50. package/.next/server/app/page_client-reference-manifest.js +1 -1
  51. package/.next/server/app-paths-manifest.json +6 -6
  52. package/.next/server/chunks/496.js +1 -1
  53. package/.next/server/chunks/686.js +1 -1
  54. package/.next/server/middleware-build-manifest.js +1 -1
  55. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  56. package/.next/server/pages/404.html +1 -1
  57. package/.next/server/pages/500.html +1 -1
  58. package/.next/server/server-reference-manifest.json +1 -1
  59. package/.next/static/chunks/4632.0ed92ae42905ce40.js +1 -0
  60. package/.next/static/chunks/4686-7d694def920ff093.js +1 -0
  61. package/.next/static/chunks/{webpack-b1a572e319353f5d.js → webpack-42c5aae79b0e5d8c.js} +1 -1
  62. package/README.md +52 -8
  63. package/assets/archive.png +0 -0
  64. package/assets/growth-1.png +0 -0
  65. package/assets/growth-2.png +0 -0
  66. package/assets/import.png +0 -0
  67. package/assets/main.png +0 -0
  68. package/assets/outline.png +0 -0
  69. package/assets/prompts-1.png +0 -0
  70. package/assets/prompts-2.png +0 -0
  71. package/assets/providers-1.png +0 -0
  72. package/assets/providers-2.png +0 -0
  73. package/assets/providers-3.png +0 -0
  74. package/assets/trace-1.png +0 -0
  75. package/assets/trace-2.png +0 -0
  76. package/assets/trace-3.png +0 -0
  77. package/assets/trace-4.png +0 -0
  78. package/assets/viewer-1.png +0 -0
  79. package/assets/viewer-2.png +0 -0
  80. package/next.config.ts +6 -2
  81. package/package.json +7 -3
  82. package/scripts/launcher.mjs +1 -1
  83. package/scripts/managed-niubash-assets.mjs +146 -0
  84. package/scripts/prepare-niubash-packages.mjs +182 -0
  85. package/scripts/smoke-managed-niubash.mjs +62 -0
  86. package/scripts/verify-package.mjs +53 -0
  87. package/src/components/SessionImportSection.tsx +76 -23
  88. package/src/i18n.tsx +2 -0
  89. package/src/lib/agent-manager.ts +3 -0
  90. package/src/lib/managed-niubash.ts +227 -0
  91. package/src/lib/session-import/claude.ts +23 -2
  92. package/src/lib/session-import/codex.ts +61 -8
  93. package/src/lib/session-import/dsh.ts +12 -1
  94. package/src/lib/session-import/files.ts +14 -0
  95. package/src/lib/session-import/grok.ts +56 -23
  96. package/src/lib/session-import/types.ts +12 -0
  97. package/src/lib/skills-service.ts +51 -14
  98. package/.next/static/chunks/4632.d574c210ad78d065.js +0 -1
  99. package/.next/static/chunks/4686-0a4155f8488314b8.js +0 -1
  100. package/assets/showcase.png +0 -0
  101. package/bin/pi.js +0 -50
  102. /package/.next/static/{olV7jxKRN5VhfEK012WhV → DdBwPYoHBKtaJ97QIr6Co}/_buildManifest.js +0 -0
  103. /package/.next/static/{olV7jxKRN5VhfEK012WhV → DdBwPYoHBKtaJ97QIr6Co}/_ssgManifest.js +0 -0
@@ -36,6 +36,8 @@ interface ImportOutcome {
36
36
  reason?: string;
37
37
  messageCount?: number;
38
38
  workspace?: string;
39
+ /** 已成功导入,但源里的运行时注入/加密内容等无法作为对话还原 */
40
+ skipped?: string[];
39
41
  }
40
42
 
41
43
  interface ImportReport {
@@ -60,6 +62,8 @@ function dayOf(ms?: number): string {
60
62
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
61
63
  }
62
64
 
65
+ const summaryKey = (summary: Pick<Summary, "source" | "externalId">) => `${summary.source}:${summary.externalId}`;
66
+
63
67
  export function SessionImportSection({ cwd }: { cwd: string }) {
64
68
  const { t } = useI18n();
65
69
  const [summaries, setSummaries] = useState<Summary[]>([]);
@@ -83,9 +87,14 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
83
87
  setFailure(json.error || t.toastError);
84
88
  return;
85
89
  }
86
- setSummaries(json.data.sessions ?? []);
90
+ const nextSummaries = (json.data.sessions ?? []) as Summary[];
91
+ const nextImported = new Set<string>(json.data.imported ?? []);
92
+ const selectable = new Set(nextSummaries.map(summaryKey));
93
+ setSummaries(nextSummaries);
87
94
  setErrors(json.data.errors ?? []);
88
- setImportedKeys(new Set<string>(json.data.imported ?? []));
95
+ setImportedKeys(nextImported);
96
+ // 重新扫描后,源会话可能已经消失或被另一个请求导入;不要留下幽灵选择
97
+ setSelected((previous) => new Set([...previous].filter((item) => selectable.has(item) && !nextImported.has(item))));
89
98
  } catch (error) {
90
99
  setFailure(error instanceof Error ? error.message : String(error));
91
100
  } finally {
@@ -109,8 +118,6 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
109
118
  void scan();
110
119
  }, [scan]);
111
120
 
112
- const key = (summary: Summary) => `${summary.source}:${summary.externalId}`;
113
-
114
121
  const groups = useMemo(() => {
115
122
  const bySource = new Map<string, Summary[]>();
116
123
  for (const summary of summaries) {
@@ -120,16 +127,30 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
120
127
  }
121
128
  return SOURCE_ORDER.filter((source) => bySource.has(source)).map((source) => {
122
129
  const rows = [...(bySource.get(source) as Summary[])].sort((left, right) => (right.updatedAt ?? right.createdAt ?? 0) - (left.updatedAt ?? left.createdAt ?? 0));
123
- return { source, rows, imported: rows.filter((row) => importedKeys.has(key(row))).length };
130
+ return { source, rows, imported: rows.filter((row) => importedKeys.has(summaryKey(row))).length };
124
131
  });
125
132
  }, [summaries, importedKeys]);
126
133
 
134
+ /**
135
+ * 一次只显示一个来源:六个工具各列 15 条就是 90 行,全铺出来既难找也没必要。
136
+ * 首次打开默认落到“最近活动会话”所在的来源;用户切换后保持选择。
137
+ * 直接派生有效来源,不用 effect 回填,避免初始化 effect 覆盖用户刚完成的点击。
138
+ */
139
+ const [activeSource, setActiveSource] = useState("");
140
+ const defaultSource = groups.reduce<{ source: string; at: number } | null>((latest, group) => {
141
+ const first = group.rows[0];
142
+ const at = first?.updatedAt ?? first?.createdAt ?? 0;
143
+ return !latest || at > latest.at ? { source: group.source, at } : latest;
144
+ }, null)?.source ?? "";
145
+ const visibleSource = activeSource && groups.some((group) => group.source === activeSource) ? activeSource : defaultSource;
146
+ const activeGroup = groups.find((group) => group.source === visibleSource);
147
+
127
148
  const toggle = (summary: Summary) => {
128
- if (importedKeys.has(key(summary))) return;
149
+ if (importedKeys.has(summaryKey(summary))) return;
129
150
  setSelected((previous) => {
130
151
  const next = new Set(previous);
131
- if (next.has(key(summary))) next.delete(key(summary));
132
- else next.add(key(summary));
152
+ if (next.has(summaryKey(summary))) next.delete(summaryKey(summary));
153
+ else next.add(summaryKey(summary));
133
154
  return next;
134
155
  });
135
156
  };
@@ -138,9 +159,9 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
138
159
  setSelected((previous) => {
139
160
  const next = new Set(previous);
140
161
  for (const row of rows) {
141
- if (importedKeys.has(key(row))) continue;
142
- if (checked) next.add(key(row));
143
- else next.delete(key(row));
162
+ if (importedKeys.has(summaryKey(row))) continue;
163
+ if (checked) next.add(summaryKey(row));
164
+ else next.delete(summaryKey(row));
144
165
  }
145
166
  return next;
146
167
  });
@@ -148,7 +169,7 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
148
169
 
149
170
  const run = async () => {
150
171
  if (!selected.size) return;
151
- const items = summaries.filter((summary) => selected.has(key(summary))).map((summary) => ({ source: summary.source, externalId: summary.externalId }));
172
+ const items = summaries.filter((summary) => selected.has(summaryKey(summary))).map((summary) => ({ source: summary.source, externalId: summary.externalId }));
152
173
  setImporting(true);
153
174
  setFailure(null);
154
175
  setReport(null);
@@ -187,6 +208,7 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
187
208
 
188
209
  const failures = report?.results.filter((result) => result.status === "failed") ?? [];
189
210
  const skipped = report?.results.filter((result) => result.status === "skipped") ?? [];
211
+ const importedWithNotes = report?.results.filter((result) => result.status === "imported" && result.skipped?.length) ?? [];
190
212
 
191
213
  return (
192
214
  <div className="flex flex-col gap-4" data-testid="session-import">
@@ -266,6 +288,11 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
266
288
  {sourceLabel(result.source)} · {result.title || result.externalId} — {result.reason}
267
289
  </div>
268
290
  ))}
291
+ {importedWithNotes.map((result) => (
292
+ <div key={`note-${result.source}-${result.externalId}`} className="mt-1" style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }}>
293
+ {sourceLabel(result.source)} · {result.title || result.externalId} — {result.skipped?.join(";")}
294
+ </div>
295
+ ))}
269
296
  {failures.map((result) => (
270
297
  <div key={`fail-${result.source}-${result.externalId}`} className="mt-1" style={{ fontSize: 11.5, color: "var(--dsw-danger)" }}>
271
298
  {sourceLabel(result.source)} · {result.title || result.externalId} — {result.reason}
@@ -276,26 +303,52 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
276
303
 
277
304
  {!scanning && !summaries.length && !errors.length && <div style={{ fontSize: 13, color: "var(--dsw-label-caption)" }}>{t.importEmpty}</div>}
278
305
 
279
- {groups.map((group) => (
280
- <div key={group.source} className="flex flex-col gap-2" data-testid={`import-group-${group.source}`}>
306
+ {/* 来源选择器:选中哪个就只列哪个工具的会话 */}
307
+ {groups.length > 0 && (
308
+ <div className="flex flex-wrap items-center gap-1.5" role="tablist" aria-label={t.importPickSource} data-testid="import-source-tabs">
309
+ {groups.map((group) => {
310
+ const active = group.source === visibleSource;
311
+ return (
312
+ <button
313
+ key={group.source}
314
+ type="button"
315
+ role="tab"
316
+ aria-selected={active}
317
+ className="rounded-full px-3 py-1 transition-colors"
318
+ style={{
319
+ fontSize: 12,
320
+ border: `0.5px solid ${active ? "var(--dsw-accent)" : "var(--dsw-border-l2)"}`,
321
+ background: active ? "var(--dsw-accent)" : "transparent",
322
+ color: active ? "#fff" : "var(--dsw-label-tertiary)",
323
+ }}
324
+ onClick={() => setActiveSource(group.source)}
325
+ data-testid={`import-source-${group.source}`}
326
+ >
327
+ {sourceLabel(group.source)}
328
+ <span style={{ marginLeft: 6, opacity: 0.75 }}>{group.rows.length}</span>
329
+ </button>
330
+ );
331
+ })}
332
+ </div>
333
+ )}
334
+
335
+ {activeGroup && (
336
+ <div className="flex flex-col gap-2" data-testid={`import-group-${activeGroup.source}`}>
281
337
  <div className="flex items-center gap-2">
282
- <span className="rounded-full px-2 py-0.5" style={{ fontSize: 10.5, background: "var(--dsw-hover)", color: "var(--dsw-label-tertiary)" }}>
283
- {sourceLabel(group.source)}
284
- </span>
285
338
  <span style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }}>
286
- {t.importGroupCount.replace("{n}", String(group.rows.length)).replace("{imported}", String(group.imported))}
339
+ {t.importGroupCount.replace("{n}", String(activeGroup.rows.length)).replace("{imported}", String(activeGroup.imported))}
287
340
  </span>
288
341
  <div className="flex-1" />
289
- <button className="rounded-lg px-2 py-0.5" style={{ fontSize: 11.5, color: "var(--dsw-label-tertiary)" }} onClick={() => selectGroup(group.rows, true)}>
342
+ <button className="rounded-lg px-2 py-0.5" style={{ fontSize: 11.5, color: "var(--dsw-label-tertiary)" }} onClick={() => selectGroup(activeGroup.rows, true)}>
290
343
  {t.importSelectAll}
291
344
  </button>
292
- <button className="rounded-lg px-2 py-0.5" style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }} onClick={() => selectGroup(group.rows, false)}>
345
+ <button className="rounded-lg px-2 py-0.5" style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }} onClick={() => selectGroup(activeGroup.rows, false)}>
293
346
  {t.importSelectNone}
294
347
  </button>
295
348
  </div>
296
349
  <div className="flex flex-col gap-1.5">
297
- {group.rows.map((row) => {
298
- const rowKey = key(row);
350
+ {activeGroup.rows.map((row) => {
351
+ const rowKey = summaryKey(row);
299
352
  const done = importedKeys.has(rowKey);
300
353
  const checked = selected.has(rowKey);
301
354
  return (
@@ -327,7 +380,7 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
327
380
  })}
328
381
  </div>
329
382
  </div>
330
- ))}
383
+ )}
331
384
  </div>
332
385
  );
333
386
  }
package/src/i18n.tsx CHANGED
@@ -387,6 +387,7 @@ const zh = {
387
387
  importSourceOpencode: "opencode",
388
388
  importSourceError: "{source} 扫描失败:{message}",
389
389
  importGroupCount: "{n} 条 · 已导入 {imported}",
390
+ importPickSource: "选择来源",
390
391
  importLimitHint: "每个来源只列最近 {n} 条对话(更早的会话不扫描,省时省资源)",
391
392
  importLimitHintTooltip: "看更早的会话:把 src/lib/session-import/index.ts 里的 SCAN_LIMIT_PER_SOURCE 调大",
392
393
  importSelectAll: "全选本组",
@@ -985,6 +986,7 @@ const en: Dict = {
985
986
  importSourceOpencode: "opencode",
986
987
  importSourceError: "{source} scan failed: {message}",
987
988
  importGroupCount: "{n} sessions · {imported} imported",
989
+ importPickSource: "Choose a source",
988
990
  importLimitHint: "Only the {n} most recent conversations per source are scanned",
989
991
  importLimitHintTooltip: "To see older sessions, raise SCAN_LIMIT_PER_SOURCE in src/lib/session-import/index.ts",
990
992
  importSelectAll: "Select group",
@@ -35,6 +35,7 @@ import { sanitizeToolOutput } from "./text-sanitize";
35
35
  import { BoundaryError } from "./path-security";
36
36
  import { estimateTokensOf } from "./process-format";
37
37
  import { getPiSettings } from "./pi-settings";
38
+ import { createManagedNiubashTools } from "./managed-niubash";
38
39
  import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
39
40
  import type {
40
41
  AgentCommand,
@@ -799,6 +800,8 @@ export async function ensureSession(m: Managed): Promise<AgentSession> {
799
800
  modelRuntime: await getModelRuntime(),
800
801
  resourceLoader: loader,
801
802
  settingsManager: getSettingsManager(m.cwd),
803
+ // Windows: 同名 bash 覆盖 SDK 内置工具,指向托管 niubash;其它平台返回 [],走 Pi 默认。
804
+ customTools: createManagedNiubashTools(m.cwd),
802
805
  });
803
806
  created = session;
804
807
  if (m.disposed) throw new Error("session is disposed");
@@ -0,0 +1,227 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import path from "node:path";
5
+ import { createBashToolDefinition, type ToolDefinition } from "@earendil-works/pi-coding-agent";
6
+ import { APP_ROOT } from "./app-root";
7
+
8
+ const DISTRIBUTION_VERSION = "1.1.4-piweb.0";
9
+ const NIUBASH_VERSION = "1.1.4";
10
+ const SOURCE_REPOSITORY = "https://github.com/unixwin/niubash";
11
+ const SOURCE_TAG = "v1.1.4";
12
+
13
+ const PACKAGE_BY_ARCH = {
14
+ x64: {
15
+ packageName: "@rexvane/piweb-niubash-win32-x64",
16
+ directory: "piweb-niubash-win32-x64",
17
+ asset: "niubash-v1.1.4-win-x64.zip",
18
+ assetSha256: "9f543634288a316f7ed8e5a76ff794eca402cb21e74b8f8a551daffafde162f5",
19
+ machine: 0x8664,
20
+ },
21
+ arm64: {
22
+ packageName: "@rexvane/piweb-niubash-win32-arm64",
23
+ directory: "piweb-niubash-win32-arm64",
24
+ asset: "niubash-v1.1.4-win-arm64.zip",
25
+ assetSha256: "77e98ba8d7f6be70c35388bcc28bdbc883c06bcaafffbe186a88788ad32ccf00",
26
+ machine: 0xaa64,
27
+ },
28
+ } as const;
29
+
30
+ const LICENSE_HASHES = {
31
+ "NIUBASH.txt": "1290aae99e71239ee3154e7847830e49e7a36c758fa6d659d73fa752dc9f81c8",
32
+ "RUBASH.txt": "e426a75a20633f3088da22aef61a5a44d06f1f884c600f6011d00702c3989079",
33
+ "WINUXCMD.txt": "8ffc4ed56f5e6ab8183a7ece2e3aa6ea4800b0a4e8170d11bd60856f2dd760f3",
34
+ } as const;
35
+
36
+ export type SupportedArch = keyof typeof PACKAGE_BY_ARCH;
37
+
38
+ type PackageExport = {
39
+ schema?: unknown;
40
+ packageName?: unknown;
41
+ distributionVersion?: unknown;
42
+ niubashVersion?: unknown;
43
+ platform?: unknown;
44
+ arch?: unknown;
45
+ runtimeDir?: unknown;
46
+ shellPath?: unknown;
47
+ manifestPath?: unknown;
48
+ };
49
+
50
+ type LoadedPackage = { entryPath: string; value: unknown };
51
+
52
+ /**
53
+ * npm 安装(全局或本地)普通情况下由 Node 解析:npm 会把可选包提升到任意祖先
54
+ * node_modules,所以解析必须从包自己的 package.json 出发。仓库检出(开发 / CI /
55
+ * 发布前的 runtime 准备)没有安装这一步,此时按约定找 platform-packages/<dir>——
56
+ * 这与平台包发布后的目录布局一一对应,不引入第二套解析规则。
57
+ */
58
+ export function resolveManagedNiubashPackageEntry(packageName: string, arch: SupportedArch, root = APP_ROOT): LoadedPackage {
59
+ const require = createRequire(path.join(root, "package.json"));
60
+ try {
61
+ return { entryPath: require.resolve(packageName), value: require(packageName) };
62
+ } catch (error) {
63
+ const checkoutEntry = path.join(root, "platform-packages", PACKAGE_BY_ARCH[arch].directory, "index.cjs");
64
+ if (fs.existsSync(checkoutEntry)) return { entryPath: checkoutEntry, value: require(checkoutEntry) };
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ export type ManagedNiubashRuntime = {
70
+ packageName: string;
71
+ distributionVersion: string;
72
+ niubashVersion: string;
73
+ arch: SupportedArch;
74
+ runtimeDir: string;
75
+ shellPath: string;
76
+ };
77
+
78
+ export type ManagedNiubashResolveOptions = {
79
+ platform?: string;
80
+ arch?: string;
81
+ /** Base for package resolution; tests and the prepare script point it at a checkout root. */
82
+ root?: string;
83
+ loadPackage?: (packageName: string, arch: SupportedArch) => LoadedPackage;
84
+ warn?: (message: string) => void;
85
+ };
86
+
87
+ function sha256File(filename: string): string {
88
+ return crypto.createHash("sha256").update(fs.readFileSync(filename)).digest("hex");
89
+ }
90
+
91
+ function readJson(filename: string): any {
92
+ const stat = fs.statSync(filename);
93
+ if (!stat.isFile() || stat.size > 2 * 1024 * 1024) throw new Error(`${path.basename(filename)} is missing or too large`);
94
+ return JSON.parse(fs.readFileSync(filename, "utf8"));
95
+ }
96
+
97
+ function samePath(left: string, right: string): boolean {
98
+ const a = path.resolve(left);
99
+ const b = path.resolve(right);
100
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
101
+ }
102
+
103
+ function peMachine(filename: string): number {
104
+ const data = fs.readFileSync(filename);
105
+ if (data.length < 64 || data[0] !== 0x4d || data[1] !== 0x5a) throw new Error(`${path.basename(filename)} is not a PE executable`);
106
+ const offset = data.readUInt32LE(0x3c);
107
+ if (offset + 6 > data.length || data.toString("ascii", offset, offset + 4) !== "PE\0\0") {
108
+ throw new Error(`${path.basename(filename)} has an invalid PE header`);
109
+ }
110
+ return data.readUInt16LE(offset + 4);
111
+ }
112
+
113
+ function validatePackage(loaded: LoadedPackage, arch: SupportedArch): ManagedNiubashRuntime {
114
+ const expected = PACKAGE_BY_ARCH[arch];
115
+ if (!loaded.value || typeof loaded.value !== "object") throw new Error(`${expected.packageName} has no runtime export`);
116
+ const exported = loaded.value as PackageExport;
117
+ const packageRoot = path.dirname(path.resolve(loaded.entryPath));
118
+ const runtimeDir = path.join(packageRoot, "runtime");
119
+ const shellPath = path.join(runtimeDir, "niu.exe");
120
+ const manifestPath = path.join(packageRoot, "runtime-manifest.json");
121
+
122
+ const checks: Array<[unknown, unknown, string]> = [
123
+ [exported.schema, 1, "export schema"],
124
+ [exported.packageName, expected.packageName, "export package name"],
125
+ [exported.distributionVersion, DISTRIBUTION_VERSION, "export distribution version"],
126
+ [exported.niubashVersion, NIUBASH_VERSION, "export niubash version"],
127
+ [exported.platform, "win32", "export platform"],
128
+ [exported.arch, arch, "export architecture"],
129
+ ];
130
+ for (const [actual, wanted, label] of checks) {
131
+ if (actual !== wanted) throw new Error(`${label} mismatch`);
132
+ }
133
+ if (typeof exported.runtimeDir !== "string" || !samePath(exported.runtimeDir, runtimeDir)) throw new Error("runtime directory escapes its package");
134
+ if (typeof exported.shellPath !== "string" || !samePath(exported.shellPath, shellPath)) throw new Error("shell path escapes its package");
135
+ if (typeof exported.manifestPath !== "string" || !samePath(exported.manifestPath, manifestPath)) throw new Error("manifest path escapes its package");
136
+
137
+ const packageInfo = readJson(path.join(packageRoot, "package.json"));
138
+ if (packageInfo.name !== expected.packageName || packageInfo.version !== DISTRIBUTION_VERSION) throw new Error("package identity mismatch");
139
+ if (packageInfo.os?.length !== 1 || packageInfo.os[0] !== "win32") throw new Error("package OS restriction mismatch");
140
+ if (packageInfo.cpu?.length !== 1 || packageInfo.cpu[0] !== arch) throw new Error("package CPU restriction mismatch");
141
+
142
+ const manifest = readJson(manifestPath);
143
+ if (manifest.schema !== 1
144
+ || manifest.source?.repository !== SOURCE_REPOSITORY
145
+ || manifest.source?.tag !== SOURCE_TAG
146
+ || manifest.source?.asset !== expected.asset
147
+ || manifest.source?.sha256 !== expected.assetSha256) {
148
+ throw new Error("runtime provenance mismatch");
149
+ }
150
+ if (!manifest.files || typeof manifest.files !== "object" || Array.isArray(manifest.files)) throw new Error("runtime file manifest is missing");
151
+ const files = Object.entries(manifest.files) as Array<[string, unknown]>;
152
+ if (files.length < 300 || files.length > 1000) throw new Error(`runtime file count is invalid (${files.length})`);
153
+ for (const [relative, digest] of files) {
154
+ if (!relative || relative.includes("\\") || path.posix.isAbsolute(relative) || /^[A-Za-z]:/.test(relative) || relative.split("/").includes("..")) {
155
+ throw new Error(`unsafe runtime path: ${relative}`);
156
+ }
157
+ if (typeof digest !== "string" || !/^[a-f0-9]{64}$/.test(digest)) throw new Error(`invalid runtime hash: ${relative}`);
158
+ const filename = path.join(runtimeDir, ...relative.split("/"));
159
+ if (!fs.statSync(filename).isFile()) throw new Error(`runtime file is missing: ${relative}`);
160
+ if (sha256File(filename) !== digest) throw new Error(`runtime file failed integrity check: ${relative}`);
161
+ }
162
+ for (const required of ["niu.exe", "winuxcmd/usr/bin/winuxcmd.exe", "bundles/oh-my-niu/bundle.toml"]) {
163
+ if (!(required in manifest.files)) throw new Error(`runtime file is missing: ${required}`);
164
+ }
165
+ for (const executable of [shellPath, path.join(runtimeDir, "winuxcmd", "usr", "bin", "winuxcmd.exe")]) {
166
+ if (peMachine(executable) !== expected.machine) throw new Error(`${path.basename(executable)} architecture mismatch`);
167
+ }
168
+ for (const [name, digest] of Object.entries(LICENSE_HASHES)) {
169
+ if (sha256File(path.join(packageRoot, "licenses", name)) !== digest) throw new Error(`license integrity check failed: ${name}`);
170
+ }
171
+
172
+ return Object.freeze({
173
+ packageName: expected.packageName,
174
+ distributionVersion: DISTRIBUTION_VERSION,
175
+ niubashVersion: NIUBASH_VERSION,
176
+ arch,
177
+ runtimeDir,
178
+ shellPath,
179
+ });
180
+ }
181
+
182
+ function reasonOf(error: unknown, packageName: string): string {
183
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "MODULE_NOT_FOUND") return `${packageName} is not installed`;
184
+ return error instanceof Error ? error.message : String(error);
185
+ }
186
+
187
+ /** Resolve and fully validate the platform package. Failures deliberately fall back to Pi's normal Bash. */
188
+ export function resolveManagedNiubash(options: ManagedNiubashResolveOptions = {}): ManagedNiubashRuntime | null {
189
+ const platform = options.platform ?? process.platform;
190
+ const arch = options.arch ?? process.arch;
191
+ if (platform !== "win32") return null;
192
+ const expected = PACKAGE_BY_ARCH[arch as SupportedArch];
193
+ if (!expected) {
194
+ (options.warn ?? console.warn)(`[piweb] Managed niubash does not support Windows ${arch}; using Pi's configured/default Bash.`);
195
+ return null;
196
+ }
197
+ const supported = arch as SupportedArch;
198
+ try {
199
+ const loaded = options.loadPackage
200
+ ? options.loadPackage(expected.packageName, supported)
201
+ : resolveManagedNiubashPackageEntry(expected.packageName, supported, options.root);
202
+ return validatePackage(loaded, supported);
203
+ } catch (error) {
204
+ (options.warn ?? console.warn)(
205
+ `[piweb] Managed niubash is unavailable (${reasonOf(error, expected.packageName)}); using Pi's configured/default Bash. `
206
+ + "Reinstall @rexvane/piweb to restore the bundled Windows shell (`npm run prepare:niubash` in a checkout).",
207
+ );
208
+ return null;
209
+ }
210
+ }
211
+
212
+ const globalForManagedNiubash = globalThis as typeof globalThis & {
213
+ __piWebManagedNiubash?: { resolved: ManagedNiubashRuntime | null };
214
+ };
215
+
216
+ function defaultRuntime(): ManagedNiubashRuntime | null {
217
+ if (!globalForManagedNiubash.__piWebManagedNiubash) {
218
+ globalForManagedNiubash.__piWebManagedNiubash = { resolved: resolveManagedNiubash() };
219
+ }
220
+ return globalForManagedNiubash.__piWebManagedNiubash.resolved;
221
+ }
222
+
223
+ /** Same-name custom tool intentionally replaces Pi's built-in bash only inside PiWeb sessions. */
224
+ export function createManagedNiubashTools(cwd: string, options?: ManagedNiubashResolveOptions): ToolDefinition[] {
225
+ const runtime = options ? resolveManagedNiubash(options) : defaultRuntime();
226
+ return runtime ? [createBashToolDefinition(cwd, { shellPath: runtime.shellPath }) as ToolDefinition] : [];
227
+ }
@@ -19,6 +19,15 @@ const SYNTHETIC_PREFIXES = ["<command-name>", "<command-message>", "<local-comma
19
19
 
20
20
  const isSynthetic = (text: string) => SYNTHETIC_PREFIXES.some((prefix) => text.startsWith(prefix));
21
21
 
22
+ /**
23
+ * 子代理的转写不能当作会话:Claude Code 把它们放在
24
+ * `<项目>/<会话 uuid>/subagents/` 下(含更深的 workflows/wf_<id>/ 层,本机 236 个子代理文件 vs 70 个主会话)。
25
+ * 那是主代理派出去的活,不是你在用的那条对话。
26
+ * 这些文件里的记录全是 isSidechain,本来也会被 isMessageLine 挡掉,
27
+ * 这里再按路径显式排除一次:意图更清楚,也不怕以后格式变化。
28
+ */
29
+ const isSubagentTranscript = (file: string) => file.split(/[/\\]/).includes("subagents");
30
+
22
31
  function isMessageLine(record: Record<string, unknown>): boolean {
23
32
  return (record.type === "user" || record.type === "assistant") && record.isSidechain !== true && Boolean(record.message);
24
33
  }
@@ -115,6 +124,7 @@ export const claudeSource: ImportSourceModule = {
115
124
  const out: ExternalSessionSummary[] = [];
116
125
  for (const { file, mtime } of candidates) {
117
126
  if (reachedLimit(out.length, limit)) break; // 只读最近的若干条:后面的文件根本不打开
127
+ if (isSubagentTranscript(file)) continue; // 主代理派给子代理的活不算会话
118
128
  let head: string;
119
129
  try {
120
130
  head = await readHead(file);
@@ -150,6 +160,7 @@ export const claudeSource: ImportSourceModule = {
150
160
  const skipped: string[] = [];
151
161
  let sidechains = 0;
152
162
  let synthetic = 0;
163
+ let runtimeContext = 0;
153
164
  let projectPath = summary.projectPath;
154
165
  let model = summary.model;
155
166
  let provider: string | undefined;
@@ -159,6 +170,12 @@ export const claudeSource: ImportSourceModule = {
159
170
  sidechains += 1;
160
171
  continue;
161
172
  }
173
+ // Claude 的 attachment/system 是技能列表、token 提醒、环境快照等运行时上下文;
174
+ // pi 会按目标工作区生成自己的版本,不能伪装成真人 user 消息,但要在报告中明确告知。
175
+ if (record.type === "attachment" || record.type === "system") {
176
+ runtimeContext += 1;
177
+ continue;
178
+ }
162
179
  if (typeof record.cwd === "string" && record.cwd.trim()) projectPath = projectPath ?? record.cwd;
163
180
  const message = record.message as Record<string, unknown> | undefined;
164
181
  if (!message) continue;
@@ -196,9 +213,12 @@ export const claudeSource: ImportSourceModule = {
196
213
  if (record.type === "user") {
197
214
  const { text: textBlocks, results } = splitUserContent((message as { content?: unknown }).content);
198
215
  const plain = textBlocks.filter((block) => block.type === "text").map((block) => block.text).join("\n");
199
- const keepText = textBlocks.filter((block) => block.type !== "text");
216
+ const nonText = textBlocks.filter((block) => block.type !== "text");
200
217
  if (plain && !isSynthetic(plain.trim()) && !/^Caveat:/i.test(plain.trim())) {
201
- entries.push({ type: "message", message: { role: "user", content: [...keepText, { type: "text", text: plain }], timestamp } as ImportedMessage });
218
+ entries.push({ type: "message", message: { role: "user", content: [...nonText, { type: "text", text: plain }], timestamp } as ImportedMessage });
219
+ } else if (!plain && nonText.length) {
220
+ // 图片可以是用户消息的全部内容,不能因为没有文字就把整轮丢掉
221
+ entries.push({ type: "message", message: { role: "user", content: nonText, timestamp } as ImportedMessage });
202
222
  } else if (plain) {
203
223
  synthetic += 1;
204
224
  }
@@ -213,6 +233,7 @@ export const claudeSource: ImportSourceModule = {
213
233
 
214
234
  if (sidechains) skipped.push(`跳过 ${sidechains} 条侧链(子代理)记录`);
215
235
  if (synthetic) skipped.push(`跳过 ${synthetic} 条合成输入(命令回显/系统提醒)`);
236
+ if (runtimeContext) skipped.push(`跳过 ${runtimeContext} 条源工具运行时上下文(pi 会按当前工作区重新生成)`);
216
237
  if (!entries.some((entry) => entry.type === "message")) return null;
217
238
  return { summary: { ...summary, projectPath, model, provider }, entries, skipped };
218
239
  },