@baique/pi-web-sky 0.1.35 → 0.1.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +16 -16
- package/.next/build-manifest.json +3 -3
- package/.next/react-loadable-manifest.json +1 -1
- package/.next/required-server-files.js +1 -1
- package/.next/required-server-files.json +1 -1
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +3 -3
- package/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +3 -3
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/api/agent/[id]/bash-output/route.js +1 -1
- package/.next/server/app/api/agent/[id]/events/route.js +1 -1
- package/.next/server/app/api/agent/[id]/route.js +1 -1
- package/.next/server/app/api/app-update/route.js +1 -1
- package/.next/server/app/api/auth/login/[provider]/route.js +1 -1
- package/.next/server/app/api/cwd/validate/route.js +1 -1
- package/.next/server/app/api/default-cwd/route.js +1 -1
- package/.next/server/app/api/file-index/route.js +1 -1
- package/.next/server/app/api/files/[...path]/route.js +1 -1
- package/.next/server/app/api/mcp/route.js +1 -1
- package/.next/server/app/api/models/route.js +1 -1
- package/.next/server/app/api/plugins/route.js +1 -1
- package/.next/server/app/api/project-trust/route.js +1 -1
- package/.next/server/app/api/search/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/auto-name/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/context/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/entries/[entryId]/thinking/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/state/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/todos/route.js +1 -1
- package/.next/server/app/api/sessions/route.js +1 -1
- package/.next/server/app/api/skills/check/route.js +1 -1
- package/.next/server/app/api/skills/check/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/install/route.js +1 -1
- package/.next/server/app/api/skills/route.js +2 -31
- package/.next/server/app/api/skills/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/update/route.js +1 -1
- package/.next/server/app/api/skills/update/route.js.nft.json +1 -1
- package/.next/server/app/api/worktrees/route.js +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +5 -5
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +5 -5
- package/.next/server/app/index.segments/_full.segment.rsc +5 -5
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/page.js +2 -2
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +16 -16
- package/.next/server/chunks/2404.js +1 -1
- package/.next/server/chunks/3500.js +30 -0
- package/.next/server/chunks/4226.js +2 -2
- package/.next/server/chunks/542.js +10 -5
- package/.next/server/chunks/5794.js +1 -1
- package/.next/server/chunks/6429.js +1 -1
- package/.next/server/chunks/898.js +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/static/chunks/5315.7e72d26a3ad3bd9a.js +4 -0
- package/.next/static/chunks/app/{layout-1d9a7a44fbc92d2e.js → layout-ebbc0d287267b07b.js} +1 -1
- package/.next/static/chunks/app/{page-6d6779a81a0a7639.js → page-e568623a44c67606.js} +2 -2
- package/.next/static/chunks/{webpack-fa4f40085b1ec1ac.js → webpack-2e4e348867a555cd.js} +1 -1
- package/.next/static/css/422c7dc0faa04d89.css +1 -0
- package/.next/trace +8 -9
- package/.next/trace-build +1 -1
- package/bin/pi-web-sky.js +15 -20
- package/lib/agent-client.test.mjs +64 -0
- package/lib/agent-client.ts +53 -0
- package/lib/agent-event-connection.test.mjs +193 -0
- package/lib/agent-event-connection.ts +202 -0
- package/lib/agent-event-stream.test.mjs +155 -0
- package/lib/agent-event-stream.ts +132 -0
- package/lib/agent-event-wire.test.mjs +278 -0
- package/lib/agent-event-wire.ts +107 -0
- package/lib/agent-phase.ts +24 -0
- package/lib/allowed-roots.ts +31 -0
- package/lib/ansi.test.mjs +52 -0
- package/lib/ansi.ts +211 -0
- package/lib/api-types.ts +139 -0
- package/lib/app-update.test.mjs +27 -0
- package/lib/app-update.ts +28 -0
- package/lib/atomic-file.test.mjs +37 -0
- package/lib/atomic-file.ts +34 -0
- package/lib/audit-session.ts +235 -0
- package/lib/bash-output.test.mjs +63 -0
- package/lib/bash-output.ts +53 -0
- package/lib/bg-image.ts +129 -0
- package/lib/board-events.ts +83 -0
- package/lib/board-purge.test.mjs +66 -0
- package/lib/board-purge.ts +65 -0
- package/lib/board-search.test.mjs +110 -0
- package/lib/board-search.ts +63 -0
- package/lib/board-store.test.mjs +542 -0
- package/lib/board-store.ts +794 -0
- package/lib/board-types.ts +96 -0
- package/lib/board-utils.ts +17 -0
- package/lib/bounded-form-data.test.mjs +54 -0
- package/lib/bounded-form-data.ts +49 -0
- package/lib/browser-notifications.test.mjs +167 -0
- package/lib/browser-notifications.ts +104 -0
- package/lib/chat-lazy-load.test.mjs +140 -0
- package/lib/chat-lazy-load.ts +64 -0
- package/lib/clipboard-paths.test.mjs +140 -0
- package/lib/clipboard-paths.ts +192 -0
- package/lib/clipboard.ts +18 -0
- package/lib/compaction-summary.test.mjs +44 -0
- package/lib/compaction-summary.ts +34 -0
- package/lib/custom-ui-terminal.test.mjs +32 -0
- package/lib/custom-ui-terminal.ts +27 -0
- package/lib/deepseek-pricing.test.mjs +120 -0
- package/lib/deepseek-pricing.ts +71 -0
- package/lib/directory-browser.test.mjs +60 -0
- package/lib/directory-browser.ts +83 -0
- package/lib/draft-stash.test.mjs +91 -0
- package/lib/draft-stash.ts +102 -0
- package/lib/draft-store.ts +105 -0
- package/lib/dropdown-direction.ts +27 -0
- package/lib/extension-command.test.mjs +63 -0
- package/lib/extension-command.ts +81 -0
- package/lib/file-access.test.mjs +31 -0
- package/lib/file-access.ts +59 -0
- package/lib/file-dirent.test.mjs +56 -0
- package/lib/file-dirent.ts +17 -0
- package/lib/file-explorer-state.test.mjs +63 -0
- package/lib/file-explorer-state.ts +36 -0
- package/lib/file-fuzzy.test.mjs +29 -0
- package/lib/file-fuzzy.ts +189 -0
- package/lib/file-links.test.mjs +92 -0
- package/lib/file-links.ts +148 -0
- package/lib/file-paths.ts +43 -0
- package/lib/file-types.test.mjs +34 -0
- package/lib/file-types.ts +73 -0
- package/lib/file-upload.test.mjs +70 -0
- package/lib/file-upload.ts +59 -0
- package/lib/file-viewer-state.test.mjs +20 -0
- package/lib/file-viewer-state.ts +15 -0
- package/lib/frontmatter.test.mjs +55 -0
- package/lib/frontmatter.ts +87 -0
- package/lib/git-changes.test.mjs +52 -0
- package/lib/git-changes.ts +232 -0
- package/lib/git-status.ts +47 -0
- package/lib/git-types.ts +29 -0
- package/lib/http-dispatcher.test.mjs +83 -0
- package/lib/http-dispatcher.ts +86 -0
- package/lib/i18n/format.test.mjs +19 -0
- package/lib/i18n/format.ts +60 -0
- package/lib/i18n/messages/en.ts +662 -0
- package/lib/i18n/messages/zh-CN.ts +661 -0
- package/lib/i18n/registry.test.mjs +32 -0
- package/lib/i18n/registry.ts +43 -0
- package/lib/i18n/types.ts +15 -0
- package/lib/image-attachments.test.mjs +27 -0
- package/lib/image-attachments.ts +56 -0
- package/lib/initial-navigation.test.mjs +65 -0
- package/lib/initial-navigation.ts +15 -0
- package/lib/markdown.test.mjs +104 -0
- package/lib/markdown.ts +347 -0
- package/lib/message-display.test.mjs +130 -0
- package/lib/message-display.ts +47 -0
- package/lib/model-catalog.test.mjs +327 -0
- package/lib/model-catalog.ts +414 -0
- package/lib/model-discovery-auth.ts +58 -0
- package/lib/model-discovery.test.mjs +50 -0
- package/lib/model-discovery.ts +75 -0
- package/lib/model-id-preference.ts +46 -0
- package/lib/model-scope.test.mjs +193 -0
- package/lib/model-scope.ts +198 -0
- package/lib/models-cache.test.mjs +122 -0
- package/lib/models-cache.ts +90 -0
- package/lib/models-config-store.test.mjs +200 -0
- package/lib/models-config-store.ts +84 -0
- package/lib/next-config-esm.test.mjs +14 -0
- package/lib/next-config.test.mjs +13 -0
- package/lib/node-version.test.mjs +33 -0
- package/lib/normalize.test.mjs +54 -0
- package/lib/normalize.ts +63 -0
- package/lib/npx.ts +62 -0
- package/lib/panel-layout.test.mjs +56 -0
- package/lib/panel-layout.ts +52 -0
- package/lib/patch.test.mjs +110 -0
- package/lib/patch.ts +134 -0
- package/lib/path-security.ts +42 -0
- package/lib/paths.test.mjs +58 -0
- package/lib/paths.ts +71 -0
- package/lib/pi-types.ts +183 -0
- package/lib/pi-web-options.test.mjs +63 -0
- package/lib/process-lifecycle.test.mjs +83 -0
- package/lib/project-command-env.test.mjs +298 -0
- package/lib/project-command-env.ts +129 -0
- package/lib/project-groups.test.mjs +60 -0
- package/lib/project-groups.ts +53 -0
- package/lib/project-identity.test.mjs +37 -0
- package/lib/project-identity.ts +26 -0
- package/lib/project-tree.test.mjs +115 -0
- package/lib/project-tree.ts +176 -0
- package/lib/project-trust.test.mjs +117 -0
- package/lib/project-trust.ts +48 -0
- package/lib/prompt-recovery.test.mjs +33 -0
- package/lib/prompt-recovery.ts +47 -0
- package/lib/provider-api-key-route.test.mjs +12 -0
- package/lib/provider-credential-store.test.mjs +126 -0
- package/lib/provider-credential-store.ts +114 -0
- package/lib/provider-listing-runtime.ts +37 -0
- package/lib/provider-listing.test.mjs +114 -0
- package/lib/provider-listing.ts +118 -0
- package/lib/provider-services.ts +64 -0
- package/lib/request-security.test.mjs +256 -0
- package/lib/request-security.ts +136 -0
- package/lib/rpc-manager-shutdown.test.mjs +372 -0
- package/lib/rpc-manager-widgets.test.mjs +342 -0
- package/lib/rpc-manager.test.mjs +139 -0
- package/lib/rpc-manager.ts +1703 -0
- package/lib/rpc-session-info.test.mjs +93 -0
- package/lib/session-delete.test.mjs +53 -0
- package/lib/session-delete.ts +86 -0
- package/lib/session-file-references-core.ts +82 -0
- package/lib/session-file-references.test.mjs +91 -0
- package/lib/session-file-references.ts +29 -0
- package/lib/session-path.test.mjs +24 -0
- package/lib/session-path.ts +11 -0
- package/lib/session-reader.pagination.test.mjs +115 -0
- package/lib/session-reader.test.mjs +446 -0
- package/lib/session-reader.ts +548 -0
- package/lib/session-resolve.test.mjs +65 -0
- package/lib/session-row-context-menu.test.mjs +46 -0
- package/lib/session-row-context-menu.ts +29 -0
- package/lib/session-scanner.test.mjs +151 -0
- package/lib/session-scanner.ts +238 -0
- package/lib/session-search.test.mjs +173 -0
- package/lib/session-search.ts +284 -0
- package/lib/session-stats.ts +26 -0
- package/lib/session-timing.test.mjs +107 -0
- package/lib/session-timing.ts +45 -0
- package/lib/session-title.test.mjs +284 -0
- package/lib/session-title.ts +255 -0
- package/lib/skill-frontmatter.test.mjs +75 -0
- package/lib/skill-frontmatter.ts +40 -0
- package/lib/skill-lock.test.mjs +182 -0
- package/lib/skill-lock.ts +146 -0
- package/lib/skill-updates.test.mjs +182 -0
- package/lib/skill-updates.ts +263 -0
- package/lib/skills-service.ts +16 -0
- package/lib/slash-display.test.mjs +122 -0
- package/lib/slash-display.ts +30 -0
- package/lib/sqlite-db.test.mjs +140 -0
- package/lib/sqlite-db.ts +205 -0
- package/lib/startup-preferences.test.mjs +130 -0
- package/lib/startup-preferences.ts +65 -0
- package/lib/streaming-message.test.mjs +212 -0
- package/lib/streaming-message.ts +151 -0
- package/lib/subagent-widget.test.mjs +87 -0
- package/lib/subagent-widget.ts +109 -0
- package/lib/sync-room-server.mjs +137 -0
- package/lib/task-card-store.test.mjs +309 -0
- package/lib/task-card-store.ts +559 -0
- package/lib/task-scheduler.ts +498 -0
- package/lib/task-store.test.mjs +216 -0
- package/lib/task-store.ts +271 -0
- package/lib/terminal-input.test.mjs +46 -0
- package/lib/terminal-input.ts +63 -0
- package/lib/terminal-manager.test.mjs +71 -0
- package/lib/terminal-manager.ts +245 -0
- package/lib/tool-execution-progress.test.mjs +32 -0
- package/lib/tool-execution-progress.ts +29 -0
- package/lib/tool-names.ts +25 -0
- package/lib/tool-preset-preference.test.mjs +72 -0
- package/lib/tool-preset-preference.ts +41 -0
- package/lib/tool-presets.test.mjs +56 -0
- package/lib/tool-presets.ts +42 -0
- package/lib/turn-written-files.test.mjs +137 -0
- package/lib/turn-written-files.ts +60 -0
- package/lib/types.ts +349 -0
- package/lib/wallpaper-settings.ts +191 -0
- package/lib/web-auth.test.mjs +50 -0
- package/lib/web-auth.ts +45 -0
- package/lib/workspace-memory.test.mjs +129 -0
- package/lib/workspace-memory.ts +98 -0
- package/lib/worktree.test.mjs +50 -0
- package/lib/worktree.ts +250 -0
- package/package.json +3 -1
- package/server.mjs +34 -0
- package/.next/server/chunks/7513.js +0 -30
- package/.next/static/chunks/5315.03de3c6abd8b283e.js +0 -4
- package/.next/static/css/cf25164cb7c04d95.css +0 -1
- /package/.next/static/{Ud6Y2ONkGuPVFbo1xZQvk → EoBw3VYlygpr1EqlVOa5b}/_buildManifest.js +0 -0
- /package/.next/static/{Ud6Y2ONkGuPVFbo1xZQvk → EoBw3VYlygpr1EqlVOa5b}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
|
|
6
|
+
import { wireChildProcessLifecycle } from "../bin/process-lifecycle.js";
|
|
7
|
+
|
|
8
|
+
function createProcesses() {
|
|
9
|
+
const parent = new EventEmitter();
|
|
10
|
+
const child = new EventEmitter();
|
|
11
|
+
const forwardedSignals = [];
|
|
12
|
+
const exitCodes = [];
|
|
13
|
+
|
|
14
|
+
child.kill = (signal) => {
|
|
15
|
+
forwardedSignals.push(signal);
|
|
16
|
+
return true;
|
|
17
|
+
};
|
|
18
|
+
parent.exit = (code) => {
|
|
19
|
+
exitCodes.push(code);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
return { parent, child, forwardedSignals, exitCodes };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("forwards the first shutdown signal and force-kills on repeated signals", () => {
|
|
26
|
+
const { parent, child, forwardedSignals } = createProcesses();
|
|
27
|
+
|
|
28
|
+
wireChildProcessLifecycle(child, parent);
|
|
29
|
+
parent.emit("SIGTERM");
|
|
30
|
+
parent.emit("SIGTERM");
|
|
31
|
+
parent.emit("SIGINT");
|
|
32
|
+
|
|
33
|
+
assert.deepEqual(forwardedSignals, ["SIGTERM", "SIGKILL", "SIGKILL"]);
|
|
34
|
+
child.emit("exit", null, "SIGKILL");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("propagates a child exit code and clears its shutdown wiring", async () => {
|
|
38
|
+
const { parent, child, forwardedSignals, exitCodes } = createProcesses();
|
|
39
|
+
const existingSigtermListener = () => {};
|
|
40
|
+
parent.on("SIGTERM", existingSigtermListener);
|
|
41
|
+
|
|
42
|
+
wireChildProcessLifecycle(child, parent, 10);
|
|
43
|
+
assert.equal(parent.listenerCount("SIGINT"), 1);
|
|
44
|
+
assert.equal(parent.listenerCount("SIGTERM"), 2);
|
|
45
|
+
|
|
46
|
+
parent.emit("SIGTERM");
|
|
47
|
+
child.emit("exit", 23, null);
|
|
48
|
+
await delay(20);
|
|
49
|
+
|
|
50
|
+
assert.deepEqual(exitCodes, [23]);
|
|
51
|
+
assert.equal(parent.listenerCount("SIGINT"), 0);
|
|
52
|
+
assert.deepEqual(parent.listeners("SIGTERM"), [existingSigtermListener]);
|
|
53
|
+
|
|
54
|
+
parent.emit("SIGTERM");
|
|
55
|
+
assert.deepEqual(forwardedSignals, ["SIGTERM"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("force-kills the child when graceful shutdown times out", async () => {
|
|
59
|
+
const { parent, child, forwardedSignals } = createProcesses();
|
|
60
|
+
|
|
61
|
+
wireChildProcessLifecycle(child, parent, 10);
|
|
62
|
+
parent.emit("SIGINT");
|
|
63
|
+
assert.deepEqual(forwardedSignals, ["SIGINT"]);
|
|
64
|
+
|
|
65
|
+
await delay(20);
|
|
66
|
+
|
|
67
|
+
assert.deepEqual(forwardedSignals, ["SIGINT", "SIGKILL"]);
|
|
68
|
+
child.emit("exit", null, "SIGKILL");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("uses conventional exit statuses for known child signals", () => {
|
|
72
|
+
for (const [signal, expectedExitCode] of [
|
|
73
|
+
["SIGTERM", 143],
|
|
74
|
+
["SIGKILL", 137],
|
|
75
|
+
]) {
|
|
76
|
+
const { parent, child, exitCodes } = createProcesses();
|
|
77
|
+
|
|
78
|
+
wireChildProcessLifecycle(child, parent);
|
|
79
|
+
child.emit("exit", null, signal);
|
|
80
|
+
|
|
81
|
+
assert.deepEqual(exitCodes, [expectedExitCode]);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { delimiter, join } from "node:path";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import {
|
|
5
|
+
DefaultResourceLoader,
|
|
6
|
+
createLocalBashOperations,
|
|
7
|
+
getAgentDir,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { createJiti } from "jiti";
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
createProjectCommandBashExtension,
|
|
13
|
+
createProjectCommandBashOperations,
|
|
14
|
+
preferUserBashExtension,
|
|
15
|
+
sanitizeProjectCommandEnvironment,
|
|
16
|
+
} = await createJiti(import.meta.url).import("./project-command-env.ts");
|
|
17
|
+
|
|
18
|
+
const HOST_ENVIRONMENT = {
|
|
19
|
+
PORT: "30141",
|
|
20
|
+
NODE_ENV: "production",
|
|
21
|
+
NEXT_RUNTIME: "nodejs",
|
|
22
|
+
NEXT_PRIVATE_WORKER: "1",
|
|
23
|
+
PATH: "/usr/local/bin:/usr/bin",
|
|
24
|
+
HOME: "/home/pi",
|
|
25
|
+
HTTPS_PROXY: "http://proxy.example",
|
|
26
|
+
OPENROUTER_API_KEY: "secret",
|
|
27
|
+
PI_USER_SETTING: "preserved",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
test("sanitizes host variables using platform casing rules", () => {
|
|
31
|
+
assert.deepEqual(
|
|
32
|
+
sanitizeProjectCommandEnvironment(HOST_ENVIRONMENT, "linux"),
|
|
33
|
+
{
|
|
34
|
+
PATH: HOST_ENVIRONMENT.PATH,
|
|
35
|
+
HOME: HOST_ENVIRONMENT.HOME,
|
|
36
|
+
HTTPS_PROXY: HOST_ENVIRONMENT.HTTPS_PROXY,
|
|
37
|
+
OPENROUTER_API_KEY: HOST_ENVIRONMENT.OPENROUTER_API_KEY,
|
|
38
|
+
PI_USER_SETTING: HOST_ENVIRONMENT.PI_USER_SETTING,
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
assert.deepEqual(
|
|
42
|
+
sanitizeProjectCommandEnvironment(
|
|
43
|
+
{
|
|
44
|
+
Port: "30141",
|
|
45
|
+
node_env: "production",
|
|
46
|
+
Next_Runtime: "nodejs",
|
|
47
|
+
NEXT_PUBLIC_FLAG: "1",
|
|
48
|
+
Path: "C:\\Windows",
|
|
49
|
+
},
|
|
50
|
+
"win32",
|
|
51
|
+
),
|
|
52
|
+
{ Path: "C:\\Windows" },
|
|
53
|
+
);
|
|
54
|
+
assert.deepEqual(
|
|
55
|
+
sanitizeProjectCommandEnvironment(
|
|
56
|
+
{
|
|
57
|
+
PORT: "30141",
|
|
58
|
+
Port: "project-value",
|
|
59
|
+
NODE_ENV: "production",
|
|
60
|
+
node_env: "project-mode",
|
|
61
|
+
NEXT_RUNTIME: "nodejs",
|
|
62
|
+
Next_Runtime: "project-runtime",
|
|
63
|
+
},
|
|
64
|
+
"linux",
|
|
65
|
+
),
|
|
66
|
+
{
|
|
67
|
+
Port: "project-value",
|
|
68
|
+
node_env: "project-mode",
|
|
69
|
+
Next_Runtime: "project-runtime",
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("agent bash removes host variables while preserving SDK and user environment", async () => {
|
|
75
|
+
const original = {
|
|
76
|
+
PORT: process.env.PORT,
|
|
77
|
+
NODE_ENV: process.env.NODE_ENV,
|
|
78
|
+
NEXT_RUNTIME: process.env.NEXT_RUNTIME,
|
|
79
|
+
NEXT_PRIVATE_WORKER: process.env.NEXT_PRIVATE_WORKER,
|
|
80
|
+
PI_USER_SETTING: process.env.PI_USER_SETTING,
|
|
81
|
+
};
|
|
82
|
+
Object.assign(process.env, {
|
|
83
|
+
PORT: "30141",
|
|
84
|
+
NODE_ENV: "production",
|
|
85
|
+
NEXT_RUNTIME: "nodejs",
|
|
86
|
+
NEXT_PRIVATE_WORKER: "1",
|
|
87
|
+
PI_USER_SETTING: "preserved",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const extension = createProjectCommandBashExtension({
|
|
92
|
+
cwd: process.cwd(),
|
|
93
|
+
settings: {
|
|
94
|
+
getShellCommandPrefix: () => undefined,
|
|
95
|
+
getShellPath: () => undefined,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
let registeredTool;
|
|
99
|
+
await extension.factory({
|
|
100
|
+
registerTool(tool) {
|
|
101
|
+
registeredTool = tool;
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const result = await registeredTool.execute(
|
|
106
|
+
"issue-484",
|
|
107
|
+
{
|
|
108
|
+
command: `node -e 'console.log(JSON.stringify({PORT:process.env.PORT,NODE_ENV:process.env.NODE_ENV,NEXT_RUNTIME:process.env.NEXT_RUNTIME,NEXT_PRIVATE_WORKER:process.env.NEXT_PRIVATE_WORKER,PI_USER_SETTING:process.env.PI_USER_SETTING,PI_SESSION_ID:process.env.PI_SESSION_ID,PATH:process.env.PATH}))'`,
|
|
109
|
+
},
|
|
110
|
+
undefined,
|
|
111
|
+
undefined,
|
|
112
|
+
{
|
|
113
|
+
model: undefined,
|
|
114
|
+
thinkingLevel: "off",
|
|
115
|
+
sessionManager: {
|
|
116
|
+
getSessionId: () => "session-484",
|
|
117
|
+
getSessionFile: () => undefined,
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
const childEnvironment = JSON.parse(result.content[0].text);
|
|
122
|
+
|
|
123
|
+
assert.equal(childEnvironment.PORT, undefined);
|
|
124
|
+
assert.equal(childEnvironment.NODE_ENV, undefined);
|
|
125
|
+
assert.equal(childEnvironment.NEXT_RUNTIME, undefined);
|
|
126
|
+
assert.equal(childEnvironment.NEXT_PRIVATE_WORKER, undefined);
|
|
127
|
+
assert.equal(childEnvironment.PI_USER_SETTING, "preserved");
|
|
128
|
+
assert.equal(childEnvironment.PI_SESSION_ID, "session-484");
|
|
129
|
+
assert.ok(childEnvironment.PATH.split(delimiter).includes(join(getAgentDir(), "bin")));
|
|
130
|
+
} finally {
|
|
131
|
+
for (const [name, value] of Object.entries(original)) {
|
|
132
|
+
if (value === undefined) delete process.env[name];
|
|
133
|
+
else process.env[name] = value;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("agent bash reads current shell settings for every execution", async () => {
|
|
139
|
+
let commandPrefix = "export PI_WEB_PREFIX=first";
|
|
140
|
+
const extension = createProjectCommandBashExtension({
|
|
141
|
+
cwd: process.cwd(),
|
|
142
|
+
settings: {
|
|
143
|
+
getShellCommandPrefix: () => commandPrefix,
|
|
144
|
+
getShellPath: () => undefined,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
let registeredTool;
|
|
148
|
+
await extension.factory({
|
|
149
|
+
registerTool(tool) {
|
|
150
|
+
registeredTool = tool;
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
const execute = () => registeredTool.execute(
|
|
154
|
+
"settings-reload",
|
|
155
|
+
{ command: "printf %s \"$PI_WEB_PREFIX\"" },
|
|
156
|
+
undefined,
|
|
157
|
+
undefined,
|
|
158
|
+
undefined,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
assert.equal((await execute()).content[0].text, "first");
|
|
162
|
+
commandPrefix = "export PI_WEB_PREFIX=second";
|
|
163
|
+
assert.equal((await execute()).content[0].text, "second");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("direct bash removes host variables and allows explicit project values", async () => {
|
|
167
|
+
const agentBinDir = join(process.cwd(), ".test-agent", "bin");
|
|
168
|
+
const operations = createProjectCommandBashOperations({
|
|
169
|
+
agentBinDir,
|
|
170
|
+
baseEnvironment: {
|
|
171
|
+
...HOST_ENVIRONMENT,
|
|
172
|
+
PATH: process.env.PATH,
|
|
173
|
+
PI_SESSION_ID: "stale-host-value",
|
|
174
|
+
},
|
|
175
|
+
localOperations: createLocalBashOperations(),
|
|
176
|
+
});
|
|
177
|
+
let output = "";
|
|
178
|
+
|
|
179
|
+
await operations.exec(
|
|
180
|
+
`NODE_ENV=test PORT=3200 node -e 'console.log(JSON.stringify({PORT:process.env.PORT,NODE_ENV:process.env.NODE_ENV,NEXT_RUNTIME:process.env.NEXT_RUNTIME,PI_USER_SETTING:process.env.PI_USER_SETTING,PATH:process.env.PATH}))'`,
|
|
181
|
+
process.cwd(),
|
|
182
|
+
{ onData: (chunk) => { output += chunk.toString(); } },
|
|
183
|
+
);
|
|
184
|
+
const childEnvironment = JSON.parse(output);
|
|
185
|
+
|
|
186
|
+
assert.equal(childEnvironment.PORT, "3200");
|
|
187
|
+
assert.equal(childEnvironment.NODE_ENV, "test");
|
|
188
|
+
assert.equal(childEnvironment.NEXT_RUNTIME, undefined);
|
|
189
|
+
assert.equal(childEnvironment.PI_USER_SETTING, "preserved");
|
|
190
|
+
assert.ok(childEnvironment.PATH.split(delimiter).includes(agentBinDir));
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("direct bash preserves execution controls and streaming callbacks", async () => {
|
|
194
|
+
const signal = new AbortController().signal;
|
|
195
|
+
let received;
|
|
196
|
+
let streamed = "";
|
|
197
|
+
const operations = createProjectCommandBashOperations({
|
|
198
|
+
baseEnvironment: HOST_ENVIRONMENT,
|
|
199
|
+
localOperations: {
|
|
200
|
+
async exec(command, cwd, options) {
|
|
201
|
+
received = { command, cwd, options };
|
|
202
|
+
options.onData(Buffer.from("streamed"));
|
|
203
|
+
return { exitCode: 0 };
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await operations.exec("echo ready", "/project", {
|
|
209
|
+
onData: (chunk) => { streamed += chunk.toString(); },
|
|
210
|
+
signal,
|
|
211
|
+
timeout: 12,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
assert.equal(received.command, "echo ready");
|
|
215
|
+
assert.equal(received.cwd, "/project");
|
|
216
|
+
assert.equal(received.options.signal, signal);
|
|
217
|
+
assert.equal(received.options.timeout, 12);
|
|
218
|
+
assert.equal(streamed, "streamed");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
async function captureOperationEnvironment(options) {
|
|
222
|
+
let environment;
|
|
223
|
+
const operations = createProjectCommandBashOperations({
|
|
224
|
+
...options,
|
|
225
|
+
localOperations: {
|
|
226
|
+
async exec(_command, _cwd, executionOptions) {
|
|
227
|
+
environment = executionOptions.env;
|
|
228
|
+
return { exitCode: 0 };
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
await operations.exec("echo ready", "/project", {
|
|
233
|
+
onData() {},
|
|
234
|
+
});
|
|
235
|
+
return environment;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
test("direct bash updates the platform PATH key", async () => {
|
|
239
|
+
const agentBinDir = join(process.cwd(), ".test-agent", "bin");
|
|
240
|
+
const cases = [
|
|
241
|
+
{
|
|
242
|
+
options: { agentBinDir, baseEnvironment: { Path: "project-metadata", PATH: "/usr/bin" }, platform: "linux" },
|
|
243
|
+
expected: { Path: "project-metadata", PATH: `${agentBinDir}${delimiter}/usr/bin` },
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
options: { agentBinDir: "C:\\pi-agent\\bin", baseEnvironment: { Path: "C:\\Windows" }, platform: "win32" },
|
|
247
|
+
expected: { Path: "C:\\pi-agent\\bin;C:\\Windows" },
|
|
248
|
+
},
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
for (const { options, expected } of cases) {
|
|
252
|
+
assert.deepEqual(await captureOperationEnvironment(options), expected);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("a user extension keeps priority over the Pi Web fallback bash tool", async () => {
|
|
257
|
+
const userBash = {
|
|
258
|
+
name: "user-bash",
|
|
259
|
+
factory: (pi) => {
|
|
260
|
+
pi.registerTool({
|
|
261
|
+
name: "bash",
|
|
262
|
+
label: "user bash",
|
|
263
|
+
description: "user override",
|
|
264
|
+
parameters: { type: "object", properties: {} },
|
|
265
|
+
async execute() {
|
|
266
|
+
return { content: [{ type: "text", text: "user override" }], details: undefined };
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
const hostBash = createProjectCommandBashExtension({
|
|
272
|
+
cwd: process.cwd(),
|
|
273
|
+
settings: {
|
|
274
|
+
getShellCommandPrefix: () => undefined,
|
|
275
|
+
getShellPath: () => undefined,
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
const loader = new DefaultResourceLoader({
|
|
279
|
+
cwd: process.cwd(),
|
|
280
|
+
agentDir: join(process.cwd(), ".test-agent"),
|
|
281
|
+
extensionFactories: [userBash, hostBash],
|
|
282
|
+
noExtensions: true,
|
|
283
|
+
noSkills: true,
|
|
284
|
+
noPromptTemplates: true,
|
|
285
|
+
noThemes: true,
|
|
286
|
+
noContextFiles: true,
|
|
287
|
+
extensionsOverride: (base) => preferUserBashExtension(base),
|
|
288
|
+
});
|
|
289
|
+
await loader.reload();
|
|
290
|
+
const extensions = loader.getExtensions();
|
|
291
|
+
const bashDefinitions = extensions.extensions
|
|
292
|
+
.map((extension) => extension.tools.get("bash")?.definition)
|
|
293
|
+
.filter(Boolean);
|
|
294
|
+
|
|
295
|
+
assert.equal(bashDefinitions.length, 1);
|
|
296
|
+
assert.equal(bashDefinitions[0].description, "user override");
|
|
297
|
+
assert.deepEqual(extensions.errors, []);
|
|
298
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createBashToolDefinition,
|
|
3
|
+
createLocalBashOperations,
|
|
4
|
+
getAgentDir,
|
|
5
|
+
type BashOperations,
|
|
6
|
+
type InlineExtension,
|
|
7
|
+
type LoadExtensionsResult,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
const HOST_EXTENSION_NAME = "pi-web-project-command-environment";
|
|
12
|
+
const HOST_EXTENSION_PATH = `<inline:${HOST_EXTENSION_NAME}>`;
|
|
13
|
+
|
|
14
|
+
type ProjectShellSettings = {
|
|
15
|
+
getShellCommandPrefix(): string | undefined;
|
|
16
|
+
getShellPath(): string | undefined;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type ProjectCommandBashOperationsOptions = {
|
|
20
|
+
agentBinDir?: string;
|
|
21
|
+
baseEnvironment?: NodeJS.ProcessEnv;
|
|
22
|
+
localOperations?: BashOperations;
|
|
23
|
+
platform?: NodeJS.Platform;
|
|
24
|
+
shellPath?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function isHostRuntimeVariable(name: string, platform: NodeJS.Platform): boolean {
|
|
28
|
+
const comparableName = platform === "win32" ? name.toUpperCase() : name;
|
|
29
|
+
return comparableName === "PORT"
|
|
30
|
+
|| comparableName === "NODE_ENV"
|
|
31
|
+
|| comparableName.startsWith("NEXT_");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function sanitizeProjectCommandEnvironment(
|
|
35
|
+
baseEnvironment: NodeJS.ProcessEnv,
|
|
36
|
+
platform: NodeJS.Platform = process.platform,
|
|
37
|
+
): NodeJS.ProcessEnv {
|
|
38
|
+
const environment = { ...baseEnvironment };
|
|
39
|
+
for (const name of Object.keys(environment)) {
|
|
40
|
+
if (isHostRuntimeVariable(name, platform)) delete environment[name];
|
|
41
|
+
}
|
|
42
|
+
return environment;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function withAgentBinDirectory(
|
|
46
|
+
environment: NodeJS.ProcessEnv,
|
|
47
|
+
agentBinDir: string,
|
|
48
|
+
platform: NodeJS.Platform,
|
|
49
|
+
): NodeJS.ProcessEnv {
|
|
50
|
+
const pathKey = platform === "win32"
|
|
51
|
+
? Object.keys(environment).find((name) => name.toUpperCase() === "PATH") ?? "PATH"
|
|
52
|
+
: "PATH";
|
|
53
|
+
const pathDelimiter = platform === "win32" ? ";" : ":";
|
|
54
|
+
const currentPath = environment[pathKey] ?? "";
|
|
55
|
+
const pathEntries = currentPath.split(pathDelimiter).filter(Boolean);
|
|
56
|
+
if (!pathEntries.includes(agentBinDir)) {
|
|
57
|
+
environment[pathKey] = [agentBinDir, currentPath].filter(Boolean).join(pathDelimiter);
|
|
58
|
+
}
|
|
59
|
+
return environment;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createProjectCommandBashOperations(
|
|
63
|
+
options: ProjectCommandBashOperationsOptions = {},
|
|
64
|
+
): BashOperations {
|
|
65
|
+
const {
|
|
66
|
+
agentBinDir = join(getAgentDir(), "bin"),
|
|
67
|
+
baseEnvironment = process.env,
|
|
68
|
+
localOperations = createLocalBashOperations({ shellPath: options.shellPath }),
|
|
69
|
+
platform = process.platform,
|
|
70
|
+
} = options;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
exec(command, cwd, executionOptions) {
|
|
74
|
+
const environment = withAgentBinDirectory(
|
|
75
|
+
sanitizeProjectCommandEnvironment(executionOptions.env ?? baseEnvironment, platform),
|
|
76
|
+
agentBinDir,
|
|
77
|
+
platform,
|
|
78
|
+
);
|
|
79
|
+
return localOperations.exec(command, cwd, {
|
|
80
|
+
...executionOptions,
|
|
81
|
+
env: environment,
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createProjectCommandBashExtension(options: {
|
|
88
|
+
cwd: string;
|
|
89
|
+
settings: ProjectShellSettings;
|
|
90
|
+
}): InlineExtension {
|
|
91
|
+
return {
|
|
92
|
+
name: HOST_EXTENSION_NAME,
|
|
93
|
+
hidden: true,
|
|
94
|
+
factory: (pi) => {
|
|
95
|
+
const displayDefinition = createBashToolDefinition(options.cwd);
|
|
96
|
+
pi.registerTool({
|
|
97
|
+
...displayDefinition,
|
|
98
|
+
execute(toolCallId, params, signal, onUpdate, context) {
|
|
99
|
+
const executionDefinition = createBashToolDefinition(options.cwd, {
|
|
100
|
+
commandPrefix: options.settings.getShellCommandPrefix(),
|
|
101
|
+
operations: createProjectCommandBashOperations({
|
|
102
|
+
shellPath: options.settings.getShellPath(),
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
return executionDefinition.execute(toolCallId, params, signal, onUpdate, context);
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function preferUserBashExtension(base: LoadExtensionsResult): LoadExtensionsResult {
|
|
113
|
+
const hostExtensionIndex = base.extensions.findIndex((extension) => extension.path === HOST_EXTENSION_PATH);
|
|
114
|
+
if (hostExtensionIndex < 0) return base;
|
|
115
|
+
|
|
116
|
+
const userBashOwner = base.extensions
|
|
117
|
+
.slice(0, hostExtensionIndex)
|
|
118
|
+
.find((extension) => extension.tools.has("bash"));
|
|
119
|
+
if (!userBashOwner) return base;
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
...base,
|
|
123
|
+
extensions: base.extensions.filter((_, index) => index !== hostExtensionIndex),
|
|
124
|
+
errors: base.errors.filter((error) => !(
|
|
125
|
+
error.path === HOST_EXTENSION_PATH
|
|
126
|
+
&& error.error === `Tool "bash" conflicts with ${userBashOwner.path}`
|
|
127
|
+
)),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createJiti } from "jiti";
|
|
4
|
+
|
|
5
|
+
const jiti = createJiti(import.meta.url);
|
|
6
|
+
const { projectIdentityKey } = await jiti.import("./project-identity.ts");
|
|
7
|
+
const {
|
|
8
|
+
getProjectActivity,
|
|
9
|
+
getRecentProjects,
|
|
10
|
+
sessionsForProject,
|
|
11
|
+
} = await jiti.import("./project-groups.ts");
|
|
12
|
+
|
|
13
|
+
function session(id, projectRoot, modified) {
|
|
14
|
+
return {
|
|
15
|
+
id,
|
|
16
|
+
path: `${id}.jsonl`,
|
|
17
|
+
cwd: projectRoot,
|
|
18
|
+
projectRoot,
|
|
19
|
+
projectKey: projectIdentityKey(projectRoot, "win32"),
|
|
20
|
+
created: modified,
|
|
21
|
+
modified,
|
|
22
|
+
messageCount: 1,
|
|
23
|
+
firstMessage: id,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("Windows path variants form one recent project using the newest display path", () => {
|
|
28
|
+
const older = session("older", "C:\\Users\\Alex\\Project\\Study\\ELM", "2026-08-12T00:00:00.000Z");
|
|
29
|
+
const newer = session("newer", "c:/users/ALEX/project/study/elm", "2026-08-13T00:00:00.000Z");
|
|
30
|
+
|
|
31
|
+
assert.deepEqual(getRecentProjects([older, newer]), [{
|
|
32
|
+
key: older.projectKey,
|
|
33
|
+
root: newer.projectRoot,
|
|
34
|
+
}]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("project filtering includes every session with the stable identity", () => {
|
|
38
|
+
const first = session("first", "C:\\Users\\Alex\\Project", "2026-08-12T00:00:00.000Z");
|
|
39
|
+
const second = session("second", "c:/users/alex/project/", "2026-08-13T00:00:00.000Z");
|
|
40
|
+
const other = session("other", "D:\\Elsewhere", "2026-08-13T01:00:00.000Z");
|
|
41
|
+
|
|
42
|
+
assert.deepEqual(
|
|
43
|
+
sessionsForProject([first, second, other], first.projectKey).map((item) => item.id),
|
|
44
|
+
["first", "second"],
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("running and unread counts aggregate under the stable project identity", () => {
|
|
49
|
+
const first = session("first", "C:\\Users\\Alex\\Project", "2026-08-12T00:00:00.000Z");
|
|
50
|
+
const second = session("second", "c:/users/alex/project/", "2026-08-13T00:00:00.000Z");
|
|
51
|
+
|
|
52
|
+
const activity = getProjectActivity(
|
|
53
|
+
[first, second],
|
|
54
|
+
new Set(["first", "second"]),
|
|
55
|
+
new Set(["second"]),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
assert.deepEqual(activity.get(first.projectKey), { running: 2, unread: 1 });
|
|
59
|
+
assert.equal(activity.size, 1);
|
|
60
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { SessionInfo } from "./types";
|
|
2
|
+
import { workspaceKeyOf } from "./workspace-memory";
|
|
3
|
+
|
|
4
|
+
export interface RecentProject {
|
|
5
|
+
/** Stable server-provided identity used for comparison and Map keys. */
|
|
6
|
+
key: string;
|
|
7
|
+
/** Original project path used for display and filesystem operations. */
|
|
8
|
+
root: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Projects sorted by most recent activity and deduplicated by stable key. */
|
|
12
|
+
export function getRecentProjects(sessions: readonly SessionInfo[]): RecentProject[] {
|
|
13
|
+
const latestByProject = new Map<string, { root: string; modified: string }>();
|
|
14
|
+
for (const session of sessions) {
|
|
15
|
+
const root = session.projectRoot ?? session.cwd;
|
|
16
|
+
if (!root) continue;
|
|
17
|
+
const key = workspaceKeyOf(session);
|
|
18
|
+
const previous = latestByProject.get(key);
|
|
19
|
+
if (!previous || session.modified > previous.modified) {
|
|
20
|
+
latestByProject.set(key, { root, modified: session.modified });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return [...latestByProject.entries()]
|
|
24
|
+
.sort((a, b) => b[1].modified.localeCompare(a[1].modified))
|
|
25
|
+
.map(([key, { root }]) => ({ key, root }));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getProjectActivity(
|
|
29
|
+
sessions: readonly SessionInfo[],
|
|
30
|
+
runningSessionIds: ReadonlySet<string>,
|
|
31
|
+
unreadSessionIds: ReadonlySet<string>,
|
|
32
|
+
): Map<string, { running: number; unread: number }> {
|
|
33
|
+
const counts = new Map<string, { running: number; unread: number }>();
|
|
34
|
+
for (const session of sessions) {
|
|
35
|
+
const key = workspaceKeyOf(session);
|
|
36
|
+
if (!key) continue;
|
|
37
|
+
let entry = counts.get(key);
|
|
38
|
+
if (!entry) {
|
|
39
|
+
entry = { running: 0, unread: 0 };
|
|
40
|
+
counts.set(key, entry);
|
|
41
|
+
}
|
|
42
|
+
if (runningSessionIds.has(session.id)) entry.running++;
|
|
43
|
+
if (unreadSessionIds.has(session.id)) entry.unread++;
|
|
44
|
+
}
|
|
45
|
+
return counts;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function sessionsForProject(
|
|
49
|
+
sessions: readonly SessionInfo[],
|
|
50
|
+
projectKey: string,
|
|
51
|
+
): SessionInfo[] {
|
|
52
|
+
return sessions.filter((session) => workspaceKeyOf(session) === projectKey);
|
|
53
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
async function loadSubject() {
|
|
5
|
+
const { createJiti } = await import("jiti");
|
|
6
|
+
return createJiti(import.meta.url).import("./project-identity.ts");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
test("Windows project identity ignores full-path casing and separator style", async () => {
|
|
10
|
+
const { projectIdentityKey } = await loadSubject();
|
|
11
|
+
|
|
12
|
+
const expected = projectIdentityKey("C:\\Users\\Alex\\Project\\Study\\ELM", "win32");
|
|
13
|
+
assert.equal(projectIdentityKey("c:/users/ALEX/project/study/elm", "win32"), expected);
|
|
14
|
+
assert.equal(projectIdentityKey("c:\\Users\\Alex\\Project\\Study\\.\\ELM\\", "win32"), expected);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("Windows project identity handles UNC casing and separators", async () => {
|
|
18
|
+
const { projectIdentityKey } = await loadSubject();
|
|
19
|
+
|
|
20
|
+
assert.equal(
|
|
21
|
+
projectIdentityKey("\\\\Server\\Share\\Team\\Agent", "win32"),
|
|
22
|
+
projectIdentityKey("//server/share/team/AGENT/", "win32"),
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("project identity preserves case on case-sensitive platforms", async () => {
|
|
27
|
+
const { projectIdentityKey } = await loadSubject();
|
|
28
|
+
|
|
29
|
+
assert.notEqual(
|
|
30
|
+
projectIdentityKey("/Users/Alex/Project", "linux"),
|
|
31
|
+
projectIdentityKey("/users/alex/project", "linux"),
|
|
32
|
+
);
|
|
33
|
+
assert.notEqual(
|
|
34
|
+
projectIdentityKey("/a\\b", "linux"),
|
|
35
|
+
projectIdentityKey("/a/b", "linux"),
|
|
36
|
+
);
|
|
37
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stable, internal identity for a project path.
|
|
5
|
+
*
|
|
6
|
+
* Keep the original cwd/projectRoot for display and filesystem operations.
|
|
7
|
+
* This key is only for grouping and equality: Windows paths are normalized
|
|
8
|
+
* with win32 rules and case-folded because the default Windows filesystem is
|
|
9
|
+
* case-insensitive. The explicit platform argument keeps those semantics
|
|
10
|
+
* testable on non-Windows CI.
|
|
11
|
+
*/
|
|
12
|
+
export function projectIdentityKey(
|
|
13
|
+
projectRoot: string,
|
|
14
|
+
platform: NodeJS.Platform = process.platform,
|
|
15
|
+
): string {
|
|
16
|
+
if (!projectRoot) return projectRoot;
|
|
17
|
+
const pathApi = platform === "win32" ? path.win32 : path.posix;
|
|
18
|
+
const normalized = pathApi.normalize(projectRoot);
|
|
19
|
+
const rootLength = pathApi.parse(normalized).root.length;
|
|
20
|
+
let end = normalized.length;
|
|
21
|
+
while (end > rootLength && normalized[end - 1] === pathApi.sep) end--;
|
|
22
|
+
const withoutTrailingSeparators = normalized.slice(0, end);
|
|
23
|
+
return platform === "win32"
|
|
24
|
+
? withoutTrailingSeparators.toLowerCase()
|
|
25
|
+
: withoutTrailingSeparators;
|
|
26
|
+
}
|