pi-codex-marketplace 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -2,20 +2,24 @@
2
2
 
3
3
  Bridge Package for Codex Marketplace compatibility in Pi (`Pi 0.84.2`).
4
4
 
5
- > **One-line:** `pi install pi-codex-marketplace` → `/codex-marketplace` shows Global / Project partitioned Bridge State and lifecycle controls with Validation Disclosure, dual Confirmation, and three-orthogonal Receipt reporting.
5
+ > **One-line:** `pi install npm:pi-codex-marketplace` → `/codex-marketplace` shows Global / Project partitioned Bridge State and lifecycle controls with Validation Disclosure, dual Confirmation, and three-orthogonal Receipt reporting.
6
6
 
7
7
  ## Install
8
8
 
9
9
  ```bash
10
- pi install pi-codex-marketplace # Global Scope (writes to ~/.pi/agent/settings.json)
11
- pi install -l pi-codex-marketplace # Project Scope (.pi/settings.json)
12
- pi install -e ./path/to/pi-codex-marketplace # External link (try without publishing)
13
- pi update pi-codex-marketplace # Update to latest compatible Bridge Package
14
- pi remove pi-codex-marketplace # Remove Registration + Installations atomically
15
- pi -e ./path/to/repo # Ephemeral run without installing
10
+ pi install npm:pi-codex-marketplace # Global Scope (writes to ~/.pi/agent/settings.json)
11
+ pi install npm:pi-codex-marketplace -l # Project Scope (.pi/settings.json)
12
+ pi install ./path/to/pi-codex-marketplace # Local path (try without publishing)
13
+ pi install ./path/to/pi-codex-marketplace -l # Local path, Project Scope
14
+ pi -e npm:pi-codex-marketplace # Ephemeral try without installing (temporary)
15
+ pi update npm:pi-codex-marketplace # Update one package
16
+ pi update --all # Update pi + all packages
17
+ pi remove npm:pi-codex-marketplace # Remove package
18
+ pi list # List installed packages
19
+ pi config # Enable/disable resources (Tab switches scope)
16
20
  ```
17
21
 
18
- All `pi install / -e / -l / update / remove` semantics are handled by the Pi host; this package declares a single `pi` extension entry (`extensions/pi/index.ts`) loaded via `jiti` and requires no build step.
22
+ Source types follow `docs/packages.md`: `npm:` for registry, `git:`/`https://` for git, and absolute/relative paths for local. Ephemeral runs use `pi -e <source>` (not `pi install -e`). This package declares a single `pi` extension entry (`extensions/pi/index.ts`) loaded via `jiti` and requires no build step.
19
23
 
20
24
  Requirements: **Pi 0.84.2**, **Node >=22.19.0**, **macOS / Linux** (Windows not supported).
21
25
 
@@ -78,7 +82,7 @@ Project Scope mutations and Effective-State participation require Pi's `Project
78
82
  | OS | **macOS**, **Linux** | Windows not supported (path containment, symlink, `flock` semantics are POSIX-only) |
79
83
  | Node | **>=22.19.0** | `engines.node` enforced; `npm-shrinkwrap.json` pins Pi 0.84.2 host |
80
84
  | Pi host | **0.84.2** | `peerDependencies` exact `0.84.2`; expected compatible range `^0.84.2` (devDeps). `pi-ai`/`pi-tui` peers `*` per Pi extension docs. |
81
- | Semantics | `pi install` / `pi install -e` / `pi install -l` / `pi update` / `pi remove` | Single `pi` extension package; `files` ships `extensions/`, `src/`, `README.md`, `LICENSE` only |
85
+ | Semantics | `pi install` / `pi -e` / `pi install -l` / `pi update` / `pi remove` / `pi list` / `pi config` | Single `pi` extension package; `files` ships `extensions/`, `src/`, `README.md`, `LICENSE` only |
82
86
 
83
87
  Peer declaration (dual): **精確 `0.84.2`** in `peerDependencies` (exact host that this version was validated against) + **預期 `^0.84.2`** in `devDependencies` (range expected to remain compatible). `pi-ai` and `pi-tui` remain `*` because they are bundled by Pi.
84
88
 
@@ -13,6 +13,7 @@ import {
13
13
  type InstallationOutcome,
14
14
  } from '../../src/installation/flow.js';
15
15
  import { inspectMarketplaceEntries } from '../../src/installation/inspection.js';
16
+ import type { SourceCache } from '../../src/cache/source-cache.js';
16
17
  import type { Registration, Scope } from '../../src/bridge-state/types.js';
17
18
  import { reportOutcome } from './registration.js';
18
19
 
@@ -23,9 +24,9 @@ function labelText(value: string): string { return JSON.stringify(value); }
23
24
  export async function entryChoices(
24
25
  registration: Registration,
25
26
  scope: Scope,
26
- _opts: { cwd?: string; projectTrusted?: boolean },
27
+ opts: { cwd?: string; agentDir?: string; projectTrusted?: boolean; cache?: SourceCache } = {},
27
28
  ): Promise<EntryChoice[]> {
28
- const inspection = inspectMarketplaceEntries(registration, scope);
29
+ const inspection = inspectMarketplaceEntries(registration, scope, { agentDir: opts.agentDir, cache: opts.cache });
29
30
  if (!inspection.marketplaceId) return [{ label: `${labelText(registration.alias ?? registration.id)} — Unavailable (${labelText(inspection.findings[0]?.outcome ?? 'Marketplace Catalog cannot be read')})` }];
30
31
  return inspection.entries.map((item) => {
31
32
  const status = item.unavailableReason ? `Unavailable (${item.unavailableReason})` : '可安裝';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codex-marketplace",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Bridge Package for Codex Marketplace compatibility in Pi — Global/Project Bridge State, atomic persistence, and /codex-marketplace TUI",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -27,7 +27,7 @@ import {
27
27
  import { cpSync } from 'node:fs';
28
28
  import { join } from 'node:path';
29
29
 
30
- import { acquireLock, releaseLock, atomicWriteFile } from '../bridge-state/atomic.js';
30
+ import { acquireLock, acquireLockSync, releaseLock, atomicWriteFile } from '../bridge-state/atomic.js';
31
31
  import { readBridgeState } from '../bridge-state/store.js';
32
32
  import type { BridgeState } from '../bridge-state/types.js';
33
33
  import type { Scope } from '../bridge-state/types.js';
@@ -123,6 +123,19 @@ export class SourceCache {
123
123
  }
124
124
  }
125
125
 
126
+ /**
127
+ * Synchronous per-fingerprint mutual exclusion around store/prune/touch for that entry.
128
+ */
129
+ withFingerprintLockSync<T>(fingerprint: string, fn: () => T, timeoutMs = 5000): T {
130
+ const lockPath = this.lockPath(fingerprint);
131
+ const fd = acquireLockSync(lockPath, timeoutMs);
132
+ try {
133
+ return fn();
134
+ } finally {
135
+ releaseLock(fd, lockPath);
136
+ }
137
+ }
138
+
126
139
  /** Register an in-flight pin (reference-counted); returns its release function. In-flight entries are never evicted. */
127
140
  pinInFlight(fingerprint: string): () => void {
128
141
  this.inFlight.set(fingerprint, (this.inFlight.get(fingerprint) ?? 0) + 1);
@@ -148,6 +161,18 @@ export class SourceCache {
148
161
  return { path: dir, fingerprint };
149
162
  }
150
163
 
164
+ /** Synchronous exact fingerprint hit: entry directory + matching meta must exist. Touches lastAccess. */
165
+ hitExactSync(fingerprint: string): CacheHit | null {
166
+ if (!isSafeFingerprint(fingerprint)) return null;
167
+ const dir = this.entryPath(fingerprint);
168
+ const meta = this.readMeta(fingerprint);
169
+ if (!existsSync(dir) || !meta || meta.fingerprint !== fingerprint) return null;
170
+ this.withFingerprintLockSync(fingerprint, () => {
171
+ this.touchMeta(fingerprint);
172
+ });
173
+ return { path: dir, fingerprint };
174
+ }
175
+
151
176
  /**
152
177
  * Store an acquired source tree under its fingerprint. The stored fingerprint is pinned
153
178
  * for the duration of the store ("in flight"), so its own prune can never evict it.
@@ -13,12 +13,14 @@ import { CODE, RULE, blocking, hasBlocking, sortFindings, type ValidationFinding
13
13
  import { acquireAttemptFence, type AttemptFenceHandle } from '../registration/fence.js';
14
14
  import { createReceipt, type AttemptReceipt } from '../registration/receipt.js';
15
15
  import { appendReceipt } from '../journal/journal.js';
16
+ import type { SourceCache } from '../cache/source-cache.js';
16
17
  import type { ValidationSnapshot } from '../registration/snapshot.js';
17
18
  import { inspectMarketplaceEntries } from './inspection.js';
18
19
 
19
20
  export interface InstallationFlowOptions {
20
21
  cwd?: string;
21
22
  agentDir?: string;
23
+ cache?: SourceCache;
22
24
  projectTrusted?: boolean;
23
25
  fenceTimeoutMs?: number;
24
26
  /** Integration synchronization seam; production callers leave this undefined. */
@@ -148,18 +150,16 @@ async function makePreflight(
148
150
  operationFinding(scope, CODE.INSTALLATION_NOT_FOUND, RULE.INSTALLATION_NOT_FOUND, `Registration '${registrationId}' is not in ${scope} Bridge State`, 'registration'),
149
151
  ], null, opts);
150
152
  }
151
- if (registration.sourceKind !== 'local' || !registration.source) {
152
- return blocked(operation, scope, registrationId, entryPointer, state.stateRevision, [
153
- operationFinding(scope, CODE.SOURCE_REACQUISITION_REQUIRED, RULE.SOURCE_REACQUISITION_REQUIRED, 'This Registration has no retained local Validation Snapshot tree; Git Installation waits for the Source Cache lifecycle', 'registration'),
154
- ], null, opts);
155
- }
156
153
 
157
154
  const fenceResult = await acquireAttemptFence(scope, { cwd: opts.cwd, agentDir: opts.agentDir, fenceTimeoutMs: opts.fenceTimeoutMs, projectTrusted: opts.projectTrusted });
158
155
  if (!fenceResult.ok) return blocked(operation, scope, registrationId, entryPointer, state.stateRevision, [fenceResult.finding!], null, opts);
159
156
  const fence = fenceResult.handle!;
160
157
 
161
158
  try {
162
- const inspection = inspectMarketplaceEntries(registration, scope);
159
+ const inspection = inspectMarketplaceEntries(registration, scope, {
160
+ agentDir: opts.agentDir,
161
+ cache: opts.cache,
162
+ });
163
163
  const inspected = inspection.entries.find((item) => item.entry.entryId === entryPointer);
164
164
  const rejectedFindings = inspected?.findings ?? (inspection.findings.length > 0 ? inspection.findings : [
165
165
  operationFinding(scope, CODE.INSTALLATION_NOT_FOUND, RULE.INSTALLATION_NOT_FOUND, `Marketplace Entry '${entryPointer}' is Unavailable`, 'entry'),
@@ -315,7 +315,10 @@ export async function confirmPluginInstallation(
315
315
  if (current.state!.stateRevision !== preflight.stateRevision) {
316
316
  return rejectedAsStale(preflight, 'State Revision changed since Installation disclosure; re-run preflight and confirmation', opts);
317
317
  }
318
- const fresh = inspectMarketplaceEntries(preflight.registration, preflight.scope);
318
+ const fresh = inspectMarketplaceEntries(preflight.registration, preflight.scope, {
319
+ agentDir: opts.agentDir,
320
+ cache: opts.cache,
321
+ });
319
322
  if (!fresh.snapshot || fresh.snapshot.fingerprint !== preflight.snapshot.fingerprint) {
320
323
  return rejectedAsStale(preflight, 'Validation Snapshot changed since Installation disclosure; re-run preflight and confirmation', opts);
321
324
  }
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { createHash } from 'node:crypto';
9
- import { lstatSync, readFileSync } from 'node:fs';
9
+ import { lstatSync, readFileSync, realpathSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
 
12
12
  import { classifyPlugin, type CompatiblePlugin } from '../compatibility/profile.js';
@@ -15,8 +15,9 @@ import type { MarketplaceEntry } from '../registration/catalog.js';
15
15
  import { parseCatalog } from '../registration/catalog.js';
16
16
  import { resolveContained } from '../registration/contained.js';
17
17
  import { CODE, RULE, blocking, hasBlocking, sortFindings, type ValidationFinding } from '../registration/findings.js';
18
- import { localSourceKey } from '../registration/source-key.js';
19
- import { bindCapturedMaterial, buildLocalSnapshot, type ValidationSnapshot } from '../registration/snapshot.js';
18
+ import { SourceCache } from '../cache/source-cache.js';
19
+ import { localSourceKey, type SourceKey } from '../registration/source-key.js';
20
+ import { bindCapturedMaterial, buildGitSnapshot, buildLocalSnapshot, type ValidationSnapshot } from '../registration/snapshot.js';
20
21
  import { BUDGET } from '../registration/budget.js';
21
22
 
22
23
  export interface InspectedMarketplaceEntry {
@@ -43,6 +44,8 @@ export interface InspectionOptions {
43
44
  baseSnapshot?: ValidationSnapshot;
44
45
  /** Suppress the recorded-snapshot drift Blocking Finding — Refresh compares fingerprints explicitly instead. */
45
46
  ignoreRecordedDrift?: boolean;
47
+ agentDir?: string;
48
+ cache?: SourceCache;
46
49
  }
47
50
 
48
51
  function inspectionFinding(scope: Scope, code: string, rule: string, target: ValidationFinding['target'], outcome: string): ValidationFinding {
@@ -55,11 +58,60 @@ export function inspectMarketplaceEntries(registration: Registration, scope: Sco
55
58
  let root: string;
56
59
  let snapshotResult: { ok: boolean; snapshot?: ValidationSnapshot; findings: ValidationFinding[] };
57
60
  if (override) {
58
- root = opts.root!;
61
+ try {
62
+ root = realpathSync.native(opts.root!);
63
+ } catch {
64
+ root = opts.root!;
65
+ }
59
66
  snapshotResult = { ok: true, snapshot: opts.baseSnapshot, findings: [] };
60
- } else {
61
- if (registration.sourceKind !== 'local' || !registration.source) {
62
- return { entries: [], findings: [inspectionFinding(scope, CODE.SOURCE_REACQUISITION_REQUIRED, RULE.SOURCE_REACQUISITION_REQUIRED, 'registration', 'Git Source Cache lifecycle is not available yet')] };
67
+ } else if (registration.sourceKind === 'git') {
68
+ if (!registration.validationSnapshot) {
69
+ return {
70
+ entries: [],
71
+ findings: [inspectionFinding(scope, CODE.SOURCE_REACQUISITION_REQUIRED, RULE.SOURCE_REACQUISITION_REQUIRED, 'registration', 'Git Registration has no retained Validation Snapshot; re-registration or Marketplace Refresh is required')],
72
+ };
73
+ }
74
+ const cache = opts.cache ?? new SourceCache({ agentDir: opts.agentDir });
75
+ const hit = cache.hitExactSync(registration.validationSnapshot);
76
+ if (!hit) {
77
+ return {
78
+ entries: [],
79
+ findings: [inspectionFinding(scope, CODE.SOURCE_REACQUISITION_REQUIRED, RULE.SOURCE_REACQUISITION_REQUIRED, 'registration', `Git Source Cache miss: Validation Snapshot '${registration.validationSnapshot.slice(0, 16)}…' is not retained in Source Cache; Marketplace Refresh or re-acquisition is required`)],
80
+ };
81
+ }
82
+ try {
83
+ root = realpathSync.native(hit.path);
84
+ } catch {
85
+ root = hit.path;
86
+ }
87
+ const canonicalLocator = registration.canonicalLocator ?? registration.source ?? '';
88
+ const selectorCanonical = registration.gitSelector?.canonical ?? '';
89
+ const resolvedRevision = registration.resolvedRevision ?? '';
90
+ const sourceKey: SourceKey = registration.sourceKey ?? {
91
+ kind: 'git',
92
+ key: `git:${canonicalLocator}#${selectorCanonical}`,
93
+ canonicalUrl: canonicalLocator,
94
+ selector: selectorCanonical,
95
+ resolvedRevision,
96
+ };
97
+ const snapResult = buildGitSnapshot(root, sourceKey, scope, {
98
+ canonicalLocator,
99
+ resolvedRevision,
100
+ selectorCanonical,
101
+ });
102
+ if (!snapResult.ok || !snapResult.snapshot) {
103
+ return { entries: [], findings: snapResult.findings };
104
+ }
105
+ if (snapResult.snapshot.fingerprint !== registration.validationSnapshot) {
106
+ return {
107
+ entries: [],
108
+ findings: [inspectionFinding(scope, CODE.SOURCE_DRIFT, RULE.SOURCE_DRIFT, 'registration', `Source Drift: cached tree at fingerprint ${registration.validationSnapshot.slice(0, 16)}… no longer hashes to the recorded Validation Snapshot; Marketplace Refresh is required`)],
109
+ };
110
+ }
111
+ snapshotResult = snapResult;
112
+ } else if (registration.sourceKind === 'local' || (!registration.sourceKind && registration.source && !registration.canonicalLocator)) {
113
+ if (!registration.source) {
114
+ return { entries: [], findings: [inspectionFinding(scope, CODE.SOURCE_REACQUISITION_REQUIRED, RULE.SOURCE_REACQUISITION_REQUIRED, 'registration', 'Local Registration has no source path')] };
63
115
  }
64
116
  const key = localSourceKey(registration.source);
65
117
  if (!key.ok) return { entries: [], findings: [inspectionFinding(scope, CODE.SOURCE_REACQUISITION_REQUIRED, RULE.SOURCE_REACQUISITION_REQUIRED, 'registration', key.error ?? 'Marketplace Root cannot be revalidated')] };
@@ -74,6 +126,11 @@ export function inspectMarketplaceEntries(registration: Registration, scope: Sco
74
126
  }
75
127
  snapshotResult = buildLocalSnapshot(root, key.sourceKey!, scope);
76
128
  if (!snapshotResult.ok) return { entries: [], findings: snapshotResult.findings };
129
+ } else {
130
+ return {
131
+ entries: [],
132
+ findings: [inspectionFinding(scope, CODE.SOURCE_REACQUISITION_REQUIRED, RULE.SOURCE_REACQUISITION_REQUIRED, 'registration', `Unknown or unsupported sourceKind '${registration.sourceKind}'`)],
133
+ };
77
134
  }
78
135
 
79
136
  const catalogPath = join(root, '.agents', 'plugins', 'marketplace.json');
@@ -125,7 +125,7 @@ export function projectEffectiveState(
125
125
  const findings: ValidationFinding[] = [];
126
126
 
127
127
  const inspections = new Map<string, MarketplaceInspection>();
128
- const inspect = opts.inspectRegistration ?? ((registration: EffectiveRegistration) => inspectMarketplaceEntries(registration, registration.sourceScope));
128
+ const inspect = opts.inspectRegistration ?? ((registration: EffectiveRegistration) => inspectMarketplaceEntries(registration, registration.sourceScope, { agentDir: opts.agentDir }));
129
129
  const inspectionFor = (registration: EffectiveRegistration): MarketplaceInspection => {
130
130
  const key = `${registration.sourceScope}:${registration.id}`;
131
131
  let value = inspections.get(key);
@@ -69,7 +69,12 @@ export function resolveContained(root: string, relPath: string, checkType: 'any'
69
69
  };
70
70
  }
71
71
 
72
- const abs = root + sep + relPath.slice(2);
72
+ let canonicalRoot = root;
73
+ try {
74
+ canonicalRoot = realpathSync.native(root);
75
+ } catch {}
76
+
77
+ const abs = canonicalRoot + sep + relPath.slice(2);
73
78
  let canonical: string;
74
79
  try {
75
80
  canonical = realpathSync.native(abs);
@@ -80,7 +85,7 @@ export function resolveContained(root: string, relPath: string, checkType: 'any'
80
85
  }
81
86
 
82
87
  // containment check
83
- if (!isWithin(root, canonical)) {
88
+ if (!isWithin(canonicalRoot, canonical)) {
84
89
  return {
85
90
  outcome: { kind: 'blocking', blockClass: 'path', reason: `canonical target '${canonical}' escapes owning root '${root}'` },
86
91
  type: 'special',