@tea-agent/loop-agent 0.34.2 → 0.34.4

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 (29) hide show
  1. package/AGENTS.md +7 -2
  2. package/CHANGELOG.md +65 -22
  3. package/dist/application/task-lifecycle/advance.js +5 -1
  4. package/dist/task/source-prepare/index.js +1 -0
  5. package/dist/task/source-prepare/placeholder-paths.js +77 -0
  6. package/dist/task/source-prepare/semantic-intake.js +192 -27
  7. package/dist/worker/console/app-data.js +2 -0
  8. package/dist/worker/console/chat/chat-event-store.js +85 -3
  9. package/dist/worker/console/chat/pi-runtime.js +230 -62
  10. package/dist/worker/console/chat/resource-preferences-store.js +152 -0
  11. package/dist/worker/console/chat/routes.js +401 -18
  12. package/dist/worker/console/chat/turn-execution-registry.js +82 -0
  13. package/dist/worker/console/dag-execution-receipt.js +14 -1
  14. package/dist/worker/console/prd-intake-bridge.js +51 -10
  15. package/dist/worker/console/server.js +4 -0
  16. package/dist/worker/console/static/assets/index-B6Qdbk8V.js +29 -0
  17. package/dist/worker/console/static/assets/index-Bt0NUxcQ.css +1 -0
  18. package/dist/worker/console/static/index.html +2 -2
  19. package/dist/worker/console/static-src/operator-chat/refs.js +24 -0
  20. package/dist/worker/console/static-src/operator-chat/resource-auto-invocation.js +91 -0
  21. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +690 -0
  22. package/dist/worker/console/static-src/operator-chat/turn-submission.js +158 -0
  23. package/dist/worker/console/static-src/operator-chat/useChatStream.js +535 -86
  24. package/dist/workflows/dag/init-hybrid.js +2 -0
  25. package/docs/operations/local-development-environment.md +4 -2
  26. package/docs/templates/branch-merge-report.md +9 -0
  27. package/package.json +3 -2
  28. package/dist/worker/console/static/assets/index-BQkhJpV8.css +0 -1
  29. package/dist/worker/console/static/assets/index-BpuHmlSP.js +0 -29
@@ -221,6 +221,20 @@ function shouldCoalesce(ring, turnId, partial) {
221
221
  return false;
222
222
  return nowMs - Date.parse(tail.at) <= ring.coalesceWindowMs;
223
223
  }
224
+ /** Stable hash of the normalized prompt payload for clientRequestId binding. */
225
+ export function hashChatTurnPayload(input) {
226
+ const normalized = {
227
+ text: input.text,
228
+ images: (input.images ?? []).map((image) => ({
229
+ type: image.type ?? "image",
230
+ mimeType: image.mimeType ?? "",
231
+ data: image.data ?? "",
232
+ })),
233
+ };
234
+ return createHash("sha256")
235
+ .update(JSON.stringify(normalized))
236
+ .digest("hex");
237
+ }
224
238
  const DEFAULT_MAX_EVENTS = 2000;
225
239
  /** Mirrors pi-web's bounded-ring orientation (2000 events / 10 MiB). */
226
240
  const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
@@ -399,6 +413,24 @@ export function createChatEventStore(options) {
399
413
  eventId: eventIdFor(sessionId, event.seq),
400
414
  }));
401
415
  const turns = Array.isArray(parsed.turns) ? parsed.turns : [];
416
+ // New Console epoch: orphan any queued/running turn whose ownerEpoch is
417
+ // missing or mismatched. Releases the active lease without pretending the
418
+ // user aborted (CHAT_TURN_OWNER_LOST fence).
419
+ let orphaned = false;
420
+ const now = new Date().toISOString();
421
+ for (const turn of turns) {
422
+ if (turn.state !== "queued" && turn.state !== "running")
423
+ continue;
424
+ if (turn.ownerEpoch === eventEpoch)
425
+ continue;
426
+ turn.state = "failed";
427
+ turn.finishedAt = now;
428
+ turn.error = {
429
+ code: "CHAT_TURN_OWNER_LOST",
430
+ message: "turn ownership lost after Console restart; previous execution is no longer owned",
431
+ };
432
+ orphaned = true;
433
+ }
402
434
  turnCounts.set(sessionId, parsed.turnCount ?? 0);
403
435
  const ring = {
404
436
  events,
@@ -415,6 +447,8 @@ export function createChatEventStore(options) {
415
447
  // R6: re-check the bounds after a restart — a persisted file may exceed the
416
448
  // current limits if it was written by an older config or hand-edited.
417
449
  enforceBounds(sessionId, ring);
450
+ if (orphaned)
451
+ persist(sessionId, ring);
418
452
  return ring;
419
453
  }
420
454
  catch (error) {
@@ -521,11 +555,35 @@ export function createChatEventStore(options) {
521
555
  }
522
556
  return { copiedCount };
523
557
  },
524
- createTurn(sessionId) {
558
+ createTurn(sessionId, input) {
525
559
  const ring = ringFor(sessionId);
560
+ const clientRequestId = input?.clientRequestId?.trim() || undefined;
561
+ const payloadHash = input?.payloadHash?.trim() || undefined;
562
+ if (clientRequestId) {
563
+ const existing = ring.turns.find((turn) => turn.clientRequestId === clientRequestId);
564
+ if (existing) {
565
+ if (payloadHash &&
566
+ existing.payloadHash &&
567
+ existing.payloadHash !== payloadHash) {
568
+ return {
569
+ ok: false,
570
+ code: "REQUEST_ID_REUSE_CONFLICT",
571
+ message: `clientRequestId ${clientRequestId} was reused with a different payload`,
572
+ existingTurn: { ...existing },
573
+ };
574
+ }
575
+ // Same id + same (or missing) payload: idempotent return, no new lease.
576
+ return {
577
+ ok: true,
578
+ turn: { ...existing },
579
+ created: false,
580
+ idempotent: true,
581
+ };
582
+ }
583
+ }
526
584
  const active = ring.turns.find((turn) => turn.state === "queued" || turn.state === "running");
527
585
  if (active)
528
- return { ok: false, code: "TURN_ACTIVE", activeTurn: active };
586
+ return { ok: false, code: "TURN_ACTIVE", activeTurn: { ...active } };
529
587
  const ordinal = (turnCounts.get(sessionId) ?? 0) + 1;
530
588
  turnCounts.set(sessionId, ordinal);
531
589
  const now = new Date().toISOString();
@@ -536,11 +594,14 @@ export function createChatEventStore(options) {
536
594
  ordinal,
537
595
  state: "queued",
538
596
  createdAt: now,
597
+ ownerEpoch: eventEpoch,
598
+ ...(clientRequestId ? { clientRequestId } : {}),
599
+ ...(payloadHash ? { payloadHash } : {}),
539
600
  };
540
601
  ring.turns.push(turn);
541
602
  ring.approxBytes += byteHint(turn);
542
603
  persist(sessionId, ring);
543
- return { ok: true, turn };
604
+ return { ok: true, turn: { ...turn }, created: true, idempotent: false };
544
605
  },
545
606
  setTurnState(sessionId, turnId, state, error) {
546
607
  const ring = ringFor(sessionId);
@@ -561,6 +622,27 @@ export function createChatEventStore(options) {
561
622
  persist(sessionId, ring);
562
623
  return { ...turn };
563
624
  },
625
+ markAbortRequested(sessionId, turnId, at) {
626
+ const ring = ringFor(sessionId);
627
+ const turn = ring.turns.find((candidate) => candidate.turnId === turnId);
628
+ if (!turn)
629
+ return undefined;
630
+ const before = JSON.stringify(turn).length;
631
+ if (!turn.abortRequestedAt)
632
+ turn.abortRequestedAt = at ?? new Date().toISOString();
633
+ ring.approxBytes +=
634
+ (JSON.stringify(turn).length - before) * BYTE_GATE_SCALE;
635
+ persist(sessionId, ring);
636
+ return { ...turn };
637
+ },
638
+ findTurnByClientRequestId(sessionId, clientRequestId) {
639
+ const id = clientRequestId.trim();
640
+ if (!id)
641
+ return undefined;
642
+ const ring = ringFor(sessionId);
643
+ const turn = ring.turns.find((candidate) => candidate.clientRequestId === id);
644
+ return turn ? { ...turn } : undefined;
645
+ },
564
646
  getTurn(sessionId, turnId) {
565
647
  const ring = ringFor(sessionId);
566
648
  const turn = ring.turns.find((candidate) => candidate.turnId === turnId);
@@ -32,6 +32,7 @@ import { projectCompactSnapshot, } from "./chat-event-store.js";
32
32
  import { extractUsageSample } from "./usage.js";
33
33
  import { OPERATOR_CHAT_ALLOWED_TOOLS, authorizeOperatorChatTool, assertNoWriteToolInList, } from "./tools.js";
34
34
  import { createOperatorChatResourceLoader, } from "./resource-loader.js";
35
+ import { applySkillAutoInvocationPreferences, isAutoInvocationEnabled, loadResourcePreferences, skillResourceId, } from "./resource-preferences-store.js";
35
36
  import { buildModelCallableToolSchemas } from "./tool-adapter.js";
36
37
  import { resolveDefaultChatModel, resolveLowChatModel, } from "./model-resolver.js";
37
38
  import { filterActiveInterviewTools } from "../interview/tools.js";
@@ -466,7 +467,8 @@ export function projectRuntimeSnapshot(input) {
466
467
  ...(skill.baseDir ? { baseDir: field(skill.baseDir, 400) } : {}),
467
468
  scope: skill.scope,
468
469
  source: field(skill.source, 200) ?? "",
469
- enabled: skill.enabled,
470
+ resourceId: field(skill.resourceId, 200) ?? "",
471
+ autoInvocationEnabled: skill.autoInvocationEnabled,
470
472
  diagnostics: skill.diagnostics.map((diag) => field(diag, 400) ?? ""),
471
473
  });
472
474
  const projectExtension = (ext) => ({
@@ -645,6 +647,8 @@ export class ConsolePiRuntime {
645
647
  revisions = new Map();
646
648
  /** RF-01: per-session reload in-flight lock (concurrent second call → PI_SESSION_BUSY). */
647
649
  reloadInFlight = new Set();
650
+ /** Synchronous prompt ownership per session (admission before first await). */
651
+ promptOwners = new Set();
648
652
  /** At most one detached automatic-title task per durable Console session. */
649
653
  titleInFlight = new Set();
650
654
  /** Composer thinking selection ("auto" = no explicit override). */
@@ -665,6 +669,78 @@ export class ConsolePiRuntime {
665
669
  this.loader = createOperatorChatResourceLoader();
666
670
  this.bindings = options.bindings ?? createDefaultPiSdkBindings();
667
671
  }
672
+ /** Absolute path for repo-scoped resource preferences (may be undefined in unit tests). */
673
+ get resourcePreferencesPath() {
674
+ return this.options.resourcePreferencesPath;
675
+ }
676
+ /**
677
+ * Load repo-scoped skill auto-invocation preferences (fail closed on malformed).
678
+ * Missing path / missing file → empty prefs (all skills auto-invocation default on).
679
+ */
680
+ async loadSkillAutoInvocationPreferences() {
681
+ const filePath = this.options.resourcePreferencesPath;
682
+ if (!filePath) {
683
+ const empty = { schemaVersion: 1, skills: {} };
684
+ this.cachedSkillPrefs = empty;
685
+ return empty;
686
+ }
687
+ const prefs = await loadResourcePreferences(filePath);
688
+ this.cachedSkillPrefs = prefs;
689
+ return prefs;
690
+ }
691
+ /**
692
+ * Build resourceLoaderOptions that inject skillsOverride from stored prefs.
693
+ * Skills remain in inventory; only disableModelInvocation is forced for
694
+ * auto-invocation-off preferences. Manual /skill:name is preserved.
695
+ *
696
+ * The override re-reads preferences on EVERY invocation so session.reload()
697
+ * after a PATCH sees the latest resource-preferences.json (not create-time
698
+ * closure state).
699
+ */
700
+ async buildResourceLoaderOptions(base = {}) {
701
+ const runtime = this;
702
+ return {
703
+ ...base,
704
+ skillsOverride: (current) => {
705
+ // Synchronous override surface: use last-known prefs via a sync
706
+ // fail-closed path. Prefer async cache filled by ensurePrefs.
707
+ const prefs = runtime.cachedSkillPrefs ?? {
708
+ schemaVersion: 1,
709
+ skills: {},
710
+ };
711
+ return {
712
+ skills: applySkillAutoInvocationPreferences(current.skills, prefs),
713
+ diagnostics: current.diagnostics,
714
+ };
715
+ },
716
+ };
717
+ }
718
+ /** Last successfully loaded skill prefs (sync override + snapshot projection). */
719
+ cachedSkillPrefs;
720
+ /** Refresh cached prefs before materialization / reload so override sees latest. */
721
+ async refreshSkillPrefsCache() {
722
+ const prefs = await this.loadSkillAutoInvocationPreferences();
723
+ this.cachedSkillPrefs = prefs;
724
+ return prefs;
725
+ }
726
+ /**
727
+ * Resolve a skill in the current session inventory by server-issued resourceId.
728
+ * Returns undefined when the id is unknown (caller maps to PI_SKILL_NOT_FOUND).
729
+ */
730
+ async findSkillByResourceId(sessionId, resourceId) {
731
+ const snapshot = await this.getRuntimeSnapshot(sessionId);
732
+ if (!snapshot)
733
+ return undefined;
734
+ const skill = snapshot.skills.find((entry) => entry.resourceId === resourceId);
735
+ if (!skill)
736
+ return undefined;
737
+ return {
738
+ resourceId: skill.resourceId,
739
+ name: skill.name,
740
+ path: skill.path,
741
+ autoInvocationEnabled: skill.autoInvocationEnabled,
742
+ };
743
+ }
668
744
  get sessionDir() {
669
745
  return this.options.sessionDir;
670
746
  }
@@ -820,13 +896,15 @@ export class ConsolePiRuntime {
820
896
  ...(init.systemPromptSuffix ? [init.systemPromptSuffix] : []),
821
897
  ];
822
898
  // Single services construction for formal materialization (+ optional
823
- // default-model resolve from the same modelRuntime).
899
+ // default-model resolve from the same modelRuntime). Repo-scoped skill
900
+ // auto-invocation prefs inject skillsOverride without touching SKILL.md.
901
+ await this.refreshSkillPrefsCache().catch(() => undefined);
824
902
  const { services } = await this.bindings.createServices({
825
903
  cwd: this.options.cwd,
826
904
  agentDir,
827
- resourceLoaderOptions: {
905
+ resourceLoaderOptions: await this.buildResourceLoaderOptions({
828
906
  appendSystemPrompt,
829
- },
907
+ }),
830
908
  });
831
909
  if (this.disposedSessions.has(sessionId))
832
910
  return;
@@ -1176,6 +1254,9 @@ export class ConsolePiRuntime {
1176
1254
  }
1177
1255
  this.reloadInFlight.add(sessionId);
1178
1256
  try {
1257
+ // Refresh prefs cache so skillsOverride applied during reload sees
1258
+ // the latest resource-preferences.json written by PATCH.
1259
+ await this.refreshSkillPrefsCache().catch(() => undefined);
1179
1260
  await session.reload();
1180
1261
  }
1181
1262
  catch (error) {
@@ -1283,9 +1364,33 @@ export class ConsolePiRuntime {
1283
1364
  catch (error) {
1284
1365
  resourceFailure("context files", error);
1285
1366
  }
1367
+ // Preferences are the source of truth for UI auto-invocation state;
1368
+ // loader disableModelInvocation may also reflect frontmatter. Prefer
1369
+ // explicit preference (default on) when projecting autoInvocationEnabled.
1370
+ let skillPrefs = { schemaVersion: 1, skills: {} };
1371
+ try {
1372
+ skillPrefs = await this.loadSkillAutoInvocationPreferences();
1373
+ }
1374
+ catch (error) {
1375
+ diagnostics.push({
1376
+ type: "error",
1377
+ code: "PI_RESOURCE_PREFERENCES_MALFORMED",
1378
+ message: error instanceof Error ? error.message : String(error),
1379
+ retryable: true,
1380
+ });
1381
+ }
1286
1382
  try {
1287
1383
  const skillsResult = resourceLoader.getSkills?.();
1288
1384
  for (const skill of skillsResult?.skills ?? []) {
1385
+ const resourceId = skillResourceId({
1386
+ name: skill.name,
1387
+ filePath: skill.filePath,
1388
+ });
1389
+ // Preference off always means autoInvocationEnabled=false even if
1390
+ // loader already applied disableModelInvocation. Frontmatter-only
1391
+ // disable (no preference) also surfaces as autoInvocationEnabled=false.
1392
+ const prefEnabled = isAutoInvocationEnabled(skillPrefs, resourceId);
1393
+ const autoInvocationEnabled = prefEnabled && skill.disableModelInvocation !== true;
1289
1394
  skills.push({
1290
1395
  name: skill.name,
1291
1396
  description: skill.description ?? "",
@@ -1293,7 +1398,8 @@ export class ConsolePiRuntime {
1293
1398
  ...(skill.baseDir ? { baseDir: skill.baseDir } : {}),
1294
1399
  scope: skill.sourceInfo?.scope ?? "unknown",
1295
1400
  source: skill.sourceInfo?.source ?? "",
1296
- enabled: !skill.disableModelInvocation,
1401
+ resourceId,
1402
+ autoInvocationEnabled,
1297
1403
  diagnostics: [],
1298
1404
  });
1299
1405
  resolvedResources.push({
@@ -1620,12 +1726,13 @@ export class ConsolePiRuntime {
1620
1726
  OPERATOR_CHAT_SYSTEM_PROMPT_BASE,
1621
1727
  ...(init.systemPromptSuffix ? [init.systemPromptSuffix] : []),
1622
1728
  ];
1729
+ await this.refreshSkillPrefsCache().catch(() => undefined);
1623
1730
  const { services } = await this.bindings.createServices({
1624
1731
  cwd: this.options.cwd,
1625
1732
  agentDir,
1626
- resourceLoaderOptions: {
1733
+ resourceLoaderOptions: await this.buildResourceLoaderOptions({
1627
1734
  appendSystemPrompt,
1628
- },
1735
+ }),
1629
1736
  });
1630
1737
  const modelRuntime = services.modelRuntime;
1631
1738
  const resolvedModel = init.model
@@ -1669,81 +1776,142 @@ export class ConsolePiRuntime {
1669
1776
  hasSession(sessionId) {
1670
1777
  return this.sessions.has(sessionId);
1671
1778
  }
1779
+ /**
1780
+ * Live runtime busy facts for /state reconcile. isPromptRunning is owned by
1781
+ * this Console admission fence; other flags mirror the Pi session when present.
1782
+ */
1783
+ getSessionBusyFacts(sessionId) {
1784
+ const session = this.sessions.get(sessionId);
1785
+ const isPromptRunning = this.promptOwners.has(sessionId);
1786
+ return {
1787
+ isStreaming: session?.isStreaming === true || isPromptRunning,
1788
+ isPromptRunning,
1789
+ isCompacting: session?.isCompacting === true,
1790
+ isBashRunning: session?.isBashRunning === true ||
1791
+ session?.hasPendingBashMessages === true,
1792
+ };
1793
+ }
1672
1794
  /**
1673
1795
  * Send a prompt and stream events. Gate 3: each tool invocation is
1674
1796
  * re-authorized by authorizeOperatorChatTool before the dispatcher runs it
1675
1797
  * (the dispatcher is wired by the HTTP layer, see chat-session.ts).
1798
+ *
1799
+ * Prompt ownership is acquired synchronously before the first await so two
1800
+ * concurrent prompts on the same Session cannot both enter the SDK path.
1676
1801
  */
1677
1802
  async prompt(sessionId, text, onEvent, options) {
1678
- // First message while creating waits on the same materialization promise.
1679
- await this.ensureSessionReady(sessionId);
1680
- const session = this.sessions.get(sessionId);
1681
- if (!session) {
1682
- throw new Error(`chat session not found: ${sessionId}`);
1803
+ // Synchronous admission fence before any await.
1804
+ if (this.promptOwners.has(sessionId)) {
1805
+ throw new Error("CHAT_TURN_ACTIVE");
1683
1806
  }
1684
- // Gate 2 (re-pin before each turn): interview turns pass through the
1685
- // existing closed interview allow/deny registry. The next ordinary turn
1686
- // explicitly restores the normal Operator Chat set (baseline + extension
1687
- // tools, ADR 0012).
1688
- const requestedTools = OPERATOR_CHAT_ACTIVE_TOOL_NAMES();
1689
- const activeTools = options?.mode === "requirement-interview"
1690
- ? filterActiveInterviewTools(requestedTools).allowed
1691
- : computeOperatorChatActiveToolNames(session);
1692
- session.setActiveToolsByName(activeTools);
1693
- const unsub = session.subscribe((event) => {
1694
- const mapped = mapSdkEvent(sessionId, event);
1695
- if (mapped)
1696
- onEvent(mapped);
1697
- // Pi puts per-assistant-message usage on `message.usage` ({ input, output,
1698
- // cacheRead, cacheWrite, totalTokens, cost }). Emit on message_end so
1699
- // multi-step tool turns accumulate like pi-web SessionInfoBar; turn_end
1700
- // repeats the final message and must not double-count.
1701
- if (event.type === "message_end") {
1702
- const usage = extractUsageSample(event);
1703
- if (usage)
1704
- onEvent({ type: "usage", sessionId, usage });
1705
- }
1706
- });
1807
+ this.promptOwners.add(sessionId);
1808
+ let unsub;
1809
+ let abortPromise;
1707
1810
  const onAbort = () => {
1708
- // M1 T08: Stop must abort only the current turn, NOT dispose the
1709
- // session. SDK AgentSession.abort() aborts the current operation and
1710
- // waits for the agent to become idle, keeping the session reusable for
1711
- // the next turn. Only fall back to dispose if the session has no abort()
1712
- // (e.g. a minimal stub), since a stuck turn should not leak forever.
1811
+ // M1 T08 / AC-R3-003: Stop must abort only the current turn, NEVER
1812
+ // dispose the session. Await session.abort() so ownership settles
1813
+ // after the SDK reports idle. When abort is unavailable, fail closed
1814
+ // non-destructively Session stays reusable; terminal closeout or
1815
+ // /state reconcile still converges ownership.
1816
+ const session = this.sessions.get(sessionId);
1817
+ if (!session)
1818
+ return;
1713
1819
  try {
1714
1820
  if (typeof session.abort === "function") {
1715
- void session.abort();
1716
- }
1717
- else {
1718
- session.dispose();
1821
+ abortPromise = Promise.resolve(session.abort()).catch(() => undefined);
1719
1822
  }
1823
+ // else: do not call session.dispose() — Stop never destroys Session.
1720
1824
  }
1721
1825
  catch {
1722
1826
  // ignore
1723
1827
  }
1724
1828
  };
1725
- options?.signal?.addEventListener("abort", onAbort);
1726
1829
  try {
1727
- await session.prompt(text, options?.images?.length ? { images: options.images } : undefined);
1728
- // Wait until the session reports idle / not streaming. The SDK prompt()
1729
- // resolves when the agent turn completes, but post-turn continuation
1730
- // (auto-compaction / follow-up) may still be in flight. We poll isIdle.
1731
- // While polling we emit periodic heartbeat events so the SSE stream
1732
- // keeps proxies/browsers from timing out (no other data is flowing).
1733
- await waitForIdle(session, {
1734
- signal: options?.signal,
1735
- onHeartbeat: () => onEvent({ type: "heartbeat", sessionId }),
1830
+ // AC-FIX-002: if Stop already fired before readiness, never start model work.
1831
+ if (options?.signal?.aborted) {
1832
+ return;
1833
+ }
1834
+ // First message while creating waits on the same materialization promise.
1835
+ await this.ensureSessionReady(sessionId);
1836
+ // Abort may land during readiness (or between readiness and prompt).
1837
+ if (options?.signal?.aborted) {
1838
+ return;
1839
+ }
1840
+ const session = this.sessions.get(sessionId);
1841
+ if (!session) {
1842
+ throw new Error(`chat session not found: ${sessionId}`);
1843
+ }
1844
+ // Gate 2 (re-pin before each turn): interview turns pass through the
1845
+ // existing closed interview allow/deny registry. The next ordinary turn
1846
+ // explicitly restores the normal Operator Chat set (baseline + extension
1847
+ // tools, ADR 0012).
1848
+ const requestedTools = OPERATOR_CHAT_ACTIVE_TOOL_NAMES();
1849
+ const activeTools = options?.mode === "requirement-interview"
1850
+ ? filterActiveInterviewTools(requestedTools).allowed
1851
+ : computeOperatorChatActiveToolNames(session);
1852
+ session.setActiveToolsByName(activeTools);
1853
+ // Final pre-prompt fence: abort after tool pin must still skip session.prompt.
1854
+ if (options?.signal?.aborted) {
1855
+ return;
1856
+ }
1857
+ unsub = session.subscribe((event) => {
1858
+ const mapped = mapSdkEvent(sessionId, event);
1859
+ if (mapped)
1860
+ onEvent(mapped);
1861
+ // Pi puts per-assistant-message usage on `message.usage` ({ input, output,
1862
+ // cacheRead, cacheWrite, totalTokens, cost }). Emit on message_end so
1863
+ // multi-step tool turns accumulate like pi-web SessionInfoBar; turn_end
1864
+ // repeats the final message and must not double-count.
1865
+ if (event.type === "message_end") {
1866
+ const usage = extractUsageSample(event);
1867
+ if (usage)
1868
+ onEvent({ type: "usage", sessionId, usage });
1869
+ }
1736
1870
  });
1737
- onEvent({ type: "agent_settled", sessionId });
1738
- }
1739
- catch (error) {
1740
- const message = error instanceof Error ? error.message : String(error);
1741
- onEvent({ type: "error", sessionId, message });
1742
- throw error;
1871
+ options?.signal?.addEventListener("abort", onAbort);
1872
+ if (options?.signal?.aborted) {
1873
+ onAbort();
1874
+ if (abortPromise)
1875
+ await abortPromise.catch(() => undefined);
1876
+ return;
1877
+ }
1878
+ try {
1879
+ await session.prompt(text, options?.images?.length ? { images: options.images } : undefined);
1880
+ // Wait until the session reports idle / not streaming. The SDK prompt()
1881
+ // resolves when the agent turn completes, but post-turn continuation
1882
+ // (auto-compaction / follow-up) may still be in flight. We poll isIdle.
1883
+ // While polling we emit periodic heartbeat events so the SSE stream
1884
+ // keeps proxies/browsers from timing out (no other data is flowing).
1885
+ await waitForIdle(session, {
1886
+ signal: options?.signal,
1887
+ onHeartbeat: () => onEvent({ type: "heartbeat", sessionId }),
1888
+ });
1889
+ if (abortPromise)
1890
+ await abortPromise;
1891
+ // User abort is closed out as Turn state `aborted`; do not publish a
1892
+ // red ordinary error event for intentional Stop.
1893
+ if (!options?.signal?.aborted) {
1894
+ onEvent({ type: "agent_settled", sessionId });
1895
+ }
1896
+ }
1897
+ catch (error) {
1898
+ if (abortPromise)
1899
+ await abortPromise.catch(() => undefined);
1900
+ if (options?.signal?.aborted) {
1901
+ // User abort: swallow ordinary error publication (AC-FIX-003).
1902
+ return;
1903
+ }
1904
+ const message = error instanceof Error ? error.message : String(error);
1905
+ onEvent({ type: "error", sessionId, message });
1906
+ throw error;
1907
+ }
1908
+ finally {
1909
+ options?.signal?.removeEventListener("abort", onAbort);
1910
+ unsub?.();
1911
+ }
1743
1912
  }
1744
1913
  finally {
1745
- options?.signal?.removeEventListener("abort", onAbort);
1746
- unsub();
1914
+ this.promptOwners.delete(sessionId);
1747
1915
  }
1748
1916
  }
1749
1917
  /** Generate exactly one title in an isolated in-memory, tool-less LOW session. */