@tea-agent/loop-agent 0.33.5 → 0.33.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/CHANGELOG.md +33 -0
- package/dist/application/task-lifecycle/advance.js +254 -4
- package/dist/application/task-lifecycle/gates.js +50 -0
- package/dist/application/task-lifecycle/observe.js +11 -2
- package/dist/commands/init-upgrade.js +32 -1
- package/dist/commands/init.js +94 -3
- package/dist/executors/shell-write-guard.js +26 -8
- package/dist/shared/operator/capabilities.js +72 -42
- package/dist/shared/resilient-git.js +133 -0
- package/dist/task/source-prepare/artifact-meta.js +137 -0
- package/dist/task/source-prepare/index.js +2 -0
- package/dist/task/source-prepare/parse-intent.js +58 -10
- package/dist/task/source-prepare/prepare.js +180 -16
- package/dist/task/source-prepare/reference-integrity.js +18 -2
- package/dist/task/source-prepare/semantic-intake.js +404 -0
- package/dist/worker/console/app-data.js +2 -0
- package/dist/worker/console/chat/chat-event-store.js +190 -25
- package/dist/worker/console/chat/pi-console-config.js +250 -32
- package/dist/worker/console/chat/pi-runtime.js +625 -71
- package/dist/worker/console/chat/resource-loader.js +5 -4
- package/dist/worker/console/chat/routes.js +324 -146
- package/dist/worker/console/chat/runtime-context.js +48 -12
- package/dist/worker/console/chat/runtime-selection.js +59 -0
- package/dist/worker/console/chat/shortcuts.js +1 -0
- package/dist/worker/console/chat/tool-adapter.js +9 -3
- package/dist/worker/console/chat/tools.js +5 -1
- package/dist/worker/console/dag-execution-receipt.js +380 -0
- package/dist/worker/console/operator-actions.js +559 -68
- package/dist/worker/console/server.js +8 -15
- package/dist/worker/console/static/assets/index-BUOLppPr.js +28 -0
- package/dist/worker/console/static/assets/index-C1KzazY5.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
- package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
- package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +16 -0
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +210 -184
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
- package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
- package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
- package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
- package/dist/worker/delivery/final-verification.js +13 -5
- package/dist/worker/delivery/package.js +31 -19
- package/dist/worker/delivery/verification-bundle.js +6 -4
- package/dist/worker/observe/static/operator-chrome.css +5 -2
- package/dist/worker/observe/static/operator-chrome.js +6 -1
- package/dist/worker/observe/static/styles.css +39 -9
- package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
- package/dist/workflows/dag/workspace-checkpoint.js +8 -27
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +3 -1
- package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
- package/skills/loop-agent/references/task-workflow.md +4 -0
- package/dist/worker/console/chat/instruction-skills.js +0 -217
- package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
- package/dist/worker/console/static/assets/index-CteJFFL2.js +0 -29
|
@@ -169,7 +169,69 @@ export function projectOperationEventSummary(event) {
|
|
|
169
169
|
.digest("hex"),
|
|
170
170
|
};
|
|
171
171
|
}
|
|
172
|
+
/** Conservative per-event byte contribution (compact JSON length × UTF-8/pretty scale). */
|
|
173
|
+
function byteHint(value) {
|
|
174
|
+
return JSON.stringify(value).length * BYTE_GATE_SCALE;
|
|
175
|
+
}
|
|
176
|
+
function recomputeApproxBytes(ring) {
|
|
177
|
+
let total = BYTE_GATE_SLACK;
|
|
178
|
+
for (const event of ring.events)
|
|
179
|
+
total += byteHint(event);
|
|
180
|
+
for (const turn of ring.turns)
|
|
181
|
+
total += byteHint(turn);
|
|
182
|
+
ring.approxBytes = total;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* D3 eviction priority: drop the single oldest candidate — text updates
|
|
186
|
+
* (`message_update`) first, structural events second, and `message_end` (the
|
|
187
|
+
* terminal full-text event) absolutely last. Falls back to the oldest event
|
|
188
|
+
* (legacy `shift()` order) when only `message_end` remain.
|
|
189
|
+
*/
|
|
190
|
+
function dropOneByPriority(ring) {
|
|
191
|
+
const events = ring.events;
|
|
192
|
+
if (events.length === 0)
|
|
193
|
+
return undefined;
|
|
194
|
+
const textIndex = events.findIndex((event) => event.kind === "message_update");
|
|
195
|
+
if (textIndex !== -1)
|
|
196
|
+
return events.splice(textIndex, 1)[0];
|
|
197
|
+
const structuralIndex = events.findIndex((event) => event.kind !== "message_end");
|
|
198
|
+
if (structuralIndex !== -1)
|
|
199
|
+
return events.splice(structuralIndex, 1)[0];
|
|
200
|
+
return events.shift();
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* D2 coalescing decision: merge an incoming `message_update` into the ring tail
|
|
204
|
+
* only when the tail is also a same-turn `message_update` and the update landed
|
|
205
|
+
* inside the coalescing window. Structural events close the window implicitly
|
|
206
|
+
* (their presence makes the tail check fail), so an
|
|
207
|
+
* update → tool_call → update sequence never merges across the tool boundary.
|
|
208
|
+
* `partial.at` (when present) drives the clock so tests can inject window
|
|
209
|
+
* boundaries deterministically without fake timers.
|
|
210
|
+
*/
|
|
211
|
+
function shouldCoalesce(ring, turnId, partial) {
|
|
212
|
+
if (partial.kind !== "message_update")
|
|
213
|
+
return false;
|
|
214
|
+
const tail = ring.events.at(-1);
|
|
215
|
+
if (!tail || tail.kind !== "message_update")
|
|
216
|
+
return false;
|
|
217
|
+
if (tail.turnId !== turnId)
|
|
218
|
+
return false;
|
|
219
|
+
const nowMs = partial.at ? Date.parse(partial.at) : Date.now();
|
|
220
|
+
if (Number.isNaN(nowMs))
|
|
221
|
+
return false;
|
|
222
|
+
return nowMs - Date.parse(tail.at) <= ring.coalesceWindowMs;
|
|
223
|
+
}
|
|
172
224
|
const DEFAULT_MAX_EVENTS = 2000;
|
|
225
|
+
/** Mirrors pi-web's bounded-ring orientation (2000 events / 10 MiB). */
|
|
226
|
+
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
|
227
|
+
/** Same-turn `message_update` events inside this window coalesce into one persisted event. */
|
|
228
|
+
const DEFAULT_COALESCE_WINDOW_MS = 100;
|
|
229
|
+
/** Conservative per-event scale for the byte pre-check gate (UTF-8 multibyte ≤ 4 bytes/char × pretty-print expansion). */
|
|
230
|
+
const BYTE_GATE_SCALE = 12;
|
|
231
|
+
/** Fixed envelope slack (schema keys, turnCount, nextSeq, minSeq, turns) for the byte pre-check gate. */
|
|
232
|
+
const BYTE_GATE_SLACK = 8192;
|
|
233
|
+
/** Legacy epoch for event files written before M5.1 (no epoch field). */
|
|
234
|
+
const LEGACY_EPOCH = "legacy";
|
|
173
235
|
/** Make a sessionId safe to use as a filename component. */
|
|
174
236
|
function safeSessionId(sessionId) {
|
|
175
237
|
return sessionId.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 180);
|
|
@@ -292,6 +354,9 @@ export function createChatOperationLinker(options) {
|
|
|
292
354
|
}
|
|
293
355
|
export function createChatEventStore(options) {
|
|
294
356
|
const maxEvents = options?.maxEventsPerSession ?? DEFAULT_MAX_EVENTS;
|
|
357
|
+
const maxBytes = options?.maxBytesPerSession ?? DEFAULT_MAX_BYTES;
|
|
358
|
+
const coalesceWindowMs = options?.coalesceWindowMs ?? DEFAULT_COALESCE_WINDOW_MS;
|
|
359
|
+
const eventEpoch = options?.epoch?.trim() || LEGACY_EPOCH;
|
|
295
360
|
const persistenceDir = options?.appData?.chatEvents
|
|
296
361
|
? path.resolve(options.appData.chatEvents)
|
|
297
362
|
: undefined;
|
|
@@ -307,6 +372,12 @@ export function createChatEventStore(options) {
|
|
|
307
372
|
? path.join(persistenceDir, `${safeSessionId(sessionId)}.json`)
|
|
308
373
|
: undefined;
|
|
309
374
|
}
|
|
375
|
+
function eventIdFor(sessionId, seq) {
|
|
376
|
+
// Preserve the pre-M5.1 test/file surface for stores that did not opt in.
|
|
377
|
+
return eventEpoch === LEGACY_EPOCH
|
|
378
|
+
? `${sessionId}:${seq}`
|
|
379
|
+
: `${eventEpoch}:${seq}`;
|
|
380
|
+
}
|
|
310
381
|
function loadRing(sessionId) {
|
|
311
382
|
const file = persistedPath(sessionId);
|
|
312
383
|
if (!file || !existsSync(file))
|
|
@@ -318,26 +389,41 @@ export function createChatEventStore(options) {
|
|
|
318
389
|
!Array.isArray(parsed.events)) {
|
|
319
390
|
throw new Error("invalid persisted chat event ring");
|
|
320
391
|
}
|
|
321
|
-
|
|
392
|
+
// Persisted events are derived recovery facts. On a new Console boot we
|
|
393
|
+
// deliberately remap their ids into this store's generation namespace;
|
|
394
|
+
// clients with an old epoch then reconcile rather than cross generations.
|
|
395
|
+
const events = parsed.events
|
|
396
|
+
.slice(-maxEvents)
|
|
397
|
+
.map((event) => ({
|
|
398
|
+
...event,
|
|
399
|
+
eventId: eventIdFor(sessionId, event.seq),
|
|
400
|
+
}));
|
|
322
401
|
const turns = Array.isArray(parsed.turns) ? parsed.turns : [];
|
|
323
402
|
turnCounts.set(sessionId, parsed.turnCount ?? 0);
|
|
324
|
-
|
|
403
|
+
const ring = {
|
|
325
404
|
events,
|
|
326
405
|
turns,
|
|
327
406
|
nextSeq: Math.max(parsed.nextSeq, (events[events.length - 1]?.seq ?? 0) + 1),
|
|
328
407
|
minSeq: events[0]?.seq ?? parsed.minSeq ?? 1,
|
|
329
408
|
maxEvents,
|
|
409
|
+
maxBytes,
|
|
410
|
+
coalesceWindowMs,
|
|
411
|
+
approxBytes: 0,
|
|
412
|
+
epoch: eventEpoch,
|
|
330
413
|
};
|
|
414
|
+
recomputeApproxBytes(ring);
|
|
415
|
+
// R6: re-check the bounds after a restart — a persisted file may exceed the
|
|
416
|
+
// current limits if it was written by an older config or hand-edited.
|
|
417
|
+
enforceBounds(sessionId, ring);
|
|
418
|
+
return ring;
|
|
331
419
|
}
|
|
332
420
|
catch (error) {
|
|
333
421
|
throw new Error(`invalid persisted chat events ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
334
422
|
}
|
|
335
423
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
return;
|
|
340
|
-
writeSecureJsonSync(file, {
|
|
424
|
+
/** The exact payload written to disk (single source of truth for D4 byte accounting). */
|
|
425
|
+
function ringPayload(sessionId, ring) {
|
|
426
|
+
return {
|
|
341
427
|
schemaVersion: 1,
|
|
342
428
|
sessionId,
|
|
343
429
|
turnCount: turnCounts.get(sessionId) ?? 0,
|
|
@@ -345,7 +431,48 @@ export function createChatEventStore(options) {
|
|
|
345
431
|
events: ring.events,
|
|
346
432
|
nextSeq: ring.nextSeq,
|
|
347
433
|
minSeq: ring.minSeq,
|
|
348
|
-
|
|
434
|
+
epoch: ring.epoch,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
/** Serialized exactly as `writeSecureJsonSync` writes it, so byte accounting
|
|
438
|
+
* (D4) and the on-disk bytes use the same formatting. */
|
|
439
|
+
function serializeRingForPersist(sessionId, ring) {
|
|
440
|
+
return `${JSON.stringify(ringPayload(sessionId, ring), null, 2)}\n`;
|
|
441
|
+
}
|
|
442
|
+
function bytesOfRing(sessionId, ring) {
|
|
443
|
+
return Buffer.byteLength(serializeRingForPersist(sessionId, ring), "utf8");
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Enforce the `maxEvents + maxBytes` dual bound (D3/D4). maxEvents first
|
|
447
|
+
* (count), then bytes against the real serialized payload; every drop removes
|
|
448
|
+
* the oldest eviction candidate by priority. The byte loop is bounded because
|
|
449
|
+
* each iteration removes an event and stops on an empty ring. A conservative
|
|
450
|
+
* pre-check skips the exact serialization for small rings (keeps the frequent
|
|
451
|
+
* per-token append path O(1)-ish).
|
|
452
|
+
*/
|
|
453
|
+
function enforceBounds(sessionId, ring) {
|
|
454
|
+
while (ring.events.length > ring.maxEvents) {
|
|
455
|
+
const dropped = dropOneByPriority(ring);
|
|
456
|
+
if (dropped)
|
|
457
|
+
ring.approxBytes -= byteHint(dropped);
|
|
458
|
+
}
|
|
459
|
+
if (ring.events.length > 0 && ring.approxBytes > ring.maxBytes) {
|
|
460
|
+
while (ring.events.length > 0 &&
|
|
461
|
+
bytesOfRing(sessionId, ring) > ring.maxBytes) {
|
|
462
|
+
const dropped = dropOneByPriority(ring);
|
|
463
|
+
if (dropped)
|
|
464
|
+
ring.approxBytes -= byteHint(dropped);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (ring.events.length > 0) {
|
|
468
|
+
ring.minSeq = ring.events[0].seq;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
function persist(sessionId, ring) {
|
|
472
|
+
const file = persistedPath(sessionId);
|
|
473
|
+
if (!file)
|
|
474
|
+
return;
|
|
475
|
+
writeSecureJsonSync(file, ringPayload(sessionId, ring));
|
|
349
476
|
}
|
|
350
477
|
function ringFor(sessionId) {
|
|
351
478
|
let ring = rings.get(sessionId);
|
|
@@ -356,6 +483,10 @@ export function createChatEventStore(options) {
|
|
|
356
483
|
nextSeq: 1,
|
|
357
484
|
minSeq: 1,
|
|
358
485
|
maxEvents,
|
|
486
|
+
maxBytes,
|
|
487
|
+
coalesceWindowMs,
|
|
488
|
+
approxBytes: BYTE_GATE_SLACK,
|
|
489
|
+
epoch: eventEpoch,
|
|
359
490
|
};
|
|
360
491
|
rings.set(sessionId, ring);
|
|
361
492
|
}
|
|
@@ -407,6 +538,7 @@ export function createChatEventStore(options) {
|
|
|
407
538
|
createdAt: now,
|
|
408
539
|
};
|
|
409
540
|
ring.turns.push(turn);
|
|
541
|
+
ring.approxBytes += byteHint(turn);
|
|
410
542
|
persist(sessionId, ring);
|
|
411
543
|
return { ok: true, turn };
|
|
412
544
|
},
|
|
@@ -416,6 +548,7 @@ export function createChatEventStore(options) {
|
|
|
416
548
|
if (!turn)
|
|
417
549
|
return undefined;
|
|
418
550
|
turn.state = state;
|
|
551
|
+
const before = JSON.stringify(turn).length;
|
|
419
552
|
if (state === "running" && !turn.startedAt)
|
|
420
553
|
turn.startedAt = new Date().toISOString();
|
|
421
554
|
if (state === "settled" || state === "aborted" || state === "failed") {
|
|
@@ -423,6 +556,8 @@ export function createChatEventStore(options) {
|
|
|
423
556
|
}
|
|
424
557
|
if (error)
|
|
425
558
|
turn.error = error;
|
|
559
|
+
ring.approxBytes +=
|
|
560
|
+
(JSON.stringify(turn).length - before) * BYTE_GATE_SCALE;
|
|
426
561
|
persist(sessionId, ring);
|
|
427
562
|
return { ...turn };
|
|
428
563
|
},
|
|
@@ -443,49 +578,79 @@ export function createChatEventStore(options) {
|
|
|
443
578
|
},
|
|
444
579
|
append(sessionId, turnId, partial) {
|
|
445
580
|
const ring = ringFor(sessionId);
|
|
581
|
+
// D1: every append (coalesced or not) consumes a fresh live seq so the
|
|
582
|
+
// live stream stays fully dense (AC-006); the persisted ring may collapse
|
|
583
|
+
// text updates, but nextSeq is always persisted alongside it so a restart
|
|
584
|
+
// never re-issues a seq a live client already saw.
|
|
446
585
|
const seq = ring.nextSeq++;
|
|
447
|
-
const
|
|
586
|
+
const at = partial.at ?? new Date().toISOString();
|
|
587
|
+
const live = {
|
|
448
588
|
schemaVersion: 1,
|
|
449
|
-
eventId:
|
|
589
|
+
eventId: eventIdFor(sessionId, seq),
|
|
450
590
|
sessionId,
|
|
451
591
|
seq,
|
|
452
592
|
turnId,
|
|
453
|
-
at
|
|
593
|
+
at,
|
|
454
594
|
kind: partial.kind,
|
|
455
595
|
data: partial.data,
|
|
456
596
|
};
|
|
457
|
-
ring
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
597
|
+
if (shouldCoalesce(ring, turnId, partial)) {
|
|
598
|
+
// AC-001: same-turn in-window text update replaces the persisted tail in
|
|
599
|
+
// place (keeping its original seq/eventId/turnId); only the text and `at`
|
|
600
|
+
// slide forward. The fresh live event is still returned/broadcast below.
|
|
601
|
+
const tail = ring.events.at(-1);
|
|
602
|
+
const before = JSON.stringify(tail).length;
|
|
603
|
+
tail.data = live.data;
|
|
604
|
+
tail.at = live.at;
|
|
605
|
+
ring.approxBytes +=
|
|
606
|
+
(JSON.stringify(tail).length - before) * BYTE_GATE_SCALE;
|
|
462
607
|
}
|
|
463
|
-
|
|
464
|
-
ring.
|
|
608
|
+
else {
|
|
609
|
+
ring.events.push(live);
|
|
610
|
+
ring.approxBytes += byteHint(live);
|
|
465
611
|
}
|
|
612
|
+
enforceBounds(sessionId, ring);
|
|
466
613
|
persist(sessionId, ring);
|
|
467
614
|
const subs = subscribers.get(sessionId);
|
|
468
615
|
if (subs) {
|
|
469
616
|
for (const fn of subs) {
|
|
470
617
|
try {
|
|
471
|
-
fn(
|
|
618
|
+
fn(live);
|
|
472
619
|
}
|
|
473
620
|
catch {
|
|
474
621
|
// a subscriber throwing must never break the append path
|
|
475
622
|
}
|
|
476
623
|
}
|
|
477
624
|
}
|
|
478
|
-
return
|
|
625
|
+
return live;
|
|
479
626
|
},
|
|
480
|
-
listFrom(sessionId,
|
|
627
|
+
listFrom(sessionId, cursor) {
|
|
481
628
|
const ring = rings.get(sessionId) ?? loadRing(sessionId);
|
|
482
629
|
if (!ring)
|
|
483
|
-
return { events: [] };
|
|
630
|
+
return { events: [], epoch: eventEpoch };
|
|
484
631
|
rings.set(sessionId, ring);
|
|
485
|
-
|
|
486
|
-
|
|
632
|
+
const normalized = typeof cursor === "number" ? { seq: cursor, epoch: null } : cursor;
|
|
633
|
+
if (normalized.epoch && normalized.epoch !== ring.epoch) {
|
|
634
|
+
return {
|
|
635
|
+
error: "CURSOR_EPOCH_MISMATCH",
|
|
636
|
+
minSeq: ring.minSeq,
|
|
637
|
+
epoch: ring.epoch,
|
|
638
|
+
};
|
|
487
639
|
}
|
|
488
|
-
|
|
640
|
+
if (ring.events.length > 0 && normalized.seq + 1 < ring.minSeq) {
|
|
641
|
+
return {
|
|
642
|
+
error: "CURSOR_EXPIRED",
|
|
643
|
+
minSeq: ring.minSeq,
|
|
644
|
+
epoch: ring.epoch,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
events: ring.events.filter((event) => event.seq > normalized.seq),
|
|
649
|
+
epoch: ring.epoch,
|
|
650
|
+
};
|
|
651
|
+
},
|
|
652
|
+
epoch(sessionId) {
|
|
653
|
+
return ringFor(sessionId).epoch;
|
|
489
654
|
},
|
|
490
655
|
snapshot(sessionId) {
|
|
491
656
|
const ring = rings.get(sessionId) ?? loadRing(sessionId);
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { open, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { readJsonIfExists, writeSecureJson } from "../app-data.js";
|
|
5
|
-
import { OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS } from "./instruction-skills.js";
|
|
6
4
|
const revision = (raw) => createHash("sha256").update(raw).digest("hex");
|
|
7
5
|
export async function readModelsConfig(agentDir) {
|
|
8
6
|
let raw = "{}";
|
|
@@ -110,26 +108,219 @@ export async function patchModelsConfig(agentDir, input) {
|
|
|
110
108
|
await rename(tmp, file);
|
|
111
109
|
return readModelsConfig(agentDir);
|
|
112
110
|
}
|
|
113
|
-
|
|
114
|
-
export
|
|
115
|
-
|
|
116
|
-
|
|
111
|
+
/** Max bytes of a package.json we are willing to read (read-only, bounded). */
|
|
112
|
+
export const MAX_PACKAGE_JSON_BYTES = 64 * 1024;
|
|
113
|
+
/** Max directory levels walked upward from an owned resolved resource path. */
|
|
114
|
+
export const MAX_PACKAGE_JSON_WALK_DEPTH = 24;
|
|
115
|
+
/**
|
|
116
|
+
* One npm-style range token: optional comparator/v-prefix followed by an
|
|
117
|
+
* exact/x-form version, a wildcard, or a prerelease/build suffix. Accepts
|
|
118
|
+
* >=2.0.0, <3.0.0, ^2.1.0, ~1.2.3, ~1, 1.2, 1.x, 1.2.x, 1.2.3-beta.1+build.5, *.
|
|
119
|
+
*/
|
|
120
|
+
const SPEC_RANGE_TOKEN = "(?:>=|<=|[<>=~^*v])?(?:\\d+(?:\\.(?:\\d+|x|X)){0,2}(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?|\\*|x|X)";
|
|
121
|
+
/** Whitespace-separated comparator list, e.g. ">=2.0.0 <3.0.0". */
|
|
122
|
+
const SPEC_RANGE = new RegExp(`^${SPEC_RANGE_TOKEN}(?:\\s+${SPEC_RANGE_TOKEN})*$`);
|
|
123
|
+
/**
|
|
124
|
+
* Installed-side grammar: an EXACT semver with optional prerelease and build
|
|
125
|
+
* metadata (1.2.3, 1.2.3-beta.1, 1.2.3+build.5, 1.2.3-beta.1+build.5). Ranges
|
|
126
|
+
* and partial shapes (*, 1.x, ~1, 1.2) are rejected with a field diagnostic.
|
|
127
|
+
*/
|
|
128
|
+
const INSTALLED_VERSION = /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
129
|
+
/**
|
|
130
|
+
* Try to derive the explicit version/range from an npm-style spec — verbatim
|
|
131
|
+
* projection, zero resolution and zero install (AC-RF-1). Git/URL shapes
|
|
132
|
+
* (containing /, #, :// or git+) never project a configuredVersion.
|
|
133
|
+
*/
|
|
134
|
+
function versionFromSpec(spec) {
|
|
135
|
+
// npm:name@1.2.3 / name@^1.2 / @scope/name@1.2.3 — never guess on git/url specs.
|
|
136
|
+
const match = spec.match(/^(?:npm:)?(@?[^@/\s]+(?:\/[^@\s]+)?)@(.+)$/);
|
|
137
|
+
if (!match)
|
|
138
|
+
return undefined;
|
|
139
|
+
const version = match[2];
|
|
140
|
+
if (/[\/#:]|git\+/.test(version))
|
|
141
|
+
return undefined;
|
|
142
|
+
return SPEC_RANGE.test(version) ? version : undefined;
|
|
117
143
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
144
|
+
/**
|
|
145
|
+
* Bounded read of a package.json: open + single read capped at
|
|
146
|
+
* MAX_PACKAGE_JSON_BYTES+1 so an oversized file is detected without
|
|
147
|
+
* buffering the whole file, then JSON.parse with root-object validation.
|
|
148
|
+
*/
|
|
149
|
+
async function readPackageJsonBounded(file) {
|
|
150
|
+
let handle;
|
|
151
|
+
try {
|
|
152
|
+
handle = await open(file, "r");
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
const code = error.code;
|
|
156
|
+
if (code === "ENOENT")
|
|
157
|
+
return { ok: false, missing: true, diagnostic: "" };
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
diagnostic: `PACKAGE_JSON_READ_FAILED: ${file} (${error instanceof Error ? error.message : String(error)})`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const buffer = Buffer.alloc(MAX_PACKAGE_JSON_BYTES + 1);
|
|
165
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
166
|
+
if (bytesRead > MAX_PACKAGE_JSON_BYTES) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
diagnostic: `PACKAGE_JSON_TOO_LARGE: ${file} (exceeds ${MAX_PACKAGE_JSON_BYTES} bytes)`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const raw = buffer.subarray(0, bytesRead).toString("utf8");
|
|
173
|
+
let parsed;
|
|
174
|
+
try {
|
|
175
|
+
parsed = JSON.parse(raw);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
diagnostic: `PACKAGE_JSON_INVALID: ${file} (${error instanceof Error ? error.message : String(error)})`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
diagnostic: `PACKAGE_JSON_INVALID: ${file} (root must be a JSON object)`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const record = parsed;
|
|
190
|
+
return { ok: true, value: { name: record.name, version: record.version } };
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
await handle.close().catch(() => { });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Owning npm package root for a resolved entry directory: the deepest
|
|
198
|
+
* ancestor BELOW the nearest node_modules directory (scoped:
|
|
199
|
+
* node_modules/@scope/<pkg>). Returns undefined when the entry is not inside
|
|
200
|
+
* any node_modules boundary — readOwnedPackageJson then degrades with
|
|
201
|
+
* PACKAGE_JSON_NOT_FOUND instead of walking to an ancestor manifest.
|
|
202
|
+
*/
|
|
203
|
+
function packageBoundaryRoot(startDir) {
|
|
204
|
+
let dir = startDir;
|
|
205
|
+
for (let depth = 0; depth < MAX_PACKAGE_JSON_WALK_DEPTH; depth += 1) {
|
|
206
|
+
if (path.basename(dir) === "node_modules") {
|
|
207
|
+
const segments = path
|
|
208
|
+
.relative(dir, startDir)
|
|
209
|
+
.split(path.sep)
|
|
210
|
+
.filter((segment) => segment.length > 0);
|
|
211
|
+
if (segments.length === 0)
|
|
212
|
+
return dir;
|
|
213
|
+
if (segments[0].startsWith("@") && segments.length >= 2) {
|
|
214
|
+
return path.join(dir, segments[0], segments[1]);
|
|
215
|
+
}
|
|
216
|
+
return path.join(dir, segments[0]);
|
|
217
|
+
}
|
|
218
|
+
const parent = path.dirname(dir);
|
|
219
|
+
if (parent === dir)
|
|
220
|
+
return undefined;
|
|
221
|
+
dir = parent;
|
|
222
|
+
}
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Walk UP from an owned resolved resource path (bounded, separator-normalized)
|
|
227
|
+
* to the NEAREST package.json within the owning npm package boundary and
|
|
228
|
+
* project its validated metadata. The walk NEVER crosses the package root
|
|
229
|
+
* (node_modules/<pkg> or node_modules/@scope/<pkg>): a missing manifest there
|
|
230
|
+
* yields PACKAGE_JSON_NOT_FOUND instead of mis-attributing an ancestor
|
|
231
|
+
* workspace manifest (AC-RF-3). Missing name/version fields yield per-field
|
|
232
|
+
* PACKAGE_JSON_FIELD_INVALID diagnostics and suppress the whole metadata trio
|
|
233
|
+
* (AC-RF-4). Corrupt / oversized / invalid-field reads stop at that nearest
|
|
234
|
+
* package.json with a per-package diagnostic. Empty paths never probe the
|
|
235
|
+
* filesystem.
|
|
236
|
+
*/
|
|
237
|
+
async function readOwnedPackageJson(entryPath) {
|
|
238
|
+
if (!entryPath)
|
|
239
|
+
return { ok: false, diagnostics: [] };
|
|
240
|
+
const normalized = entryPath.replace(/[\\/]+/g, path.sep);
|
|
241
|
+
const startDir = path.dirname(normalized);
|
|
242
|
+
const boundaryRoot = packageBoundaryRoot(startDir);
|
|
243
|
+
if (boundaryRoot === undefined) {
|
|
244
|
+
// No legal node_modules/<pkg> or node_modules/@scope/<pkg> boundary:
|
|
245
|
+
// never walk to (or accept) an ancestor manifest (AC-B1/AC-B2).
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
diagnostics: [
|
|
249
|
+
`PACKAGE_JSON_NOT_FOUND: ${startDir} (resolved resource is not inside a legal node_modules package boundary)`,
|
|
250
|
+
],
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
let dir = startDir;
|
|
254
|
+
for (let depth = 0; depth < MAX_PACKAGE_JSON_WALK_DEPTH; depth += 1) {
|
|
255
|
+
const file = path.join(dir, "package.json");
|
|
256
|
+
const read = await readPackageJsonBounded(file);
|
|
257
|
+
if (read.ok) {
|
|
258
|
+
const { name, version } = read.value;
|
|
259
|
+
const meta = { installedPath: dir };
|
|
260
|
+
const fieldDiagnostics = [];
|
|
261
|
+
if (name === undefined) {
|
|
262
|
+
fieldDiagnostics.push(`PACKAGE_JSON_FIELD_INVALID: ${file} (missing name)`);
|
|
263
|
+
}
|
|
264
|
+
else if (typeof name !== "string" ||
|
|
265
|
+
name.trim().length === 0 ||
|
|
266
|
+
name.length > 214) {
|
|
267
|
+
fieldDiagnostics.push(`PACKAGE_JSON_FIELD_INVALID: ${file} (name must be a non-empty string ≤ 214 chars)`);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
meta.packageName = name;
|
|
271
|
+
}
|
|
272
|
+
if (version === undefined) {
|
|
273
|
+
fieldDiagnostics.push(`PACKAGE_JSON_FIELD_INVALID: ${file} (missing version)`);
|
|
274
|
+
}
|
|
275
|
+
else if (typeof version !== "string" || !INSTALLED_VERSION.test(version)) {
|
|
276
|
+
// Type + format validation only: the browser projection (80-char limit)
|
|
277
|
+
// is the single truncation authority for versions (AC-8 reuse chain).
|
|
278
|
+
fieldDiagnostics.push(`PACKAGE_JSON_FIELD_INVALID: ${file} (version must be an exact semver string)`);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
meta.version = version;
|
|
282
|
+
}
|
|
283
|
+
if (name === undefined || version === undefined) {
|
|
284
|
+
// A manifest missing a required field cannot prove package ownership:
|
|
285
|
+
// none of packageName/version/installedPath is projected (AC-RF-4).
|
|
286
|
+
return { ok: false, diagnostics: fieldDiagnostics };
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
ok: true,
|
|
290
|
+
meta,
|
|
291
|
+
...(fieldDiagnostics.length > 0
|
|
292
|
+
? { diagnostics: fieldDiagnostics }
|
|
293
|
+
: {}),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
// Missing file (ENOENT): keep walking up the owned chain, but never past
|
|
297
|
+
// the owning package root. Any other read/parse failure means the nearest
|
|
298
|
+
// package.json IS the package boundary — degrade only this package and
|
|
299
|
+
// stop (AC-5).
|
|
300
|
+
if (read.missing !== true) {
|
|
301
|
+
return { ok: false, diagnostics: [read.diagnostic] };
|
|
302
|
+
}
|
|
303
|
+
if (boundaryRoot !== undefined && dir === boundaryRoot) {
|
|
304
|
+
return {
|
|
305
|
+
ok: false,
|
|
306
|
+
diagnostics: [
|
|
307
|
+
`PACKAGE_JSON_NOT_FOUND: ${dir} (no package.json within the owning npm package boundary)`,
|
|
308
|
+
],
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const parent = path.dirname(dir);
|
|
312
|
+
if (parent === dir)
|
|
313
|
+
break;
|
|
314
|
+
dir = parent;
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
diagnostics: [
|
|
319
|
+
`PACKAGE_JSON_NOT_FOUND: ${startDir} (no package.json on the resolved resource chain)`,
|
|
320
|
+
],
|
|
321
|
+
};
|
|
131
322
|
}
|
|
132
|
-
export async function readPackageInventory(agentDir, repoRoot) {
|
|
323
|
+
export async function readPackageInventory(agentDir, repoRoot, resolved) {
|
|
133
324
|
const read = async (file) => {
|
|
134
325
|
try {
|
|
135
326
|
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
@@ -141,18 +332,45 @@ export async function readPackageInventory(agentDir, repoRoot) {
|
|
|
141
332
|
};
|
|
142
333
|
const global = await read(path.join(agentDir, "settings.json"));
|
|
143
334
|
const project = await read(path.join(repoRoot, ".pi", "settings.json"));
|
|
144
|
-
|
|
145
|
-
...global.map((spec) => ({
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
335
|
+
const specs = [
|
|
336
|
+
...global.map((spec) => ({ spec, scope: "global" })),
|
|
337
|
+
...project.map((spec) => ({ spec, scope: "project" })),
|
|
338
|
+
];
|
|
339
|
+
const entries = [];
|
|
340
|
+
for (const { spec, scope } of specs) {
|
|
341
|
+
// A resource belongs to this package when its source matches the spec
|
|
342
|
+
// exactly or ends with it (e.g. package subpath sources).
|
|
343
|
+
const owned = (resolved ?? []).filter((item) => item.source === spec || item.source.endsWith(`/${spec}`));
|
|
344
|
+
const diagnostics = owned
|
|
345
|
+
.map((item) => item.scope === "temporary" ? `temporary scope: ${item.path}` : "")
|
|
346
|
+
.filter(Boolean);
|
|
347
|
+
// No owned resources → no filesystem probing at all (AC-1): the optional
|
|
348
|
+
// metadata fields stay absent and only the always-projected cwd is added.
|
|
349
|
+
const meta = owned.length > 0 && owned[0]?.path
|
|
350
|
+
? await readOwnedPackageJson(owned[0].path)
|
|
351
|
+
: { ok: true, meta: undefined, diagnostics: [] };
|
|
352
|
+
if (meta.diagnostics)
|
|
353
|
+
diagnostics.push(...meta.diagnostics);
|
|
354
|
+
entries.push({
|
|
152
355
|
spec,
|
|
153
|
-
scope
|
|
356
|
+
scope,
|
|
154
357
|
enabled: true,
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
358
|
+
cwd: repoRoot,
|
|
359
|
+
...(versionFromSpec(spec)
|
|
360
|
+
? { configuredVersion: versionFromSpec(spec) }
|
|
361
|
+
: {}),
|
|
362
|
+
...(meta.ok && meta.meta?.packageName
|
|
363
|
+
? { packageName: meta.meta.packageName }
|
|
364
|
+
: {}),
|
|
365
|
+
...(meta.ok && meta.meta?.version ? { version: meta.meta.version } : {}),
|
|
366
|
+
...(meta.ok && meta.meta?.installedPath
|
|
367
|
+
? { installedPath: meta.meta.installedPath }
|
|
368
|
+
: {}),
|
|
369
|
+
...(owned[0]?.path ? { path: owned[0].path } : {}),
|
|
370
|
+
resolved: owned,
|
|
371
|
+
resourceCount: owned.length,
|
|
372
|
+
diagnostics,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
return entries;
|
|
158
376
|
}
|