@rexvane/piweb 0.3.14 → 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 (92) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +5 -5
  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.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/server/app/_not-found.html +1 -1
  14. package/.next/server/app/_not-found.rsc +1 -1
  15. package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  16. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  17. package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  18. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  19. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  20. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  21. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  22. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  23. package/.next/server/app/api/models/route.js.nft.json +1 -1
  24. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  25. package/.next/server/app/api/prompts/route.js.nft.json +1 -1
  26. package/.next/server/app/api/security/route.js.nft.json +1 -1
  27. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  28. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  29. package/.next/server/app/api/sessions/import/route.js +3 -3
  30. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  31. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  32. package/.next/server/app/api/update/route.js +1 -1
  33. package/.next/server/app/api/update/route.js.nft.json +1 -1
  34. package/.next/server/app/api/version/route.js +1 -1
  35. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  36. package/.next/server/app/index.html +1 -1
  37. package/.next/server/app/index.rsc +1 -1
  38. package/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  39. package/.next/server/app/index.segments/_full.segment.rsc +1 -1
  40. package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  41. package/.next/server/app/login.html +1 -1
  42. package/.next/server/app/login.rsc +1 -1
  43. package/.next/server/app/login.segments/_full.segment.rsc +1 -1
  44. package/.next/server/app/login.segments/_tree.segment.rsc +1 -1
  45. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
  46. package/.next/server/app-paths-manifest.json +5 -5
  47. package/.next/server/chunks/686.js +1 -1
  48. package/.next/server/middleware-build-manifest.js +1 -1
  49. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  50. package/.next/server/pages/404.html +1 -1
  51. package/.next/server/pages/500.html +1 -1
  52. package/.next/server/server-reference-manifest.json +1 -1
  53. package/.next/static/chunks/4632.0ed92ae42905ce40.js +1 -0
  54. package/.next/static/chunks/{webpack-d28e132e1b8c100c.js → webpack-42c5aae79b0e5d8c.js} +1 -1
  55. package/README.md +51 -7
  56. package/assets/archive.png +0 -0
  57. package/assets/growth-1.png +0 -0
  58. package/assets/growth-2.png +0 -0
  59. package/assets/import.png +0 -0
  60. package/assets/main.png +0 -0
  61. package/assets/outline.png +0 -0
  62. package/assets/prompts-1.png +0 -0
  63. package/assets/prompts-2.png +0 -0
  64. package/assets/providers-1.png +0 -0
  65. package/assets/providers-2.png +0 -0
  66. package/assets/providers-3.png +0 -0
  67. package/assets/trace-1.png +0 -0
  68. package/assets/trace-2.png +0 -0
  69. package/assets/trace-3.png +0 -0
  70. package/assets/trace-4.png +0 -0
  71. package/assets/viewer-1.png +0 -0
  72. package/assets/viewer-2.png +0 -0
  73. package/next.config.ts +6 -2
  74. package/package.json +7 -3
  75. package/scripts/launcher.mjs +1 -1
  76. package/scripts/managed-niubash-assets.mjs +146 -0
  77. package/scripts/prepare-niubash-packages.mjs +182 -0
  78. package/scripts/smoke-managed-niubash.mjs +62 -0
  79. package/scripts/verify-package.mjs +53 -0
  80. package/src/components/SessionImportSection.tsx +36 -20
  81. package/src/lib/agent-manager.ts +3 -0
  82. package/src/lib/managed-niubash.ts +227 -0
  83. package/src/lib/session-import/claude.ts +13 -2
  84. package/src/lib/session-import/codex.ts +44 -8
  85. package/src/lib/session-import/files.ts +14 -0
  86. package/src/lib/session-import/grok.ts +54 -24
  87. package/src/lib/session-import/types.ts +12 -0
  88. package/.next/static/chunks/4632.112d2e893d48d188.js +0 -1
  89. package/assets/showcase.png +0 -0
  90. package/bin/pi.js +0 -50
  91. /package/.next/static/{LOFl9bqqMV2goBV8erESO → DdBwPYoHBKtaJ97QIr6Co}/_buildManifest.js +0 -0
  92. /package/.next/static/{LOFl9bqqMV2goBV8erESO → DdBwPYoHBKtaJ97QIr6Co}/_ssgManifest.js +0 -0
@@ -0,0 +1,146 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export const NIUBASH_VERSION = "1.1.4";
6
+ export const NIUBASH_TAG = `v${NIUBASH_VERSION}`;
7
+ export const NIUBASH_DISTRIBUTION_VERSION = `${NIUBASH_VERSION}-piweb.0`;
8
+ export const NIUBASH_REPOSITORY = "https://github.com/unixwin/niubash";
9
+
10
+ export const MANAGED_NIUBASH_ASSETS = Object.freeze({
11
+ x64: Object.freeze({
12
+ arch: "x64",
13
+ machine: 0x8664,
14
+ packageName: "@rexvane/piweb-niubash-win32-x64",
15
+ directory: "piweb-niubash-win32-x64",
16
+ asset: "niubash-v1.1.4-win-x64.zip",
17
+ sha256: "9f543634288a316f7ed8e5a76ff794eca402cb21e74b8f8a551daffafde162f5",
18
+ }),
19
+ arm64: Object.freeze({
20
+ arch: "arm64",
21
+ machine: 0xaa64,
22
+ packageName: "@rexvane/piweb-niubash-win32-arm64",
23
+ directory: "piweb-niubash-win32-arm64",
24
+ asset: "niubash-v1.1.4-win-arm64.zip",
25
+ sha256: "77e98ba8d7f6be70c35388bcc28bdbc883c06bcaafffbe186a88788ad32ccf00",
26
+ }),
27
+ });
28
+
29
+ export const NIUBASH_LICENSES = Object.freeze([
30
+ Object.freeze({
31
+ filename: "NIUBASH.txt",
32
+ url: "https://raw.githubusercontent.com/unixwin/niubash/v1.1.4/LICENSE",
33
+ sha256: "1290aae99e71239ee3154e7847830e49e7a36c758fa6d659d73fa752dc9f81c8",
34
+ }),
35
+ Object.freeze({
36
+ filename: "RUBASH.txt",
37
+ url: "https://raw.githubusercontent.com/unixwin/rubash/8b81c7501646/LICENSE",
38
+ sha256: "e426a75a20633f3088da22aef61a5a44d06f1f884c600f6011d00702c3989079",
39
+ }),
40
+ Object.freeze({
41
+ filename: "WINUXCMD.txt",
42
+ url: "https://raw.githubusercontent.com/unixwin/WinuxCmd/v1.0.8/LICENSE",
43
+ sha256: "8ffc4ed56f5e6ab8183a7ece2e3aa6ea4800b0a4e8170d11bd60856f2dd760f3",
44
+ }),
45
+ ]);
46
+
47
+ export function niubashAssetUrl(asset) {
48
+ return `${NIUBASH_REPOSITORY}/releases/download/${NIUBASH_TAG}/${asset.asset}`;
49
+ }
50
+
51
+ export function sha256(data) {
52
+ return crypto.createHash("sha256").update(data).digest("hex");
53
+ }
54
+
55
+ export function sha256File(filename) {
56
+ return sha256(fs.readFileSync(filename));
57
+ }
58
+
59
+ export function listFiles(root) {
60
+ const files = [];
61
+ const visit = (directory, prefix = "") => {
62
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
63
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
64
+ const absolute = path.join(directory, entry.name);
65
+ if (entry.isDirectory()) visit(absolute, relative);
66
+ else if (entry.isFile()) files.push(relative);
67
+ else throw new Error(`managed niubash runtime contains an unsupported entry: ${relative}`);
68
+ }
69
+ };
70
+ visit(root);
71
+ return files;
72
+ }
73
+
74
+ export function readPeMachine(filename) {
75
+ const data = fs.readFileSync(filename);
76
+ if (data.length < 64 || data[0] !== 0x4d || data[1] !== 0x5a) throw new Error(`${filename} is not a PE executable`);
77
+ const peOffset = data.readUInt32LE(0x3c);
78
+ if (peOffset + 6 > data.length || data.toString("ascii", peOffset, peOffset + 4) !== "PE\0\0") {
79
+ throw new Error(`${filename} has an invalid PE header`);
80
+ }
81
+ return data.readUInt16LE(peOffset + 4);
82
+ }
83
+
84
+ function readJson(filename) {
85
+ return JSON.parse(fs.readFileSync(filename, "utf8"));
86
+ }
87
+
88
+ function assertEqual(actual, expected, label) {
89
+ if (actual !== expected) throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
90
+ }
91
+
92
+ /** Validate a prepared source package or an unpacked npm tarball. */
93
+ export function validateManagedNiubashPackage(packageRoot, asset, { allowExtraRuntimeFiles = true } = {}) {
94
+ const packageInfo = readJson(path.join(packageRoot, "package.json"));
95
+ assertEqual(packageInfo.name, asset.packageName, "managed niubash package name");
96
+ assertEqual(packageInfo.version, NIUBASH_DISTRIBUTION_VERSION, "managed niubash package version");
97
+ if (!Array.isArray(packageInfo.os) || packageInfo.os.length !== 1 || packageInfo.os[0] !== "win32") {
98
+ throw new Error(`${asset.packageName} must be restricted to os=win32`);
99
+ }
100
+ if (!Array.isArray(packageInfo.cpu) || packageInfo.cpu.length !== 1 || packageInfo.cpu[0] !== asset.arch) {
101
+ throw new Error(`${asset.packageName} must be restricted to cpu=${asset.arch}`);
102
+ }
103
+
104
+ const runtime = path.join(packageRoot, "runtime");
105
+ if (!fs.existsSync(path.join(runtime, "niu.exe"))) {
106
+ throw new Error(`${asset.packageName} has no prepared runtime; run \`node scripts/prepare-niubash-packages.mjs\` before packing/publishing`);
107
+ }
108
+ const manifest = readJson(path.join(packageRoot, "runtime-manifest.json"));
109
+ assertEqual(manifest.schema, 1, "managed niubash manifest schema");
110
+ assertEqual(manifest.source?.repository, NIUBASH_REPOSITORY, "managed niubash source repository");
111
+ assertEqual(manifest.source?.tag, NIUBASH_TAG, "managed niubash source tag");
112
+ assertEqual(manifest.source?.asset, asset.asset, "managed niubash source asset");
113
+ assertEqual(manifest.source?.sha256, asset.sha256, "managed niubash source SHA-256");
114
+ if (!manifest.files || typeof manifest.files !== "object" || Array.isArray(manifest.files)) {
115
+ throw new Error(`${asset.packageName} runtime manifest has no file map`);
116
+ }
117
+ const expectedFiles = Object.keys(manifest.files);
118
+ if (expectedFiles.length < 300) throw new Error(`${asset.packageName} runtime is incomplete (${expectedFiles.length} files)`);
119
+ for (const relative of expectedFiles) {
120
+ if (!relative || relative.includes("\\") || path.posix.isAbsolute(relative) || relative.split("/").includes("..")) {
121
+ throw new Error(`${asset.packageName} runtime manifest contains an unsafe path: ${relative}`);
122
+ }
123
+ const filename = path.join(runtime, ...relative.split("/"));
124
+ const stat = fs.statSync(filename);
125
+ if (!stat.isFile()) throw new Error(`${asset.packageName} runtime entry is not a file: ${relative}`);
126
+ assertEqual(sha256File(filename), manifest.files[relative], `${asset.packageName} hash for ${relative}`);
127
+ }
128
+ const actualFiles = listFiles(runtime);
129
+ const extra = actualFiles.filter((file) => !(file in manifest.files));
130
+ if (!allowExtraRuntimeFiles && extra.length > 0) {
131
+ throw new Error(
132
+ `${asset.packageName} runtime has ${extra.length} files not in the signed manifest (niubash self-activates command links on first run). Re-run \`node scripts/prepare-niubash-packages.mjs\` before packing. First extra: ${extra[0]}`,
133
+ );
134
+ }
135
+
136
+ for (const required of ["niu.exe", "winuxcmd/usr/bin/winuxcmd.exe", "bundles/oh-my-niu/bundle.toml"]) {
137
+ if (!expectedFiles.includes(required)) throw new Error(`${asset.packageName} is missing ${required}`);
138
+ }
139
+ for (const executable of ["niu.exe", "winuxcmd/usr/bin/winuxcmd.exe"]) {
140
+ assertEqual(readPeMachine(path.join(runtime, ...executable.split("/"))), asset.machine, `${asset.packageName} PE machine for ${executable}`);
141
+ }
142
+ for (const license of NIUBASH_LICENSES) {
143
+ assertEqual(sha256File(path.join(packageRoot, "licenses", license.filename)), license.sha256, `${asset.packageName} license ${license.filename}`);
144
+ }
145
+ return { files: expectedFiles.length, runtime };
146
+ }
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build the Windows-only npm packages from pinned official niubash portable
4
+ * archives. Nothing is downloaded on macOS/Linux user installs: this runs only
5
+ * in PiWeb's release workflow (or explicitly by a maintainer).
6
+ */
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { spawnSync } from "node:child_process";
11
+ import { fileURLToPath } from "node:url";
12
+ import {
13
+ MANAGED_NIUBASH_ASSETS,
14
+ NIUBASH_LICENSES,
15
+ NIUBASH_REPOSITORY,
16
+ NIUBASH_TAG,
17
+ listFiles,
18
+ niubashAssetUrl,
19
+ sha256,
20
+ sha256File,
21
+ validateManagedNiubashPackage,
22
+ } from "./managed-niubash-assets.mjs";
23
+ import { isMainModule } from "./entrypoint.mjs";
24
+
25
+ const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
26
+
27
+ async function download(url, expectedSha256) {
28
+ let lastError;
29
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
30
+ try {
31
+ const response = await fetch(url, {
32
+ headers: { "user-agent": "PiWeb managed-niubash packager" },
33
+ signal: AbortSignal.timeout(120_000),
34
+ });
35
+ if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
36
+ const data = Buffer.from(await response.arrayBuffer());
37
+ const actual = sha256(data);
38
+ if (actual !== expectedSha256) throw new Error(`SHA-256 mismatch: expected ${expectedSha256}, got ${actual}`);
39
+ return data;
40
+ } catch (error) {
41
+ lastError = error;
42
+ if (attempt < 3) await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
43
+ }
44
+ }
45
+ throw new Error(`failed to download ${url}: ${lastError instanceof Error ? lastError.message : lastError}`);
46
+ }
47
+
48
+ function extractZip(archive, destination) {
49
+ fs.mkdirSync(destination, { recursive: true });
50
+ let result;
51
+ if (process.platform === "win32") {
52
+ result = spawnSync("powershell.exe", [
53
+ "-NoProfile",
54
+ "-NonInteractive",
55
+ "-Command",
56
+ "Expand-Archive -LiteralPath $env:PIWEB_NIUBASH_ARCHIVE -DestinationPath $env:PIWEB_NIUBASH_DESTINATION -Force",
57
+ ], {
58
+ encoding: "utf8",
59
+ env: { ...process.env, PIWEB_NIUBASH_ARCHIVE: archive, PIWEB_NIUBASH_DESTINATION: destination },
60
+ });
61
+ } else {
62
+ result = spawnSync("unzip", ["-q", "-o", archive, "-d", destination], { encoding: "utf8" });
63
+ if (result.error?.code === "ENOENT") {
64
+ result = spawnSync("python3", ["-m", "zipfile", "-e", archive, destination], { encoding: "utf8" });
65
+ }
66
+ }
67
+ if (result.error) throw new Error(`cannot extract ${path.basename(archive)}: ${result.error.message}`);
68
+ if (result.status !== 0) throw new Error(`cannot extract ${path.basename(archive)}: ${result.stderr || result.stdout}`);
69
+ }
70
+
71
+ async function replaceDirectory(source, destination) {
72
+ fs.rmSync(destination, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
73
+ for (let attempt = 1; ; attempt += 1) {
74
+ try {
75
+ fs.renameSync(source, destination);
76
+ return;
77
+ } catch (error) {
78
+ // Windows:刚写入几百个文件时,Defender/索引器会短暂持有句柄,目录 rename 报 EPERM。
79
+ // 先退避重试,仍不行就退化成复制(读通常是被允许的)。
80
+ if (!["EPERM", "EBUSY", "EACCES"].includes(error?.code)) throw error;
81
+ if (attempt >= 5) {
82
+ fs.cpSync(source, destination, { recursive: true });
83
+ fs.rmSync(source, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
84
+ return;
85
+ }
86
+ await new Promise((resolve) => setTimeout(resolve, attempt * 250));
87
+ }
88
+ }
89
+ }
90
+
91
+ function extractedPortableRoot(destination) {
92
+ const entries = fs.readdirSync(destination, { withFileTypes: true }).filter((entry) => entry.name !== "__MACOSX");
93
+ if (entries.length !== 1 || !entries[0].isDirectory()) throw new Error("niubash archive must contain exactly one top-level directory");
94
+ return path.join(destination, entries[0].name);
95
+ }
96
+
97
+ async function prepareLicenses(root, packageRoot) {
98
+ const destination = path.join(packageRoot, "licenses");
99
+ const staging = path.join(packageRoot, `.licenses-${process.pid}`);
100
+ fs.rmSync(staging, { recursive: true, force: true });
101
+ fs.mkdirSync(staging, { recursive: true });
102
+ try {
103
+ for (const license of NIUBASH_LICENSES) {
104
+ const source = path.join(root, "third-party", "managed-niubash", "licenses", license.filename);
105
+ if (sha256File(source) !== license.sha256) throw new Error(`license source failed integrity check: ${license.filename}`);
106
+ fs.copyFileSync(source, path.join(staging, license.filename));
107
+ }
108
+ await replaceDirectory(staging, destination);
109
+ } finally {
110
+ fs.rmSync(staging, { recursive: true, force: true });
111
+ }
112
+ }
113
+
114
+ async function prepareAsset(root, asset, log) {
115
+ const packageRoot = path.join(root, "platform-packages", asset.directory);
116
+ if (!fs.existsSync(path.join(packageRoot, "package.json"))) throw new Error(`missing platform package skeleton: ${packageRoot}`);
117
+ const temporary = fs.mkdtempSync(path.join(os.tmpdir(), `piweb-niubash-${asset.arch}-`));
118
+ const archive = path.join(temporary, asset.asset);
119
+ const extracted = path.join(temporary, "extracted");
120
+ const staging = path.join(packageRoot, `.runtime-${process.pid}`);
121
+ try {
122
+ log(`[piweb] Downloading ${asset.asset} ...`);
123
+ fs.writeFileSync(archive, await download(niubashAssetUrl(asset), asset.sha256));
124
+ extractZip(archive, extracted);
125
+ const source = extractedPortableRoot(extracted);
126
+ for (const required of ["niu.exe", "winuxcmd", "bundles"]) {
127
+ if (!fs.existsSync(path.join(source, required))) throw new Error(`${asset.asset} is missing ${required}`);
128
+ }
129
+
130
+ fs.rmSync(staging, { recursive: true, force: true });
131
+ fs.cpSync(source, staging, { recursive: true });
132
+ await replaceDirectory(staging, path.join(packageRoot, "runtime"));
133
+ const runtime = path.join(packageRoot, "runtime");
134
+
135
+ const files = {};
136
+ for (const relative of listFiles(runtime)) files[relative] = sha256File(path.join(runtime, ...relative.split("/")));
137
+ fs.writeFileSync(path.join(packageRoot, "runtime-manifest.json"), `${JSON.stringify({
138
+ schema: 1,
139
+ source: { repository: NIUBASH_REPOSITORY, tag: NIUBASH_TAG, asset: asset.asset, sha256: asset.sha256 },
140
+ files,
141
+ }, null, 2)}\n`);
142
+ await prepareLicenses(root, packageRoot);
143
+ const checked = validateManagedNiubashPackage(packageRoot, asset, { allowExtraRuntimeFiles: false });
144
+ log(`[piweb] Prepared ${asset.packageName}: ${checked.files} runtime files.`);
145
+ return checked;
146
+ } finally {
147
+ fs.rmSync(staging, { recursive: true, force: true });
148
+ fs.rmSync(temporary, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
149
+ }
150
+ }
151
+
152
+ function selectAssets(args) {
153
+ if (args.length === 0) return Object.values(MANAGED_NIUBASH_ASSETS);
154
+ if (args.length === 1 && args[0] === "--current") {
155
+ if (process.platform !== "win32" || !(process.arch in MANAGED_NIUBASH_ASSETS)) {
156
+ throw new Error(`--current requires Windows x64/ARM64 (got ${process.platform}/${process.arch})`);
157
+ }
158
+ return [MANAGED_NIUBASH_ASSETS[process.arch]];
159
+ }
160
+ if (args.length === 1 && args[0].startsWith("--arch=")) {
161
+ const arch = args[0].slice("--arch=".length);
162
+ const asset = MANAGED_NIUBASH_ASSETS[arch];
163
+ if (!asset) throw new Error(`unsupported niubash architecture: ${arch}`);
164
+ return [asset];
165
+ }
166
+ throw new Error("Usage: node scripts/prepare-niubash-packages.mjs [--current|--arch=x64|--arch=arm64]");
167
+ }
168
+
169
+ export async function prepareManagedNiubashPackages(root = DEFAULT_ROOT, { args = [], log = console.log } = {}) {
170
+ const results = [];
171
+ for (const asset of selectAssets(args)) results.push(await prepareAsset(root, asset, log));
172
+ return results;
173
+ }
174
+
175
+ if (isMainModule(import.meta.url)) {
176
+ try {
177
+ await prepareManagedNiubashPackages(DEFAULT_ROOT, { args: process.argv.slice(2) });
178
+ } catch (error) {
179
+ console.error(`[piweb] ${error instanceof Error ? error.message : error}`);
180
+ process.exitCode = 1;
181
+ }
182
+ }
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ /** Native smoke test. Run on both windows-latest (x64) and windows-11-arm. */
3
+ import assert from "node:assert/strict";
4
+ import path from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import { createRequire } from "node:module";
7
+ import { fileURLToPath } from "node:url";
8
+ import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
9
+ import { MANAGED_NIUBASH_ASSETS, validateManagedNiubashPackage } from "./managed-niubash-assets.mjs";
10
+
11
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
12
+ if (process.platform !== "win32" || !(process.arch in MANAGED_NIUBASH_ASSETS)) {
13
+ throw new Error(`managed niubash smoke test requires Windows x64/ARM64 (got ${process.platform}/${process.arch})`);
14
+ }
15
+ const asset = MANAGED_NIUBASH_ASSETS[process.arch];
16
+ const packageRoot = path.join(root, "platform-packages", asset.directory);
17
+ validateManagedNiubashPackage(packageRoot, asset, { allowExtraRuntimeFiles: false });
18
+ const require = createRequire(path.join(root, "package.json"));
19
+ const runtime = require(path.join(packageRoot, "index.cjs"));
20
+ assert.equal(runtime.packageName, asset.packageName);
21
+ assert.equal(runtime.arch, process.arch);
22
+
23
+ const version = spawnSync(runtime.shellPath, ["--version"], { encoding: "utf8", windowsHide: true });
24
+ assert.equal(version.status, 0, version.stderr);
25
+ assert.match(version.stdout, /Niubash 1\.1\.4/);
26
+ assert.match(version.stdout, /rubash\s+git 8b81c7501646/);
27
+ assert.match(version.stdout, /winuxcmd WinuxCmd 1\.0\.8/);
28
+
29
+ const operations = createLocalBashOperations({ shellPath: runtime.shellPath });
30
+ async function execute(command, options = {}) {
31
+ const chunks = [];
32
+ const result = await operations.exec(command, process.cwd(), {
33
+ onData: (data) => chunks.push(data),
34
+ ...options,
35
+ });
36
+ return { exitCode: result.exitCode, output: Buffer.concat(chunks).toString("utf8") };
37
+ }
38
+
39
+ const empty = await execute("");
40
+ assert.equal(empty.exitCode, 0);
41
+ assert.equal(empty.output, "");
42
+
43
+ const pwd = await execute("pwd");
44
+ assert.equal(pwd.exitCode, 0);
45
+ assert.equal(path.resolve(pwd.output.trim().replaceAll("/", path.sep)).toLowerCase(), path.resolve(process.cwd()).toLowerCase());
46
+
47
+ const quoted = await execute(`node -e "console.log(JSON.stringify(process.argv.slice(1)))" 'a b' '' 'c"d' 'e\\f'`);
48
+ assert.equal(quoted.exitCode, 0, quoted.output);
49
+ assert.deepEqual(JSON.parse(quoted.output.trim()), ["a b", "", "c\"d", "e\\f"]);
50
+
51
+ const shellQuotedCwd = process.cwd().replaceAll("'", `'"'"'`);
52
+ const windowsPath = await execute(`cd '${shellQuotedCwd}' && pwd`);
53
+ assert.equal(windowsPath.exitCode, 0, windowsPath.output);
54
+ assert.equal(path.resolve(windowsPath.output.trim().replaceAll("/", path.sep)).toLowerCase(), path.resolve(process.cwd()).toLowerCase());
55
+
56
+ await assert.rejects(() => execute("sleep 5", { timeout: 0.1 }), /timeout/);
57
+ const controller = new AbortController();
58
+ const aborted = execute("sleep 5", { signal: controller.signal });
59
+ setTimeout(() => controller.abort(), 100);
60
+ await assert.rejects(() => aborted, /aborted/);
61
+
62
+ console.log(`[piweb] Managed niubash ${process.arch} smoke test passed (${version.stdout.split(/\r?\n/, 1)[0]}).`);
@@ -13,9 +13,15 @@ import fs from "node:fs";
13
13
  import os from "node:os";
14
14
  import path from "node:path";
15
15
  import { spawnSync } from "node:child_process";
16
+ import { createRequire } from "node:module";
16
17
  import { fileURLToPath } from "node:url";
17
18
  import { readProductionBuild } from "./build-output.mjs";
18
19
  import { isMainModule } from "./entrypoint.mjs";
20
+ import {
21
+ MANAGED_NIUBASH_ASSETS,
22
+ NIUBASH_DISTRIBUTION_VERSION,
23
+ validateManagedNiubashPackage,
24
+ } from "./managed-niubash-assets.mjs";
19
25
 
20
26
  const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
21
27
 
@@ -66,9 +72,56 @@ export function verifyPackedPackage(root, { log = console.log } = {}) {
66
72
  }
67
73
  }
68
74
 
75
+ /** Pack and inspect one actual OS/CPU package, including every runtime hash and license. */
76
+ export function verifyPackedManagedNiubashPackage(packageRoot, asset, { log = console.log } = {}) {
77
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `piweb-niubash-pack-${asset.arch}-`));
78
+ try {
79
+ const packed = JSON.parse(runNpm(["pack", "--json", "--pack-destination", tmp], { cwd: packageRoot }));
80
+ const entry = Array.isArray(packed) ? packed[0] : packed;
81
+ if (!entry?.filename) throw new Error(`npm pack produced no tarball for ${asset.packageName}`);
82
+ const files = (entry.files ?? []).map((file) => String(file.path).replaceAll("\\", "/").replace(/^package\//, ""));
83
+ for (const required of [
84
+ "package.json", "index.cjs", "runtime-manifest.json", "runtime/niu.exe",
85
+ "runtime/winuxcmd/usr/bin/winuxcmd.exe", "runtime/bundles/oh-my-niu/bundle.toml",
86
+ "licenses/NIUBASH.txt", "licenses/RUBASH.txt", "licenses/WINUXCMD.txt",
87
+ ]) {
88
+ if (!files.includes(required)) throw new Error(`${asset.packageName} tarball is missing ${required}`);
89
+ }
90
+ const unpacked = path.join(tmp, "unpacked");
91
+ fs.mkdirSync(unpacked, { recursive: true });
92
+ run("tar", ["-xzf", entry.filename, "-C", "unpacked"], { cwd: tmp });
93
+ const installedRoot = path.join(unpacked, "package");
94
+ const checked = validateManagedNiubashPackage(installedRoot, asset, { allowExtraRuntimeFiles: false });
95
+ const require = createRequire(path.join(installedRoot, "package.json"));
96
+ const exported = require(path.join(installedRoot, "index.cjs"));
97
+ if (exported.packageName !== asset.packageName || exported.arch !== asset.arch
98
+ || exported.shellPath !== path.join(installedRoot, "runtime", "niu.exe")) {
99
+ throw new Error(`${asset.packageName} has an invalid runtime export`);
100
+ }
101
+ const bytes = fs.statSync(path.join(tmp, entry.filename)).size;
102
+ log(`[piweb] ${entry.filename}: ${checked.files} runtime files, ${(bytes / 1024 / 1024).toFixed(1)} MB.`);
103
+ return { filename: entry.filename, files, bytes, runtimeFiles: checked.files };
104
+ } finally {
105
+ fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
106
+ }
107
+ }
108
+
109
+ export function verifyManagedNiubashPackages(root, { log = console.log } = {}) {
110
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
111
+ const results = [];
112
+ for (const asset of Object.values(MANAGED_NIUBASH_ASSETS)) {
113
+ if (manifest.optionalDependencies?.[asset.packageName] !== NIUBASH_DISTRIBUTION_VERSION) {
114
+ throw new Error(`PiWeb must use exact optional dependency ${asset.packageName}@${NIUBASH_DISTRIBUTION_VERSION}`);
115
+ }
116
+ results.push(verifyPackedManagedNiubashPackage(path.join(root, "platform-packages", asset.directory), asset, { log }));
117
+ }
118
+ return results;
119
+ }
120
+
69
121
  if (isMainModule(import.meta.url)) {
70
122
  try {
71
123
  verifyPackedPackage(DEFAULT_ROOT);
124
+ verifyManagedNiubashPackages(DEFAULT_ROOT);
72
125
  } catch (error) {
73
126
  console.error(`[piweb] ${error instanceof Error ? error.message : error}`);
74
127
  process.exitCode = 1;
@@ -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,27 +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
 
127
134
  /**
128
135
  * 一次只显示一个来源:六个工具各列 15 条就是 90 行,全铺出来既难找也没必要。
129
- * 默认落在第一个有会话的来源上(SOURCE_ORDER 的顺序),用户切换后不被扫描结果重置。
136
+ * 首次打开默认落到“最近活动会话”所在的来源;用户切换后保持选择。
137
+ * 直接派生有效来源,不用 effect 回填,避免初始化 effect 覆盖用户刚完成的点击。
130
138
  */
131
139
  const [activeSource, setActiveSource] = useState("");
132
- useEffect(() => {
133
- if (activeSource && groups.some((group) => group.source === activeSource)) return;
134
- setActiveSource(groups[0]?.source ?? "");
135
- }, [groups, activeSource]);
136
- const activeGroup = groups.find((group) => group.source === activeSource);
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);
137
147
 
138
148
  const toggle = (summary: Summary) => {
139
- if (importedKeys.has(key(summary))) return;
149
+ if (importedKeys.has(summaryKey(summary))) return;
140
150
  setSelected((previous) => {
141
151
  const next = new Set(previous);
142
- if (next.has(key(summary))) next.delete(key(summary));
143
- else next.add(key(summary));
152
+ if (next.has(summaryKey(summary))) next.delete(summaryKey(summary));
153
+ else next.add(summaryKey(summary));
144
154
  return next;
145
155
  });
146
156
  };
@@ -149,9 +159,9 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
149
159
  setSelected((previous) => {
150
160
  const next = new Set(previous);
151
161
  for (const row of rows) {
152
- if (importedKeys.has(key(row))) continue;
153
- if (checked) next.add(key(row));
154
- 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));
155
165
  }
156
166
  return next;
157
167
  });
@@ -159,7 +169,7 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
159
169
 
160
170
  const run = async () => {
161
171
  if (!selected.size) return;
162
- 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 }));
163
173
  setImporting(true);
164
174
  setFailure(null);
165
175
  setReport(null);
@@ -198,6 +208,7 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
198
208
 
199
209
  const failures = report?.results.filter((result) => result.status === "failed") ?? [];
200
210
  const skipped = report?.results.filter((result) => result.status === "skipped") ?? [];
211
+ const importedWithNotes = report?.results.filter((result) => result.status === "imported" && result.skipped?.length) ?? [];
201
212
 
202
213
  return (
203
214
  <div className="flex flex-col gap-4" data-testid="session-import">
@@ -277,6 +288,11 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
277
288
  {sourceLabel(result.source)} · {result.title || result.externalId} — {result.reason}
278
289
  </div>
279
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
+ ))}
280
296
  {failures.map((result) => (
281
297
  <div key={`fail-${result.source}-${result.externalId}`} className="mt-1" style={{ fontSize: 11.5, color: "var(--dsw-danger)" }}>
282
298
  {sourceLabel(result.source)} · {result.title || result.externalId} — {result.reason}
@@ -291,7 +307,7 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
291
307
  {groups.length > 0 && (
292
308
  <div className="flex flex-wrap items-center gap-1.5" role="tablist" aria-label={t.importPickSource} data-testid="import-source-tabs">
293
309
  {groups.map((group) => {
294
- const active = group.source === activeSource;
310
+ const active = group.source === visibleSource;
295
311
  return (
296
312
  <button
297
313
  key={group.source}
@@ -332,7 +348,7 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
332
348
  </div>
333
349
  <div className="flex flex-col gap-1.5">
334
350
  {activeGroup.rows.map((row) => {
335
- const rowKey = key(row);
351
+ const rowKey = summaryKey(row);
336
352
  const done = importedKeys.has(rowKey);
337
353
  const checked = selected.has(rowKey);
338
354
  return (
@@ -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");