@blackbelt-technology/pi-agent-dashboard 0.4.1 → 0.4.2
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/AGENTS.md +79 -32
- package/README.md +7 -3
- package/docs/architecture.md +361 -12
- package/package.json +7 -7
- package/packages/extension/package.json +7 -2
- package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +51 -7
- package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
- package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
- package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
- package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
- package/packages/extension/src/ask-user-tool.ts +165 -57
- package/packages/extension/src/bridge.ts +97 -4
- package/packages/extension/src/multiselect-decode.ts +40 -0
- package/packages/extension/src/multiselect-polyfill.ts +38 -8
- package/packages/extension/src/ui-modules.ts +272 -0
- package/packages/server/package.json +9 -3
- package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
- package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
- package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
- package/packages/server/src/__tests__/directory-service.test.ts +174 -0
- package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
- package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
- package/packages/server/src/__tests__/package-routes.test.ts +136 -3
- package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
- package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
- package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
- package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
- package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
- package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
- package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
- package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
- package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
- package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
- package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
- package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
- package/packages/server/src/browse.ts +118 -13
- package/packages/server/src/browser-gateway.ts +19 -0
- package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
- package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
- package/packages/server/src/browser-handlers/handler-context.ts +15 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
- package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
- package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
- package/packages/server/src/cli.ts +5 -6
- package/packages/server/src/directory-service.ts +156 -15
- package/packages/server/src/event-wiring.ts +111 -10
- package/packages/server/src/installed-package-enricher.ts +143 -0
- package/packages/server/src/package-manager-wrapper.ts +305 -8
- package/packages/server/src/package-source-helpers.ts +104 -0
- package/packages/server/src/pending-attach-registry.ts +112 -0
- package/packages/server/src/pending-resume-intent-registry.ts +107 -0
- package/packages/server/src/pi-core-checker.ts +9 -14
- package/packages/server/src/pi-gateway.ts +14 -0
- package/packages/server/src/proposal-attach-naming.ts +47 -0
- package/packages/server/src/routes/file-routes.ts +29 -3
- package/packages/server/src/routes/package-routes.ts +72 -3
- package/packages/server/src/routes/plugin-config-routes.ts +129 -0
- package/packages/server/src/routes/system-routes.ts +2 -0
- package/packages/server/src/server.ts +339 -10
- package/packages/server/src/session-api.ts +30 -5
- package/packages/server/src/session-order-manager.ts +22 -0
- package/packages/server/src/session-scanner.ts +10 -1
- package/packages/shared/package.json +9 -2
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
- package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
- package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
- package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
- package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
- package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
- package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
- package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
- package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
- package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
- package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
- package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
- package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
- package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
- package/packages/shared/src/browser-protocol.ts +110 -4
- package/packages/shared/src/config.ts +45 -0
- package/packages/shared/src/dashboard-plugin/index.ts +11 -0
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
- package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
- package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
- package/packages/shared/src/openspec-activity-detector.ts +18 -22
- package/packages/shared/src/openspec-design-evidence.ts +109 -0
- package/packages/shared/src/openspec-poller.ts +117 -3
- package/packages/shared/src/openspec-specs-evidence.ts +79 -0
- package/packages/shared/src/platform/binary-lookup.ts +96 -1
- package/packages/shared/src/plugin-bridge-register.ts +139 -0
- package/packages/shared/src/protocol.ts +56 -2
- package/packages/shared/src/recommended-extensions.ts +7 -1
- package/packages/shared/src/rest-api.ts +68 -3
- package/packages/shared/src/state-replay.ts +11 -1
- package/packages/shared/src/tool-registry/strategies.ts +17 -3
- package/packages/shared/src/types.ts +160 -0
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import * as os from "node:os";
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import * as crypto from "node:crypto";
|
|
13
|
+
import { computeIdentity, parseSourceKind } from "./package-source-helpers.js";
|
|
13
14
|
import {
|
|
14
15
|
getDefaultRegistry,
|
|
15
16
|
ModuleResolutionError,
|
|
@@ -151,27 +152,54 @@ export function diagnosePiPackageManager(registry: ToolRegistry = getDefaultRegi
|
|
|
151
152
|
export { ModuleResolutionError };
|
|
152
153
|
|
|
153
154
|
export type PackageScope = "global" | "local";
|
|
154
|
-
export type PackageAction = "install" | "remove" | "update";
|
|
155
|
+
export type PackageAction = "install" | "remove" | "update" | "move";
|
|
155
156
|
|
|
156
157
|
export interface OperationRequest {
|
|
157
|
-
action:
|
|
158
|
+
action: "install" | "remove" | "update";
|
|
158
159
|
source: string;
|
|
159
160
|
scope: PackageScope;
|
|
160
161
|
cwd?: string;
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Pi `packages[]` entry. Either a bare source string or an object with
|
|
166
|
+
* filter keys (`extensions`/`skills`/`prompts`/`themes`). See pi's
|
|
167
|
+
* `docs/packages.md` “Package Filtering” section.
|
|
168
|
+
*/
|
|
169
|
+
export type PackageEntry = string | { source: string; [k: string]: unknown };
|
|
170
|
+
|
|
171
|
+
/** Move operation request. See change: unify-package-management-ui. */
|
|
172
|
+
export interface MoveRequest {
|
|
173
|
+
/** Full origin entry (string or filter object) — passed verbatim from the route. */
|
|
174
|
+
entry: PackageEntry;
|
|
175
|
+
fromScope: PackageScope;
|
|
176
|
+
fromCwd?: string;
|
|
177
|
+
toScope: PackageScope;
|
|
178
|
+
toCwd?: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
163
181
|
export interface OperationResult {
|
|
164
182
|
operationId: string;
|
|
183
|
+
/** `move` for composite move ops; `install`/`remove`/`update` otherwise. */
|
|
165
184
|
action: PackageAction;
|
|
166
|
-
|
|
185
|
+
/** When `action === "move"`, this is the destination scope. */
|
|
167
186
|
scope: PackageScope;
|
|
187
|
+
source: string;
|
|
168
188
|
success: boolean;
|
|
169
189
|
error?: string;
|
|
190
|
+
/** Set on `action === "move"` only; ties together emitted events. */
|
|
191
|
+
moveId?: string;
|
|
192
|
+
/** Set on `action === "move"` when install succeeded but remove failed. */
|
|
193
|
+
partialSuccess?: {
|
|
194
|
+
installed: boolean;
|
|
195
|
+
removed: boolean;
|
|
196
|
+
removeError?: string;
|
|
197
|
+
};
|
|
170
198
|
/** On failure: full resolution trail if pi couldn't be loaded. */
|
|
171
199
|
diagnostics?: Resolution;
|
|
172
200
|
}
|
|
173
201
|
|
|
174
|
-
export type ProgressListener = (operationId: string, event: ProgressEvent) => void;
|
|
202
|
+
export type ProgressListener = (operationId: string, event: ProgressEvent, moveId?: string) => void;
|
|
175
203
|
export type CompleteListener = (result: OperationResult) => void;
|
|
176
204
|
|
|
177
205
|
const AGENT_DIR = path.join(os.homedir(), ".pi", "agent");
|
|
@@ -243,6 +271,51 @@ export class PackageManagerWrapper {
|
|
|
243
271
|
return operationId;
|
|
244
272
|
}
|
|
245
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Move a package between scopes (global ↔ local). Hybrid execution:
|
|
276
|
+
*
|
|
277
|
+
* - npm:/git:/https: → install at destination, then remove from origin.
|
|
278
|
+
* - abs-path/rel-path → settings-only edit (pi never copies path sources).
|
|
279
|
+
*
|
|
280
|
+
* Returns moveId synchronously. Single-flight via `this.busy`.
|
|
281
|
+
* Throws synchronously: `PackageOperationBusyError`,
|
|
282
|
+
* `InvalidMoveRequestError`, `UnsupportedSourceForDestinationError`.
|
|
283
|
+
* Throws async (caught by executeMove): `AlreadyAtDestinationError`
|
|
284
|
+
* is delivered via the complete listener with success=false.
|
|
285
|
+
*
|
|
286
|
+
* See change: unify-package-management-ui.
|
|
287
|
+
*/
|
|
288
|
+
async move(req: MoveRequest): Promise<string> {
|
|
289
|
+
if (this.busy) {
|
|
290
|
+
throw new PackageOperationBusyError();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (req.fromScope === req.toScope) {
|
|
294
|
+
throw new InvalidMoveRequestError("fromScope and toScope must differ");
|
|
295
|
+
}
|
|
296
|
+
if (req.fromScope === "local" && !req.fromCwd) {
|
|
297
|
+
throw new InvalidMoveRequestError("fromCwd required when fromScope is local");
|
|
298
|
+
}
|
|
299
|
+
if (req.toScope === "local" && !req.toCwd) {
|
|
300
|
+
throw new InvalidMoveRequestError("toCwd required when toScope is local");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const sourceStr = typeof req.entry === "string" ? req.entry : req.entry.source;
|
|
304
|
+
if (!sourceStr || typeof sourceStr !== "string") {
|
|
305
|
+
throw new InvalidMoveRequestError("entry.source must be a non-empty string");
|
|
306
|
+
}
|
|
307
|
+
if (parseSourceKind(sourceStr) === "rel-path" && req.fromScope === "local" && !req.fromCwd) {
|
|
308
|
+
throw new UnsupportedSourceForDestinationError("relative-path source requires fromCwd");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const moveId = crypto.randomUUID();
|
|
312
|
+
this.busy = true;
|
|
313
|
+
this.executeMove(moveId, req).catch(() => {
|
|
314
|
+
// errors handled inside executeMove
|
|
315
|
+
});
|
|
316
|
+
return moveId;
|
|
317
|
+
}
|
|
318
|
+
|
|
246
319
|
/**
|
|
247
320
|
* List configured packages for a scope.
|
|
248
321
|
*/
|
|
@@ -322,13 +395,14 @@ export class PackageManagerWrapper {
|
|
|
322
395
|
this.pmPending.delete(effectiveCwd);
|
|
323
396
|
}
|
|
324
397
|
|
|
325
|
-
private async executeOperation(operationId: string, req: OperationRequest): Promise<void> {
|
|
398
|
+
private async executeOperation(operationId: string, req: OperationRequest, moveId?: string): Promise<void> {
|
|
326
399
|
const result: OperationResult = {
|
|
327
400
|
operationId,
|
|
328
401
|
action: req.action,
|
|
329
402
|
source: req.source,
|
|
330
403
|
scope: req.scope,
|
|
331
404
|
success: false,
|
|
405
|
+
moveId,
|
|
332
406
|
};
|
|
333
407
|
|
|
334
408
|
try {
|
|
@@ -336,7 +410,7 @@ export class PackageManagerWrapper {
|
|
|
336
410
|
const local = req.scope === "local";
|
|
337
411
|
|
|
338
412
|
pm.setProgressCallback((event: ProgressEvent) => {
|
|
339
|
-
this.onProgress?.(operationId, event);
|
|
413
|
+
this.onProgress?.(operationId, event, moveId);
|
|
340
414
|
});
|
|
341
415
|
|
|
342
416
|
switch (req.action) {
|
|
@@ -357,8 +431,9 @@ export class PackageManagerWrapper {
|
|
|
357
431
|
// listInstalled() calls see the mutated settings.json.
|
|
358
432
|
this.invalidatePackageManager(req.cwd);
|
|
359
433
|
|
|
360
|
-
// Reload all sessions
|
|
361
|
-
|
|
434
|
+
// Reload all sessions. When called inside a move (moveId set),
|
|
435
|
+
// skip — executeMove issues exactly one reload at the very end.
|
|
436
|
+
if (this.reloadSessions && !moveId) {
|
|
362
437
|
try {
|
|
363
438
|
const count = await this.reloadSessions();
|
|
364
439
|
(result as any).sessionsReloaded = count;
|
|
@@ -376,6 +451,156 @@ export class PackageManagerWrapper {
|
|
|
376
451
|
} else {
|
|
377
452
|
result.error = err?.message ?? String(err);
|
|
378
453
|
}
|
|
454
|
+
// Re-throw so executeMove can detect failure and short-circuit.
|
|
455
|
+
if (moveId) throw err;
|
|
456
|
+
} finally {
|
|
457
|
+
// When inside a move the busy lock is held by executeMove —
|
|
458
|
+
// do NOT release it here. Don't fire the completion listener
|
|
459
|
+
// either — executeMove emits a single composite "move" event.
|
|
460
|
+
if (!moveId) {
|
|
461
|
+
this.busy = false;
|
|
462
|
+
this.onComplete?.(result);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Execute a move. Holds the busy lock across both phases. Emits exactly
|
|
469
|
+
* one `package_operation_complete` listener call with `action: "move"`.
|
|
470
|
+
*/
|
|
471
|
+
private async executeMove(moveId: string, req: MoveRequest): Promise<void> {
|
|
472
|
+
const sourceStr = typeof req.entry === "string" ? req.entry : req.entry.source;
|
|
473
|
+
const result: OperationResult = {
|
|
474
|
+
operationId: moveId,
|
|
475
|
+
action: "move",
|
|
476
|
+
source: sourceStr,
|
|
477
|
+
scope: req.toScope,
|
|
478
|
+
success: false,
|
|
479
|
+
moveId,
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const pmCwd = req.toCwd ?? req.fromCwd ?? process.cwd();
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
const pm = await this.createPackageManager(pmCwd);
|
|
486
|
+
const settingsManager = (pm as any).settingsManager;
|
|
487
|
+
if (!settingsManager) {
|
|
488
|
+
throw new Error("pi DefaultPackageManager does not expose settingsManager (unexpected pi version)");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Identity preflight against destination's packages[].
|
|
492
|
+
const destPackages = readPackages(settingsManager, req.toScope);
|
|
493
|
+
const toSettingsDir = req.toScope === "global"
|
|
494
|
+
? path.join(os.homedir(), ".pi", "agent")
|
|
495
|
+
: path.join(req.toCwd ?? pmCwd, ".pi");
|
|
496
|
+
const fromSettingsDir = req.fromScope === "global"
|
|
497
|
+
? path.join(os.homedir(), ".pi", "agent")
|
|
498
|
+
: path.join(req.fromCwd ?? pmCwd, ".pi");
|
|
499
|
+
const incomingIdentity = computeIdentity(sourceStr, fromSettingsDir);
|
|
500
|
+
const dup = destPackages.find((e) => {
|
|
501
|
+
const s = typeof e === "string" ? e : e?.source;
|
|
502
|
+
return s ? computeIdentity(s, toSettingsDir) === incomingIdentity : false;
|
|
503
|
+
});
|
|
504
|
+
if (dup) throw new AlreadyAtDestinationError(sourceStr, req.toScope);
|
|
505
|
+
|
|
506
|
+
const kind = parseSourceKind(sourceStr);
|
|
507
|
+
const isPathArm = kind === "abs-path" || kind === "rel-path";
|
|
508
|
+
|
|
509
|
+
pm.setProgressCallback((event: ProgressEvent) => {
|
|
510
|
+
this.onProgress?.(moveId, { ...event, action: "move" as any }, moveId);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
if (isPathArm) {
|
|
514
|
+
// Path arm: settings-only edit, no file copy.
|
|
515
|
+
const fromPackages = readPackages(settingsManager, req.fromScope);
|
|
516
|
+
const fromIdx = fromPackages.findIndex((e) => {
|
|
517
|
+
const s = typeof e === "string" ? e : e?.source;
|
|
518
|
+
return s && computeIdentity(s, fromSettingsDir) === incomingIdentity;
|
|
519
|
+
});
|
|
520
|
+
if (fromIdx < 0) {
|
|
521
|
+
throw new Error(`source not found in ${req.fromScope} packages[]`);
|
|
522
|
+
}
|
|
523
|
+
const originEntry = fromPackages[fromIdx];
|
|
524
|
+
const newSource = translatePathSource({
|
|
525
|
+
originalSource: sourceStr,
|
|
526
|
+
fromSettingsDir,
|
|
527
|
+
toSettingsDir,
|
|
528
|
+
toScope: req.toScope,
|
|
529
|
+
});
|
|
530
|
+
const newEntry: PackageEntry = typeof originEntry === "string"
|
|
531
|
+
? newSource
|
|
532
|
+
: { ...originEntry, source: newSource };
|
|
533
|
+
|
|
534
|
+
writePackages(settingsManager, req.toScope, [...destPackages, newEntry]);
|
|
535
|
+
writePackages(settingsManager, req.fromScope, fromPackages.filter((_, i) => i !== fromIdx));
|
|
536
|
+
} else {
|
|
537
|
+
// npm/git/https arm: install at dest, then remove from origin.
|
|
538
|
+
const installReq: OperationRequest = {
|
|
539
|
+
action: "install",
|
|
540
|
+
source: sourceStr,
|
|
541
|
+
scope: req.toScope,
|
|
542
|
+
cwd: req.toScope === "local" ? req.toCwd : undefined,
|
|
543
|
+
};
|
|
544
|
+
await this.executeOperation(crypto.randomUUID(), installReq, moveId);
|
|
545
|
+
|
|
546
|
+
// Filter-preservation: if origin had filters (object form), patch
|
|
547
|
+
// the destination entry pi just wrote so they survive the move.
|
|
548
|
+
if (typeof req.entry === "object" && req.entry !== null) {
|
|
549
|
+
const finalDest = readPackages(settingsManager, req.toScope);
|
|
550
|
+
const idx = finalDest.findIndex((e) => {
|
|
551
|
+
const s = typeof e === "string" ? e : e?.source;
|
|
552
|
+
return s && computeIdentity(s, toSettingsDir) === incomingIdentity;
|
|
553
|
+
});
|
|
554
|
+
if (idx >= 0) {
|
|
555
|
+
const installedEntry = finalDest[idx];
|
|
556
|
+
const installedSource = typeof installedEntry === "string"
|
|
557
|
+
? installedEntry
|
|
558
|
+
: installedEntry.source;
|
|
559
|
+
finalDest[idx] = { ...req.entry, source: installedSource };
|
|
560
|
+
writePackages(settingsManager, req.toScope, finalDest);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Remove from origin. Failure → partial-success, not full failure.
|
|
565
|
+
try {
|
|
566
|
+
const removeReq: OperationRequest = {
|
|
567
|
+
action: "remove",
|
|
568
|
+
source: sourceStr,
|
|
569
|
+
scope: req.fromScope,
|
|
570
|
+
cwd: req.fromScope === "local" ? req.fromCwd : undefined,
|
|
571
|
+
};
|
|
572
|
+
await this.executeOperation(crypto.randomUUID(), removeReq, moveId);
|
|
573
|
+
} catch (removeErr: any) {
|
|
574
|
+
result.partialSuccess = {
|
|
575
|
+
installed: true,
|
|
576
|
+
removed: false,
|
|
577
|
+
removeError: removeErr?.message ?? String(removeErr),
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
result.success = true;
|
|
583
|
+
this.invalidatePackageManager(req.fromCwd);
|
|
584
|
+
this.invalidatePackageManager(req.toCwd);
|
|
585
|
+
|
|
586
|
+
if (this.reloadSessions) {
|
|
587
|
+
try {
|
|
588
|
+
const count = await this.reloadSessions();
|
|
589
|
+
(result as any).sessionsReloaded = count;
|
|
590
|
+
} catch (err) {
|
|
591
|
+
console.error("[package-manager] session reload failed:", err);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
} catch (err: any) {
|
|
595
|
+
if (err instanceof ModuleResolutionError) {
|
|
596
|
+
result.error = err.message;
|
|
597
|
+
result.diagnostics = err.resolution;
|
|
598
|
+
} else if (err instanceof AlreadyAtDestinationError) {
|
|
599
|
+
result.error = err.message;
|
|
600
|
+
(result as any).code = "already_at_destination";
|
|
601
|
+
} else {
|
|
602
|
+
result.error = err?.message ?? String(err);
|
|
603
|
+
}
|
|
379
604
|
} finally {
|
|
380
605
|
this.busy = false;
|
|
381
606
|
this.onComplete?.(result);
|
|
@@ -383,6 +608,78 @@ export class PackageManagerWrapper {
|
|
|
383
608
|
}
|
|
384
609
|
}
|
|
385
610
|
|
|
611
|
+
// ──────────────────────────────────────────────────────────────────
|
|
612
|
+
// SettingsManager helpers — thin shim around pi's API.
|
|
613
|
+
// ──────────────────────────────────────────────────────────────────
|
|
614
|
+
|
|
615
|
+
function readPackages(settingsManager: any, scope: PackageScope): PackageEntry[] {
|
|
616
|
+
const settings = scope === "global"
|
|
617
|
+
? settingsManager.getGlobalSettings?.()
|
|
618
|
+
: settingsManager.getProjectSettings?.();
|
|
619
|
+
return Array.isArray(settings?.packages) ? [...settings.packages] : [];
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function writePackages(settingsManager: any, scope: PackageScope, packages: PackageEntry[]): void {
|
|
623
|
+
if (scope === "global") {
|
|
624
|
+
if (typeof settingsManager.setPackages !== "function") {
|
|
625
|
+
throw new Error("settingsManager.setPackages not available (unexpected pi version)");
|
|
626
|
+
}
|
|
627
|
+
settingsManager.setPackages(packages);
|
|
628
|
+
} else {
|
|
629
|
+
if (typeof settingsManager.setProjectPackages !== "function") {
|
|
630
|
+
throw new Error("settingsManager.setProjectPackages not available (unexpected pi version)");
|
|
631
|
+
}
|
|
632
|
+
settingsManager.setProjectPackages(packages);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Translate a path source between scopes per design.md decision 1.
|
|
638
|
+
* To global → resolve to absolute against fromSettingsDir.
|
|
639
|
+
* To local → try path.relative(toSettingsDir, abs); keep absolute if
|
|
640
|
+
* the relative form escapes the cwd tree by more than 2 `..` segments.
|
|
641
|
+
*/
|
|
642
|
+
export function translatePathSource(args: {
|
|
643
|
+
originalSource: string;
|
|
644
|
+
fromSettingsDir: string;
|
|
645
|
+
toSettingsDir: string;
|
|
646
|
+
toScope: PackageScope;
|
|
647
|
+
}): string {
|
|
648
|
+
const { originalSource, fromSettingsDir, toSettingsDir, toScope } = args;
|
|
649
|
+
const abs = path.isAbsolute(originalSource)
|
|
650
|
+
? path.normalize(originalSource)
|
|
651
|
+
: path.resolve(fromSettingsDir, originalSource);
|
|
652
|
+
|
|
653
|
+
if (toScope === "global") return abs;
|
|
654
|
+
|
|
655
|
+
const rel = path.relative(toSettingsDir, abs);
|
|
656
|
+
if (rel === "") return ".";
|
|
657
|
+
const upSegments = rel.split(path.sep).filter((s) => s === "..").length;
|
|
658
|
+
if (upSegments > 2) return abs;
|
|
659
|
+
return rel;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export class AlreadyAtDestinationError extends Error {
|
|
663
|
+
constructor(public source: string, public destScope: PackageScope) {
|
|
664
|
+
super(`Package already installed at ${destScope} scope: ${source}`);
|
|
665
|
+
this.name = "AlreadyAtDestinationError";
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export class InvalidMoveRequestError extends Error {
|
|
670
|
+
constructor(reason: string) {
|
|
671
|
+
super(`Invalid move request: ${reason}`);
|
|
672
|
+
this.name = "InvalidMoveRequestError";
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
export class UnsupportedSourceForDestinationError extends Error {
|
|
677
|
+
constructor(reason: string) {
|
|
678
|
+
super(`Unsupported source for destination: ${reason}`);
|
|
679
|
+
this.name = "UnsupportedSourceForDestinationError";
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
386
683
|
export class PackageOperationBusyError extends Error {
|
|
387
684
|
constructor() {
|
|
388
685
|
super("A package operation is already in progress");
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for classifying pi package sources and computing
|
|
3
|
+
* dedup identities. Mirrors the rules documented in pi's
|
|
4
|
+
* `docs/packages.md`:
|
|
5
|
+
*
|
|
6
|
+
* - npm:<spec> → identity = bare package name (without `@version`)
|
|
7
|
+
* - git:<url> → identity = url with trailing `@<ref>` stripped
|
|
8
|
+
* - https://... → identity = url with trailing `@<ref>` stripped
|
|
9
|
+
* - /abs/path → identity = the absolute path verbatim
|
|
10
|
+
* - rel-path → identity = path resolved against settingsDir
|
|
11
|
+
*
|
|
12
|
+
* Used by `PackageManagerWrapper.move()` for identity preflight (so we
|
|
13
|
+
* can return 409 already_at_destination before any side-effects) and
|
|
14
|
+
* for source classification when picking the move execution arm.
|
|
15
|
+
*
|
|
16
|
+
* See change: unify-package-management-ui.
|
|
17
|
+
*/
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
export type SourceKind = "npm" | "git" | "https" | "abs-path" | "rel-path";
|
|
21
|
+
|
|
22
|
+
const PROTOCOL_PREFIXES = ["https://", "http://", "ssh://", "git://"];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Classify a pi package source string by kind.
|
|
26
|
+
*
|
|
27
|
+
* Falls through to abs-path / rel-path for anything that isn't an
|
|
28
|
+
* `npm:` / `git:` / protocol-url source. Empty strings and pure
|
|
29
|
+
* whitespace are treated as `rel-path` (defensive — pi would reject
|
|
30
|
+
* these upstream, but we don't need to crash on them here).
|
|
31
|
+
*/
|
|
32
|
+
export function parseSourceKind(source: string): SourceKind {
|
|
33
|
+
if (source.startsWith("npm:")) return "npm";
|
|
34
|
+
// Protocol URLs MUST be checked before the `git:` shorthand check
|
|
35
|
+
// because `git://` legitimately starts with `git:` but is a protocol
|
|
36
|
+
// URL per pi's docs (handled the same as https for our purposes).
|
|
37
|
+
for (const proto of PROTOCOL_PREFIXES) {
|
|
38
|
+
if (source.startsWith(proto)) return "https";
|
|
39
|
+
}
|
|
40
|
+
if (source.startsWith("git:")) return "git";
|
|
41
|
+
if (path.isAbsolute(source)) return "abs-path";
|
|
42
|
+
// Windows drive-letter paths (e.g. C:\foo or C:/foo). `path.isAbsolute`
|
|
43
|
+
// only returns true for these on win32; on POSIX hosts we still want
|
|
44
|
+
// to classify them as abs-path so cross-host tests are stable.
|
|
45
|
+
if (/^[a-zA-Z]:[\\/]/.test(source)) return "abs-path";
|
|
46
|
+
return "rel-path";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Compute the dedup identity for a source string per pi's package
|
|
51
|
+
* scope-and-deduplication rules.
|
|
52
|
+
*
|
|
53
|
+
* For relative paths, `settingsDir` is the directory of the
|
|
54
|
+
* `settings.json` file the entry lives in (pi resolves rel paths
|
|
55
|
+
* against that location). When called without `settingsDir` the
|
|
56
|
+
* relative path is returned verbatim — callers that need real
|
|
57
|
+
* dedup MUST pass `settingsDir`.
|
|
58
|
+
*/
|
|
59
|
+
export function computeIdentity(source: string, settingsDir?: string): string {
|
|
60
|
+
const kind = parseSourceKind(source);
|
|
61
|
+
|
|
62
|
+
switch (kind) {
|
|
63
|
+
case "npm": {
|
|
64
|
+
// "npm:@scope/pkg@1.2.3" → "npm:@scope/pkg"
|
|
65
|
+
// "npm:foo@1.2.3" → "npm:foo"
|
|
66
|
+
const rest = source.slice(4); // strip "npm:"
|
|
67
|
+
// Find the LAST `@` that isn't part of the leading scope `@`.
|
|
68
|
+
const scoped = rest.startsWith("@");
|
|
69
|
+
const atIdx = scoped ? rest.indexOf("@", 1) : rest.indexOf("@");
|
|
70
|
+
const name = atIdx >= 0 ? rest.slice(0, atIdx) : rest;
|
|
71
|
+
return `npm:${name}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
case "git":
|
|
75
|
+
case "https": {
|
|
76
|
+
// Strip trailing `@<ref>` if present. Refs come AFTER the
|
|
77
|
+
// repo URL; we look for the LAST `@` that isn't the
|
|
78
|
+
// `git@host` form's leading `@`.
|
|
79
|
+
//
|
|
80
|
+
// Heuristic: split on `@` and rejoin all but the last
|
|
81
|
+
// segment IF the last segment looks like a version/ref
|
|
82
|
+
// (no `/`, no `.`-host-style structure).
|
|
83
|
+
const lastAt = source.lastIndexOf("@");
|
|
84
|
+
if (lastAt > 0) {
|
|
85
|
+
const tail = source.slice(lastAt + 1);
|
|
86
|
+
// `git@github.com:...` — the `@` here is part of the host.
|
|
87
|
+
const hostLikeTail = tail.includes(":") || tail.includes("/");
|
|
88
|
+
if (!hostLikeTail) {
|
|
89
|
+
return source.slice(0, lastAt);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return source;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
case "abs-path":
|
|
96
|
+
return path.normalize(source);
|
|
97
|
+
|
|
98
|
+
case "rel-path":
|
|
99
|
+
if (settingsDir) {
|
|
100
|
+
return path.resolve(settingsDir, source);
|
|
101
|
+
}
|
|
102
|
+
return source;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory FIFO queue of pending `attachProposal` intents per cwd.
|
|
3
|
+
*
|
|
4
|
+
* Lifecycle:
|
|
5
|
+
* 1. Browser sends `spawn_session { cwd, attachProposal }` → server enqueues.
|
|
6
|
+
* 2. Bridge later issues `session_register { sessionId, cwd }` → server
|
|
7
|
+
* consumes the head intent for that cwd and applies the same idempotent
|
|
8
|
+
* attach + auto-rename logic as `handleAttachProposal`.
|
|
9
|
+
*
|
|
10
|
+
* Constraints (see openspec/changes/add-folder-task-checker-and-spawn-attach):
|
|
11
|
+
* - Per-cwd queue is FIFO, capped at 8 entries (silent drop + warn on overflow).
|
|
12
|
+
* - Entries older than 60 s are discarded on every read or write touching that
|
|
13
|
+
* cwd, so a failed spawn cannot strand an intent that would later attach to
|
|
14
|
+
* an unrelated session.
|
|
15
|
+
* - Cwd is normalized via `safeRealpathSync` before keying the queue, so
|
|
16
|
+
* trailing-slash / symlink variants collapse onto the same key.
|
|
17
|
+
*
|
|
18
|
+
* In-memory only. NOT persisted across server restarts.
|
|
19
|
+
*/
|
|
20
|
+
import { safeRealpathSync } from "./resolve-path.js";
|
|
21
|
+
|
|
22
|
+
interface PendingAttach {
|
|
23
|
+
changeName: string;
|
|
24
|
+
enqueuedAt: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const PENDING_ATTACH_TTL_MS = 60_000;
|
|
28
|
+
export const PENDING_ATTACH_QUEUE_CAP = 8;
|
|
29
|
+
|
|
30
|
+
export interface PendingAttachRegistry {
|
|
31
|
+
enqueue(cwd: string, changeName: string): boolean;
|
|
32
|
+
consume(cwd: string): string | null;
|
|
33
|
+
size(cwd: string): number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PendingAttachRegistryOptions {
|
|
37
|
+
/** Override `Date.now` for tests. */
|
|
38
|
+
now?: () => number;
|
|
39
|
+
/** Override the cwd normalizer (defaults to `safeRealpathSync`). */
|
|
40
|
+
normalize?: (cwd: string) => string;
|
|
41
|
+
/** Override warning sink (defaults to `console.warn`). */
|
|
42
|
+
warn?: (msg: string) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createPendingAttachRegistry(
|
|
46
|
+
opts: PendingAttachRegistryOptions = {},
|
|
47
|
+
): PendingAttachRegistry {
|
|
48
|
+
const now = opts.now ?? (() => Date.now());
|
|
49
|
+
const normalize = opts.normalize ?? ((cwd: string) => safeRealpathSync(stripTrailingSep(cwd)));
|
|
50
|
+
const warn = opts.warn ?? ((m: string) => console.warn(m));
|
|
51
|
+
|
|
52
|
+
const store = new Map<string, PendingAttach[]>();
|
|
53
|
+
|
|
54
|
+
function pruneStale(key: string): PendingAttach[] {
|
|
55
|
+
const queue = store.get(key);
|
|
56
|
+
if (!queue) return [];
|
|
57
|
+
const cutoff = now() - PENDING_ATTACH_TTL_MS;
|
|
58
|
+
let dropped = 0;
|
|
59
|
+
while (queue.length > 0 && queue[0]!.enqueuedAt < cutoff) {
|
|
60
|
+
const stale = queue.shift()!;
|
|
61
|
+
dropped += 1;
|
|
62
|
+
warn(
|
|
63
|
+
`[pending-attach-registry] dropping stale intent: cwd=${key} change=${stale.changeName} ageMs=${now() - stale.enqueuedAt}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (queue.length === 0) {
|
|
67
|
+
store.delete(key);
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
void dropped;
|
|
71
|
+
return queue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
enqueue(cwd: string, changeName: string): boolean {
|
|
76
|
+
if (!changeName) return false;
|
|
77
|
+
const key = normalize(cwd);
|
|
78
|
+
const queue = pruneStale(key);
|
|
79
|
+
if (queue.length >= PENDING_ATTACH_QUEUE_CAP) {
|
|
80
|
+
warn(
|
|
81
|
+
`[pending-attach-registry] queue cap reached (${PENDING_ATTACH_QUEUE_CAP}) for cwd=${key}; dropping change=${changeName}`,
|
|
82
|
+
);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
queue.push({ changeName, enqueuedAt: now() });
|
|
86
|
+
store.set(key, queue);
|
|
87
|
+
return true;
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
consume(cwd: string): string | null {
|
|
91
|
+
const key = normalize(cwd);
|
|
92
|
+
const queue = pruneStale(key);
|
|
93
|
+
if (queue.length === 0) return null;
|
|
94
|
+
const head = queue.shift()!;
|
|
95
|
+
if (queue.length === 0) store.delete(key);
|
|
96
|
+
return head.changeName;
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
size(cwd: string): number {
|
|
100
|
+
const key = normalize(cwd);
|
|
101
|
+
const queue = pruneStale(key);
|
|
102
|
+
return queue.length;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function stripTrailingSep(p: string): string {
|
|
108
|
+
if (p.length > 1 && (p.endsWith("/") || p.endsWith("\\"))) {
|
|
109
|
+
return p.replace(/[/\\]+$/, "");
|
|
110
|
+
}
|
|
111
|
+
return p;
|
|
112
|
+
}
|