@tea-agent/loop-agent 0.33.7-beta.0 → 0.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +67 -16
  2. package/dist/application/task-lifecycle/advance.js +309 -9
  3. package/dist/application/task-lifecycle/gates.js +50 -0
  4. package/dist/application/task-lifecycle/observe.js +11 -2
  5. package/dist/application/task-lifecycle/plan-transitions.js +13 -21
  6. package/dist/commands/init-upgrade.js +32 -1
  7. package/dist/commands/init.js +94 -3
  8. package/dist/executors/dag-pi-executor.js +18 -3
  9. package/dist/executors/shell-executor.js +4 -91
  10. package/dist/executors/shell-write-guard.js +26 -8
  11. package/dist/shared/operator/capabilities.js +98 -44
  12. package/dist/shared/resilient-git.js +133 -0
  13. package/dist/task/source-prepare/artifact-meta.js +137 -0
  14. package/dist/task/source-prepare/index.js +2 -0
  15. package/dist/task/source-prepare/parse-intent.js +58 -10
  16. package/dist/task/source-prepare/prepare.js +229 -18
  17. package/dist/task/source-prepare/reference-integrity.js +18 -2
  18. package/dist/task/source-prepare/semantic-intake.js +404 -0
  19. package/dist/worker/console/app-data.js +2 -0
  20. package/dist/worker/console/chat/chat-event-store.js +190 -25
  21. package/dist/worker/console/chat/model-resolver.js +17 -0
  22. package/dist/worker/console/chat/pi-console-config.js +250 -32
  23. package/dist/worker/console/chat/pi-runtime.js +1007 -188
  24. package/dist/worker/console/chat/resource-loader.js +5 -4
  25. package/dist/worker/console/chat/routes.js +495 -157
  26. package/dist/worker/console/chat/runtime-context.js +48 -12
  27. package/dist/worker/console/chat/runtime-selection.js +59 -0
  28. package/dist/worker/console/chat/session-store.js +39 -0
  29. package/dist/worker/console/chat/shortcuts.js +1 -0
  30. package/dist/worker/console/chat/tool-adapter.js +9 -3
  31. package/dist/worker/console/chat/tools.js +5 -1
  32. package/dist/worker/console/dag-execution-receipt.js +380 -0
  33. package/dist/worker/console/interview/grill-me.js +7 -6
  34. package/dist/worker/console/operator-actions.js +785 -85
  35. package/dist/worker/console/prd-intake-bridge.js +393 -0
  36. package/dist/worker/console/recovery-cta.js +35 -6
  37. package/dist/worker/console/server.js +8 -15
  38. package/dist/worker/console/static/assets/index-BQkhJpV8.css +1 -0
  39. package/dist/worker/console/static/assets/index-BpuHmlSP.js +29 -0
  40. package/dist/worker/console/static/index.html +2 -2
  41. package/dist/worker/console/static-src/app/console-types.js +1 -0
  42. package/dist/worker/console/static-src/app/usePrdImport.js +2 -1
  43. package/dist/worker/console/static-src/app/useRecoveryActions.js +24 -1
  44. package/dist/worker/console/static-src/app/useRecoveryConsole.js +19 -1
  45. package/dist/worker/console/static-src/app/useTaskWizard.js +57 -2
  46. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
  47. package/dist/worker/console/static-src/operator-chat/landing-density.js +23 -0
  48. package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
  49. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
  50. package/dist/worker/console/static-src/operator-chat/session-title-watcher.js +128 -0
  51. package/dist/worker/console/static-src/operator-chat/sidebar-split.js +90 -0
  52. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +37 -0
  53. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +125 -22
  54. package/dist/worker/console/static-src/operator-chat/useChatStream.js +215 -184
  55. package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
  56. package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
  57. package/dist/worker/console/static-src/operator-chat/useOverlayFocus.js +84 -0
  58. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
  59. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
  60. package/dist/worker/console/static-src/operator-chat/useWorkspaceLayout.js +58 -0
  61. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +31 -0
  62. package/dist/worker/delivery/final-verification.js +13 -5
  63. package/dist/worker/delivery/package.js +31 -19
  64. package/dist/worker/delivery/verification-bundle.js +6 -4
  65. package/dist/worker/observability/read-model.js +3 -0
  66. package/dist/worker/observe/static/operator-chrome.css +5 -2
  67. package/dist/worker/observe/static/operator-chrome.js +6 -1
  68. package/dist/worker/observe/static/styles.css +39 -9
  69. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +33 -462
  70. package/dist/workflows/dag/backend-test-case-manifest.js +0 -4
  71. package/dist/workflows/dag/backend-test-markdown-workflow.js +1 -25
  72. package/dist/workflows/dag/backend-test-module-stem.js +0 -5
  73. package/dist/workflows/dag/backend-test-pytest-collection.js +24 -345
  74. package/dist/workflows/dag/backend-test-scenario-param.js +82 -269
  75. package/dist/workflows/dag/backend-test-writer-completeness.js +16 -47
  76. package/dist/workflows/dag/dynamic-runtime/map.js +8 -24
  77. package/dist/workflows/dag/failure-category.js +3 -0
  78. package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
  79. package/dist/workflows/dag/init-hybrid.js +49 -55
  80. package/dist/workflows/dag/node-execution.js +3 -2
  81. package/dist/workflows/dag/retry-policy.js +44 -11
  82. package/dist/workflows/dag/runner.js +6 -0
  83. package/dist/workflows/dag/scheduler.js +49 -1
  84. package/dist/workflows/dag/types.js +0 -7
  85. package/dist/workflows/dag/validate.js +16 -2
  86. package/dist/workflows/dag/workspace-checkpoint.js +8 -27
  87. package/docs/templates/agent-dag.schema.json +4 -4
  88. package/docs/templates/backend-test-dag.json +29 -32
  89. package/harness.json +1 -1
  90. package/package.json +1 -1
  91. package/skills/local-jacoco-coverage/SKILL.md +281 -0
  92. package/skills/local-jacoco-coverage/references/requirement-to-source-mapping.md +85 -0
  93. package/skills/local-jacoco-coverage/references/runtime-alignment.md +106 -0
  94. package/skills/local-jacoco-coverage/scripts/run-coverage-analysis.sh +148 -0
  95. package/skills/local-jacoco-coverage/scripts/start-jacoco-agent.sh +110 -0
  96. package/skills/loop-agent/references/command-reference.md +3 -1
  97. package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
  98. package/skills/loop-agent/references/task-workflow.md +4 -0
  99. package/dist/worker/console/chat/instruction-skills.js +0 -217
  100. package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
  101. 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
- const events = parsed.events.slice(-maxEvents);
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
- return {
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
- function persist(sessionId, ring) {
337
- const file = persistedPath(sessionId);
338
- if (!file)
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 event = {
586
+ const at = partial.at ?? new Date().toISOString();
587
+ const live = {
448
588
  schemaVersion: 1,
449
- eventId: `${sessionId}:${seq}`,
589
+ eventId: eventIdFor(sessionId, seq),
450
590
  sessionId,
451
591
  seq,
452
592
  turnId,
453
- at: partial.at ?? new Date().toISOString(),
593
+ at,
454
594
  kind: partial.kind,
455
595
  data: partial.data,
456
596
  };
457
- ring.events.push(event);
458
- while (ring.events.length > ring.maxEvents) {
459
- const dropped = ring.events.shift();
460
- if (dropped)
461
- ring.minSeq = dropped.seq + 1;
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
- if (ring.events.length > 0) {
464
- ring.minSeq = ring.events[0].seq;
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(event);
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 event;
625
+ return live;
479
626
  },
480
- listFrom(sessionId, afterSeq) {
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
- if (ring.events.length > 0 && afterSeq + 1 < ring.minSeq) {
486
- return { error: "CURSOR_EXPIRED", minSeq: ring.minSeq };
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
- return { events: ring.events.filter((e) => e.seq > afterSeq) };
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);
@@ -171,6 +171,23 @@ export function mapSnapshotAvailableModels(snapshot) {
171
171
  * @param available SDK snapshot.available (already mapped to AvailableModelEntry)
172
172
  * @param options.tier harness tier to read (default MED)
173
173
  */
174
+ /** Resolve only the explicitly configured LOW tier; never fall back to MED/HIGH. */
175
+ export async function resolveLowChatModel(repoRoot, available) {
176
+ const pi = await readHarnessExecutorsPi(repoRoot);
177
+ const configured = pi ? normalizeExecutorTierValue(pi.LOW) : undefined;
178
+ if (!configured)
179
+ return undefined;
180
+ const selection = {
181
+ modelId: configured.model,
182
+ ...(configured.provider ? { provider: configured.provider } : {}),
183
+ };
184
+ const entry = findProviderForModelId(selection.modelId, available, {
185
+ preferredProvider: selection.provider,
186
+ });
187
+ if (!entry || entry.hasCredentials === false)
188
+ return undefined;
189
+ return { provider: entry.provider, modelId: selection.modelId, label: entry.name };
190
+ }
174
191
  export async function resolveDefaultChatModel(repoRoot, available, options) {
175
192
  const selection = await readHarnessPiTierSelection(repoRoot, options?.tier ?? "MED");
176
193
  if (!selection)
@@ -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
- const preferencesFile = (appData) => path.join(appData.chats, ".instruction-skill-preferences.json");
114
- export async function readSkillPreferences(appData) {
115
- const value = await readJsonIfExists(preferencesFile(appData));
116
- return (value?.disabledNames ?? []).filter((name) => OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS.includes(name));
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
- export async function writeSkillPreferences(appData, disabledNames) {
119
- const allowed = new Set(OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS);
120
- if (disabledNames.some((name) => !allowed.has(name)))
121
- throw Object.assign(new Error("preferences contain a non-allowed skill"), {
122
- code: "INVALID_INPUT",
123
- status: 400,
124
- });
125
- const normalized = [...new Set(disabledNames)].sort();
126
- await writeSecureJson(preferencesFile(appData), {
127
- schemaVersion: 1,
128
- disabledNames: normalized,
129
- });
130
- return normalized;
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
- return [
145
- ...global.map((spec) => ({
146
- spec,
147
- scope: "global",
148
- enabled: true,
149
- resourceCount: 0,
150
- })),
151
- ...project.map((spec) => ({
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: "project",
356
+ scope,
154
357
  enabled: true,
155
- resourceCount: 0,
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
  }