@cydm/pie 2.6.5 → 2.6.7

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.
@@ -2,8 +2,9 @@ import { createRequire as __createRequire } from "node:module"; const require =
2
2
  import {
3
3
  Agent,
4
4
  getToolCallerIdentity
5
- } from "./chunk-AETQBDEG.js";
5
+ } from "./chunk-PAC62XU4.js";
6
6
  import {
7
+ AssistantMessageEventStream,
7
8
  FileSystemGateway,
8
9
  completeSimple,
9
10
  detectPlatform,
@@ -11,7 +12,7 @@ import {
11
12
  getPlatformConfig,
12
13
  hashText,
13
14
  streamSimple
14
- } from "./chunk-5OXH6NUH.js";
15
+ } from "./chunk-7B4IIBVS.js";
15
16
  import {
16
17
  Type
17
18
  } from "./chunk-XXGYUYXI.js";
@@ -1070,6 +1071,141 @@ function createPlanCapability() {
1070
1071
  });
1071
1072
  }
1072
1073
 
1074
+ // ../../packages/shared-headless-capabilities/src/subagent-stream.ts
1075
+ function isRetryableSubagentFailure(message) {
1076
+ const remoteClosure = /SSE stream (?:aborted|closed before end|ended before response completed)/i.test(message);
1077
+ const classificationText = message.replace(/econnaborted/ig, "").replace(/SSE stream aborted/ig, "remote stream closed");
1078
+ if (/\b(?:400|401|403|404|422|429)\b|quota|weekly|monthly|limit reached|exhausted|unauthorized|authentication|invalid|protocol|stopReason=length|token limit|abort/i.test(classificationText)) return false;
1079
+ return remoteClosure || /Request timeout/i.test(message) || /econnreset|econnaborted|etimedout|eai_again|econnrefused|socket hang up|socket disconnected|secure TLS connection|fetch failed|network error|connection reset|premature close|\b50[0234]\b|bad gateway|service unavailable/i.test(message);
1080
+ }
1081
+ function cancellableBackoff(ms, signal) {
1082
+ return new Promise((resolve3) => {
1083
+ const finish = () => {
1084
+ clearTimeout(timer);
1085
+ signal?.removeEventListener("abort", finish);
1086
+ resolve3();
1087
+ };
1088
+ const timer = setTimeout(finish, ms);
1089
+ signal?.addEventListener("abort", finish, { once: true });
1090
+ if (signal?.aborted) finish();
1091
+ });
1092
+ }
1093
+ function withSubagentResponseRecovery(stream, backoffMs = 2e3) {
1094
+ return async (model, context, options) => {
1095
+ for (let attempt = 0; ; attempt++) {
1096
+ if (options?.signal?.aborted) throw new Error("Subagent aborted by parent");
1097
+ let message;
1098
+ try {
1099
+ const response = await stream(model, context, options);
1100
+ let terminal;
1101
+ const iterator = response[Symbol.asyncIterator]();
1102
+ try {
1103
+ while (true) {
1104
+ const next = await iterator.next();
1105
+ if (next.done) break;
1106
+ if (next.value.type === "done") terminal = next.value.message;
1107
+ if (next.value.type === "error") terminal = next.value.error;
1108
+ }
1109
+ } finally {
1110
+ try {
1111
+ await iterator.return?.();
1112
+ } catch {
1113
+ }
1114
+ }
1115
+ if (!terminal) throw new Error("Provider protocol error: response ended without a terminal event");
1116
+ message = terminal;
1117
+ } catch (error) {
1118
+ if (!options?.signal?.aborted && attempt < 2 && isRetryableSubagentFailure(error instanceof Error ? error.message : String(error))) {
1119
+ await cancellableBackoff(backoffMs * (attempt + 1), options?.signal);
1120
+ continue;
1121
+ }
1122
+ throw error;
1123
+ }
1124
+ if (options?.signal?.aborted) throw new Error("Subagent aborted by parent");
1125
+ if (message.stopReason === "error" && attempt < 2 && isRetryableSubagentFailure(message.errorMessage ?? "")) {
1126
+ await cancellableBackoff(backoffMs * (attempt + 1), options?.signal);
1127
+ continue;
1128
+ }
1129
+ const result = new AssistantMessageEventStream();
1130
+ if (message.stopReason === "error" || message.stopReason === "aborted") {
1131
+ result.push({ type: "error", reason: message.stopReason, error: { ...message, content: [] } });
1132
+ } else {
1133
+ result.push({ type: "done", reason: message.stopReason, message });
1134
+ }
1135
+ result.end();
1136
+ return result;
1137
+ }
1138
+ };
1139
+ }
1140
+
1141
+ // ../../packages/shared-headless-capabilities/src/subagent-sessions.ts
1142
+ var SUBAGENT_SESSION_RECORD_VERSION = 1;
1143
+ var DEFAULT_MAX_LIVE_SESSIONS = 8;
1144
+ var DEFAULT_SESSION_STORE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
1145
+ var DEFAULT_SESSION_STORE_MAX_RECORDS = 64;
1146
+ var SESSION_TRANSCRIPT_WARN_CHARS = 24e4;
1147
+ var SESSION_RECORD_MAX_CHARS = 4e6;
1148
+ function touchLiveSession(registry, id) {
1149
+ const session = registry.get(id);
1150
+ if (!session) return void 0;
1151
+ registry.delete(id);
1152
+ registry.set(id, session);
1153
+ return session;
1154
+ }
1155
+ function retainLiveSession(registry, session, cap) {
1156
+ registry.delete(session.id);
1157
+ registry.set(session.id, session);
1158
+ while (registry.size > Math.max(1, cap)) {
1159
+ let evicted = false;
1160
+ for (const [key, candidate] of registry) {
1161
+ if (candidate.busy) continue;
1162
+ registry.delete(key);
1163
+ evicted = true;
1164
+ break;
1165
+ }
1166
+ if (!evicted) {
1167
+ break;
1168
+ }
1169
+ }
1170
+ }
1171
+ function toSessionRecord(session, messages) {
1172
+ return {
1173
+ version: SUBAGENT_SESSION_RECORD_VERSION,
1174
+ id: session.id,
1175
+ name: session.name,
1176
+ mode: session.mode,
1177
+ priority: session.priority,
1178
+ originalTask: session.originalTask,
1179
+ modelOverride: session.modelOverride,
1180
+ thinkingLevel: session.thinkingLevel,
1181
+ timeoutSeconds: session.timeoutSeconds,
1182
+ createdAt: session.createdAt,
1183
+ lastUsedAt: session.lastUsedAt,
1184
+ runCount: session.runCount,
1185
+ revision: session.revision,
1186
+ messages
1187
+ };
1188
+ }
1189
+ function estimateTranscriptChars(messages) {
1190
+ let total = 0;
1191
+ for (const message of messages) {
1192
+ try {
1193
+ total += JSON.stringify(message)?.length ?? 0;
1194
+ } catch {
1195
+ total += SESSION_TRANSCRIPT_WARN_CHARS;
1196
+ }
1197
+ }
1198
+ return total;
1199
+ }
1200
+ function transplantMessages(target, messages) {
1201
+ const mutable = target;
1202
+ if (typeof mutable.replaceMessages === "function") {
1203
+ mutable.replaceMessages(messages);
1204
+ } else {
1205
+ mutable.state.messages = messages;
1206
+ }
1207
+ }
1208
+
1073
1209
  // ../../packages/shared-headless-capabilities/src/bash-policy.ts
1074
1210
  var DESTRUCTIVE_PATTERNS = [
1075
1211
  { pattern: /\bgit\s+reset\s+--hard\b/, warning: "may discard uncommitted changes", commandClass: "git_destructive" },
@@ -1404,6 +1540,10 @@ function clipHeartbeatLabel(text, maxLength = 60) {
1404
1540
  function formatSkillsForPrompt(skills) {
1405
1541
  return formatSkillSummariesForPrompt(skills);
1406
1542
  }
1543
+ var SYNTHETIC_PROVIDER_TEXT_PREFIXES = ["[output truncated:", "[dropped "];
1544
+ function isSyntheticProviderText(text) {
1545
+ return SYNTHETIC_PROVIDER_TEXT_PREFIXES.some((prefix) => text.startsWith(prefix));
1546
+ }
1407
1547
  function collectLastAssistantText(messages) {
1408
1548
  for (let i = messages.length - 1; i >= 0; i--) {
1409
1549
  const message = messages[i];
@@ -1411,17 +1551,29 @@ function collectLastAssistantText(messages) {
1411
1551
  continue;
1412
1552
  }
1413
1553
  if (Array.isArray(message.content)) {
1414
- const textContent = message.content.filter((content) => content?.type === "text" && typeof content.text === "string").map((content) => content.text).join("\n");
1554
+ const realBlocks = message.content.filter((content) => content?.type === "text" && typeof content.text === "string").map((content) => content.text).filter((text) => !isSyntheticProviderText(text));
1555
+ const textContent = realBlocks.join("\n");
1415
1556
  if (textContent.trim()) {
1416
1557
  return textContent;
1417
1558
  }
1418
1559
  }
1419
- if (typeof message.content === "string" && message.content.trim()) {
1560
+ if (typeof message.content === "string" && message.content.trim() && !isSyntheticProviderText(message.content)) {
1420
1561
  return message.content;
1421
1562
  }
1422
1563
  }
1423
1564
  return "";
1424
1565
  }
1566
+ function visibleTextBlocks(message) {
1567
+ if (!Array.isArray(message.content)) return [];
1568
+ return message.content.filter((content) => content?.type === "text" && typeof content.text === "string").map((content) => content.text).filter((text) => text.trim().length > 0 && !isSyntheticProviderText(text));
1569
+ }
1570
+ function messageHasOwnText(message) {
1571
+ if (!message) return false;
1572
+ if (Array.isArray(message.content)) {
1573
+ return visibleTextBlocks(message).length > 0;
1574
+ }
1575
+ return typeof message.content === "string" && message.content.trim().length > 0 && !isSyntheticProviderText(message.content);
1576
+ }
1425
1577
  function inferComplexity(task, priority) {
1426
1578
  const normalized = task.toLowerCase();
1427
1579
  if (priority === "thorough" || task.length > 600 || /\b(across|architecture|refactor|review|investigate|debug|failing|test|compare|multiple|race|root cause)\b/.test(normalized)) {
@@ -1539,9 +1691,31 @@ function buildSubagentPlan(deps, task, name, priority, timeoutSeconds, modelOver
1539
1691
  function subagentOutputBudget(model) {
1540
1692
  return Math.min(model.maxTokens || 8192, 16384);
1541
1693
  }
1694
+ function buildSubagentSystemPrompt(mode, skills) {
1695
+ return `You are a specialized subagent focused on one bounded task.
1696
+
1697
+ Task boundary:
1698
+ - Work only on the assigned task.
1699
+ - Use available tools to gather evidence before making claims.
1700
+ ${mode === "edit" ? "- You may edit files and run tests using the inherited parent tools and permissions. This is a shared workspace, not an isolated sandbox. Preserve existing user changes. Do not commit or automatically roll back changes. Report changed files, tests actually run, and remaining work for parent review." : "- Do not edit files; if writing is needed, say what the parent agent should change."}
1701
+ - Return a concise answer with evidence and uncertainty.
1702
+
1703
+ Output:
1704
+ - Answer
1705
+ - Evidence
1706
+ - Uncertainty: low, medium, or high
1707
+ - Parent follow-up needed, if any
1708
+
1709
+ ${formatSkillsForPrompt(skills ?? [])}`;
1710
+ }
1542
1711
  function createAgentInstance(deps, systemPrompt, tools, model, apiKey, thinkingLevel) {
1712
+ const streamFn = withSubagentResponseRecovery((streamModel, context, options) => streamSimple(streamModel, context, {
1713
+ ...options,
1714
+ maxTokens: options?.maxTokens ?? subagentOutputBudget(streamModel)
1715
+ }), deps.transientRetryBackoffMs);
1543
1716
  if (deps.createAgent) {
1544
1717
  return deps.createAgent({
1718
+ streamFn,
1545
1719
  systemPrompt,
1546
1720
  model,
1547
1721
  tools,
@@ -1560,10 +1734,7 @@ function createAgentInstance(deps, systemPrompt, tools, model, apiKey, thinkingL
1560
1734
  // Subagent turns are single-purpose report generations: request an
1561
1735
  // explicit output budget (see subagentOutputBudget) instead of leaving
1562
1736
  // it to endpoint defaults that silently truncate long reports.
1563
- streamFn: (streamModel, context, options) => streamSimple(streamModel, context, {
1564
- ...options,
1565
- maxTokens: options?.maxTokens ?? subagentOutputBudget(streamModel)
1566
- })
1737
+ streamFn
1567
1738
  });
1568
1739
  }
1569
1740
  function extractEvidenceFromToolArgs(toolName, args) {
@@ -1606,7 +1777,114 @@ function inferUncertainty(text, signals) {
1606
1777
  function isParentHandoffRequired(task) {
1607
1778
  return task.mode === "edit" || task.needsParentWrite || task.status === "timeout" || task.status === "failed" || task.confidenceSignals.some((signal) => signal.type === "tool_error" || signal.type === "no_tool_use" || signal.type === "no_text_report");
1608
1779
  }
1609
- async function runSubagentWithProgress(deps, task, reportProgress, signal) {
1780
+ function shellFromRecord(record) {
1781
+ return {
1782
+ id: record.id,
1783
+ name: record.name,
1784
+ mode: record.mode,
1785
+ priority: record.priority,
1786
+ originalTask: record.originalTask,
1787
+ modelOverride: record.modelOverride,
1788
+ thinkingLevel: record.thinkingLevel,
1789
+ timeoutSeconds: record.timeoutSeconds,
1790
+ agent: null,
1791
+ pendingMessages: record.messages,
1792
+ createdAt: record.createdAt,
1793
+ lastUsedAt: record.lastUsedAt,
1794
+ runCount: record.runCount,
1795
+ // The restored record IS our durable base line: its revision (0 for
1796
+ // legacy records that predate the field) is both our current revision
1797
+ // and our known-persisted revision.
1798
+ revision: record.revision ?? 0,
1799
+ persistedRevision: record.revision ?? 0,
1800
+ busy: true
1801
+ };
1802
+ }
1803
+ async function resolveRecallSession(deps, id, sessions, maxLiveSessions) {
1804
+ let live = sessions ? touchLiveSession(sessions, id) : void 0;
1805
+ if (live?.busy) throw new Error(`Subagent session "${id}" is busy with another task; wait for it to settle.`);
1806
+ let record;
1807
+ if (deps.sessionStore) {
1808
+ try {
1809
+ record = await deps.sessionStore.load(id);
1810
+ } catch (error) {
1811
+ const detail = error instanceof Error ? error.message : String(error);
1812
+ deps.debugLog?.("session", "session store load failed", { id, error: detail });
1813
+ throw new Error(`Subagent session "${id}" could not be read from the session store (${detail}); refusing to recall without verifying session freshness. Retry the recall, or spawn a fresh task.`);
1814
+ }
1815
+ }
1816
+ if (record && typeof record.version === "number" && record.version !== SUBAGENT_SESSION_RECORD_VERSION) {
1817
+ throw new Error(`Subagent session "${id}" is persisted in record format version ${record.version}, which this build cannot read (supported: ${SUBAGENT_SESSION_RECORD_VERSION}); refusing to recall so the newer record is not overwritten. Use the matching Pie version, or spawn a fresh task.`);
1818
+ }
1819
+ if (record && (record.version !== SUBAGENT_SESSION_RECORD_VERSION || record.id !== id || !Array.isArray(record.messages))) {
1820
+ record = void 0;
1821
+ }
1822
+ live = sessions ? touchLiveSession(sessions, id) : void 0;
1823
+ if (live?.busy) throw new Error(`Subagent session "${id}" is busy with another task; wait for it to settle.`);
1824
+ if (live) {
1825
+ const liveTranscriptLength = live.agent?.state.messages.length ?? live.pendingMessages?.length ?? 0;
1826
+ const recordIsNewer = record !== void 0 && (record.revision !== void 0 ? record.revision > live.revision : record.runCount > live.runCount || record.runCount === live.runCount && record.lastUsedAt > live.lastUsedAt || record.runCount === live.runCount && record.lastUsedAt === live.lastUsedAt && record.messages.length > liveTranscriptLength);
1827
+ const forked = record?.revision !== void 0 && record.revision > (live.persistedRevision ?? 0) && live.revision > (live.persistedRevision ?? 0);
1828
+ if (forked && record?.revision !== void 0) {
1829
+ deps.debugLog?.("session", "live and durable sessions diverged after failed persistence", {
1830
+ id,
1831
+ liveRevision: live.revision,
1832
+ persistedRevision: record.revision,
1833
+ lastSharedRevision: live.persistedRevision
1834
+ });
1835
+ sessions?.delete(id);
1836
+ throw new Error(`Subagent session "${id}" diverged across processes: this process holds unsaved work (revision ${live.revision}, last durable sync ${live.persistedRevision ?? 0}) while another process advanced the stored record (revision ${record.revision}). The diverged in-memory branch was discarded; recall again to continue from the last durable transcript, or spawn a fresh task.`);
1837
+ }
1838
+ if (recordIsNewer && record) {
1839
+ deps.debugLog?.("session", "live session behind the durable record; adopting the persisted record", {
1840
+ id,
1841
+ liveMessages: liveTranscriptLength,
1842
+ persistedMessages: record.messages.length,
1843
+ liveRevision: live.revision,
1844
+ persistedRevision: record.revision,
1845
+ liveRunCount: live.runCount,
1846
+ persistedRunCount: record.runCount,
1847
+ liveLastUsedAt: live.lastUsedAt,
1848
+ persistedLastUsedAt: record.lastUsedAt
1849
+ });
1850
+ const shell2 = shellFromRecord(record);
1851
+ if (sessions) {
1852
+ retainLiveSession(sessions, shell2, maxLiveSessions);
1853
+ }
1854
+ return { session: shell2, restored: true };
1855
+ }
1856
+ live.busy = true;
1857
+ return { session: live, restored: false };
1858
+ }
1859
+ if (!record) return void 0;
1860
+ const shell = shellFromRecord(record);
1861
+ if (sessions) {
1862
+ retainLiveSession(sessions, shell, maxLiveSessions);
1863
+ }
1864
+ return { session: shell, restored: true };
1865
+ }
1866
+ async function persistSubagentSession(deps, session, task) {
1867
+ const messages = session.agent?.state.messages ?? session.pendingMessages ?? [];
1868
+ const transcriptChars = estimateTranscriptChars(messages);
1869
+ if (task.status === "completed" && task.result && transcriptChars > SESSION_TRANSCRIPT_WARN_CHARS) {
1870
+ task.result += `
1871
+
1872
+ [Session growth warning: session ${session.id} now carries ~${Math.round(transcriptChars / 4)} tokens of transcript; every recall pays for the whole history. For the next round, consider a fresh subagent with a compact brief instead of recalling.]`;
1873
+ }
1874
+ if (!deps.sessionStore) return;
1875
+ if (transcriptChars > SESSION_RECORD_MAX_CHARS) {
1876
+ deps.debugLog?.("session", "skipping session persist: transcript over the size cap", { id: session.id, transcriptChars });
1877
+ return;
1878
+ }
1879
+ try {
1880
+ const saved = await deps.sessionStore.save(toSessionRecord(session, messages));
1881
+ task.sessionPersisted = saved === true;
1882
+ if (saved === true) session.persistedRevision = session.revision;
1883
+ } catch (error) {
1884
+ deps.debugLog?.("session", "session persist failed", { id: session.id, error: error instanceof Error ? error.message : String(error) });
1885
+ }
1886
+ }
1887
+ async function runSubagentWithProgress(deps, task, reportProgress, signal, sessions) {
1610
1888
  const startTime = Date.now();
1611
1889
  task.status = "running";
1612
1890
  let timedOut = false;
@@ -1620,6 +1898,11 @@ async function runSubagentWithProgress(deps, task, reportProgress, signal) {
1620
1898
  const heartbeatIntervalMs = deps.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
1621
1899
  deps.debugLog?.("run", "runSubagentWithProgress START", { taskName: task.name, yoloMode: deps.yoloMode });
1622
1900
  let heartbeat;
1901
+ let session;
1902
+ let runStarted = false;
1903
+ let pendingRun;
1904
+ let agentSettled = true;
1905
+ let releaseLease;
1623
1906
  if (heartbeatIntervalMs > 0) {
1624
1907
  heartbeat = setInterval(() => {
1625
1908
  try {
@@ -1632,46 +1915,134 @@ async function runSubagentWithProgress(deps, task, reportProgress, signal) {
1632
1915
  }
1633
1916
  }, Math.max(50, Math.min(1e3, heartbeatIntervalMs / 2)));
1634
1917
  }
1918
+ if (task.sessionId) task.wasRecall = true;
1635
1919
  try {
1636
1920
  report({ message: `[${task.name}] Starting...`, increment: 5 });
1637
- const { plan, tools, apiKey, model } = buildSubagentPlan(deps, task.task, task.name, task.priority, task.timeoutSeconds, task.modelOverride, task.thinkingLevel, task.mode);
1638
- task.plan = plan;
1639
- task.needsParentWrite = task.mode === "read" && needsParentWrite(task.task);
1640
- for (const excluded of plan.toolsExcluded) {
1641
- if (excluded.reason === "unclassified_tool") {
1642
- task.confidenceSignals.push({ type: "unclassified_tool", message: `${excluded.name} inherited without risk metadata` });
1921
+ let subagent;
1922
+ let baseline = 0;
1923
+ let plan;
1924
+ let runTimeoutSeconds = task.timeoutSeconds;
1925
+ if (task.sessionId) {
1926
+ if (sessions?.get(task.sessionId)?.busy) {
1927
+ throw new Error(`Subagent session "${task.sessionId}" is busy with another task; wait for it to settle.`);
1643
1928
  }
1929
+ releaseLease = await deps.sessionStore?.acquire?.(task.sessionId);
1930
+ const resolved = await resolveRecallSession(deps, task.sessionId, sessions, Math.max(1, deps.maxLiveSessions ?? DEFAULT_MAX_LIVE_SESSIONS));
1931
+ if (!resolved) {
1932
+ throw new Error(`Unknown or expired subagent session "${task.sessionId}": it is neither live in this process nor in the session store (evicted, swept, or never persisted). Spawn a fresh task instead of recalling.`);
1933
+ }
1934
+ const owned = resolved.session;
1935
+ session = owned;
1936
+ if (task.requestedTimeoutSeconds !== void 0) {
1937
+ session.timeoutSeconds = task.requestedTimeoutSeconds;
1938
+ }
1939
+ const nextMode = task.requestedMode ?? session.mode;
1940
+ if (nextMode === "edit" && (task.batchSize ?? 1) > 1) {
1941
+ throw new Error(`Recalling session "${session.id}" resolves to edit mode, and an edit delegation must contain exactly one task (the workspace is shared). Retry it alone.`);
1942
+ }
1943
+ const nextModelOverride = task.modelOverride ?? session.modelOverride;
1944
+ const nextThinking = task.thinkingLevel ?? session.thinkingLevel;
1945
+ const mustRebuild = session.agent === null || task.requestedMode !== void 0 || task.modelOverride !== void 0 || task.thinkingLevel !== void 0;
1946
+ if (!mustRebuild) {
1947
+ subagent = session.agent;
1948
+ baseline = subagent.state.messages.length;
1949
+ } else {
1950
+ let built = buildSubagentPlan(deps, session.originalTask, session.name, session.priority, session.timeoutSeconds, nextModelOverride, nextThinking, nextMode);
1951
+ let effectiveOverride = nextModelOverride;
1952
+ if (task.modelOverride && !built.plan.modelOverrideApplied && session.modelOverride && session.modelOverride !== task.modelOverride) {
1953
+ const missReason = built.plan.modelFallbackReason ?? `model override "${task.modelOverride}" could not be resolved`;
1954
+ built = buildSubagentPlan(deps, session.originalTask, session.name, session.priority, session.timeoutSeconds, session.modelOverride, nextThinking, nextMode);
1955
+ built.plan.modelFallbackReason = built.plan.modelFallbackReason ? `${missReason}; ${built.plan.modelFallbackReason}` : missReason;
1956
+ effectiveOverride = session.modelOverride;
1957
+ }
1958
+ plan = built.plan;
1959
+ task.plan = plan;
1960
+ for (const excluded of plan.toolsExcluded) {
1961
+ if (excluded.reason === "unclassified_tool") {
1962
+ task.confidenceSignals.push({ type: "unclassified_tool", message: `${excluded.name} inherited without risk metadata` });
1963
+ }
1964
+ }
1965
+ if (plan.modelFallbackReason) {
1966
+ task.confidenceSignals.push({ type: "model_fallback", message: plan.modelFallbackReason });
1967
+ }
1968
+ if (!built.apiKey) {
1969
+ throw new Error("No API key available for subagent execution.");
1970
+ }
1971
+ if (!built.model) {
1972
+ throw new Error("No model configured for subagent execution.");
1973
+ }
1974
+ const priorMessages = session.agent ? session.agent.state.messages : session.pendingMessages ?? [];
1975
+ const rebuilt = createAgentInstance(deps, buildSubagentSystemPrompt(nextMode, deps.skills), built.tools, built.model, built.apiKey, plan.thinkingLevel);
1976
+ transplantMessages(rebuilt, priorMessages);
1977
+ baseline = priorMessages.length;
1978
+ subagent = rebuilt;
1979
+ session.agent = subagent;
1980
+ session.pendingMessages = void 0;
1981
+ session.mode = nextMode;
1982
+ session.modelOverride = effectiveOverride;
1983
+ session.thinkingLevel = plan.thinkingLevel;
1984
+ }
1985
+ if (plan) {
1986
+ runTimeoutSeconds = plan.timeoutSeconds;
1987
+ } else {
1988
+ runTimeoutSeconds = session.timeoutSeconds;
1989
+ }
1990
+ task.mode = session.mode;
1991
+ task.needsParentWrite = task.mode === "read" && needsParentWrite(task.task);
1992
+ deps.log(`[Subagent ${task.name}] Recalling session ${session.id} (run ${session.runCount + 1}, mode=${session.mode}${resolved.restored ? ", restored from disk" : ""}${mustRebuild ? ", reconfigured" : ""})`);
1993
+ } else {
1994
+ const built = buildSubagentPlan(deps, task.task, task.name, task.priority, task.timeoutSeconds, task.modelOverride, task.thinkingLevel, task.mode);
1995
+ plan = built.plan;
1996
+ task.plan = plan;
1997
+ const { tools, apiKey, model } = built;
1998
+ task.needsParentWrite = task.mode === "read" && needsParentWrite(task.task);
1999
+ for (const excluded of plan.toolsExcluded) {
2000
+ if (excluded.reason === "unclassified_tool") {
2001
+ task.confidenceSignals.push({ type: "unclassified_tool", message: `${excluded.name} inherited without risk metadata` });
2002
+ }
2003
+ }
2004
+ if (plan.modelFallbackReason) {
2005
+ task.confidenceSignals.push({ type: "model_fallback", message: plan.modelFallbackReason });
2006
+ }
2007
+ if (!apiKey) {
2008
+ throw new Error("No API key available for subagent execution.");
2009
+ }
2010
+ if (!model) {
2011
+ throw new Error("No model configured for subagent execution.");
2012
+ }
2013
+ if (signal?.aborted) {
2014
+ throw new Error("Subagent aborted by parent");
2015
+ }
2016
+ deps.log(`[Subagent ${task.name}] Plan: ${plan.complexity}/${plan.modelClass}${plan.modelOverride ? `, model=${plan.modelOverrideApplied ? plan.modelOverride : "override failed"}` : ""}, ${tools.length} tools`);
2017
+ deps.debugLog?.("plan", "SubagentPlan", plan);
2018
+ subagent = createAgentInstance(deps, buildSubagentSystemPrompt(task.mode, deps.skills), tools, model, apiKey, plan.thinkingLevel);
2019
+ session = {
2020
+ id: task.id,
2021
+ name: task.name,
2022
+ mode: task.mode,
2023
+ priority: task.priority,
2024
+ originalTask: task.task,
2025
+ modelOverride: task.modelOverride,
2026
+ thinkingLevel: plan.thinkingLevel,
2027
+ timeoutSeconds: task.timeoutSeconds,
2028
+ agent: subagent,
2029
+ createdAt: Date.now(),
2030
+ lastUsedAt: Date.now(),
2031
+ runCount: 0,
2032
+ revision: 0,
2033
+ busy: true
2034
+ };
2035
+ task.sessionId = session.id;
1644
2036
  }
1645
- if (plan.modelFallbackReason) {
1646
- task.confidenceSignals.push({ type: "model_fallback", message: plan.modelFallbackReason });
1647
- }
1648
- if (!apiKey) {
1649
- throw new Error("No API key available for subagent execution.");
1650
- }
1651
- if (!model) {
1652
- throw new Error("No model configured for subagent execution.");
1653
- }
1654
- if (signal?.aborted) {
1655
- throw new Error("Subagent aborted by parent");
2037
+ if (!task.wasRecall) {
2038
+ try {
2039
+ releaseLease = await deps.sessionStore?.acquire?.(session.id);
2040
+ } catch (error) {
2041
+ session = void 0;
2042
+ throw error;
2043
+ }
1656
2044
  }
1657
- deps.log(`[Subagent ${task.name}] Plan: ${plan.complexity}/${plan.modelClass}${plan.modelOverride ? `, model=${plan.modelOverrideApplied ? plan.modelOverride : "override failed"}` : ""}, ${tools.length} tools`);
1658
- deps.debugLog?.("plan", "SubagentPlan", plan);
1659
- const systemPrompt = `You are a specialized subagent focused on one bounded task.
1660
-
1661
- Task boundary:
1662
- - Work only on the assigned task.
1663
- - Use available tools to gather evidence before making claims.
1664
- ${task.mode === "edit" ? "- You may edit files and run tests using the inherited parent tools and permissions. This is a shared workspace, not an isolated sandbox. Preserve existing user changes. Do not commit or automatically roll back changes. Report changed files, tests actually run, and remaining work for parent review." : "- Do not edit files; if writing is needed, say what the parent agent should change."}
1665
- - Return a concise answer with evidence and uncertainty.
1666
-
1667
- Output:
1668
- - Answer
1669
- - Evidence
1670
- - Uncertainty: low, medium, or high
1671
- - Parent follow-up needed, if any
1672
-
1673
- ${formatSkillsForPrompt(deps.skills ?? [])}`;
1674
- const subagent = createAgentInstance(deps, systemPrompt, tools, model, apiKey, plan.thinkingLevel);
2045
+ if (sessions) retainLiveSession(sessions, session, Math.max(1, deps.maxLiveSessions ?? DEFAULT_MAX_LIVE_SESSIONS));
1675
2046
  let assistantTurns = 0;
1676
2047
  let toolCalls = 0;
1677
2048
  let rejectTimeout = () => void 0;
@@ -1683,10 +2054,10 @@ ${formatSkillsForPrompt(deps.skills ?? [])}`;
1683
2054
  });
1684
2055
  timeout = setTimeout(() => {
1685
2056
  timedOut = true;
1686
- const timeoutMessage = `Subagent timed out after ${plan.timeoutSeconds} seconds`;
2057
+ const timeoutMessage = `Subagent timed out after ${runTimeoutSeconds} seconds`;
1687
2058
  subagent.abort?.();
1688
2059
  rejectTimeout(new Error(timeoutMessage));
1689
- }, plan.timeoutSeconds * 1e3);
2060
+ }, runTimeoutSeconds * 1e3);
1690
2061
  const abortPromise = new Promise((_, reject) => {
1691
2062
  if (!signal) return;
1692
2063
  const abortSubagent = () => {
@@ -1723,17 +2094,33 @@ ${formatSkillsForPrompt(deps.skills ?? [])}`;
1723
2094
  }
1724
2095
  });
1725
2096
  let prompt = task.task;
2097
+ if (task.wasRecall && session) {
2098
+ prompt = `This task CONTINUES subagent session "${session.name}" (${session.id}); your earlier transcript is retained. Build on it instead of redoing completed investigation, and re-verify anything the workspace may have changed since your last run.
2099
+
2100
+ New instruction:
2101
+ ${task.task}`;
2102
+ }
1726
2103
  if (task.mode === "read") {
1727
2104
  prompt += "\n\nThis delegation is read mode. Do not write files. If changes are needed, describe them for the parent.";
1728
2105
  }
1729
2106
  prompt += "\n\nReturn evidence for your conclusion and state uncertainty as low, medium, or high.";
1730
- report({ message: `[${task.name}] Running plan ${plan.complexity}/${plan.modelClass}...`, increment: 20 });
2107
+ const runLabel = plan ? `plan ${plan.complexity}/${plan.modelClass}` : `session ${session?.id ?? ""} (run ${(session?.runCount ?? 0) + 1})`;
2108
+ report({ message: `[${task.name}] Running ${runLabel}...`, increment: 20 });
2109
+ runStarted = true;
2110
+ agentSettled = false;
2111
+ const runPromise = (async () => {
2112
+ try {
2113
+ await subagent.prompt(prompt);
2114
+ await subagent.waitForIdle?.();
2115
+ } finally {
2116
+ agentSettled = true;
2117
+ }
2118
+ })();
2119
+ const settledRun = runPromise.catch(() => void 0);
2120
+ pendingRun = settledRun;
1731
2121
  try {
1732
2122
  await Promise.race([
1733
- (async () => {
1734
- await subagent.prompt(prompt);
1735
- await subagent.waitForIdle?.();
1736
- })(),
2123
+ runPromise,
1737
2124
  timeoutPromise,
1738
2125
  abortPromise
1739
2126
  ]);
@@ -1743,17 +2130,30 @@ ${formatSkillsForPrompt(deps.skills ?? [])}`;
1743
2130
  }
1744
2131
  removeAbortListener?.();
1745
2132
  unsubscribe?.();
2133
+ if (!agentSettled) {
2134
+ const graceMs = deps.sessionSettleGraceMs ?? 2e3;
2135
+ if (graceMs > 0) {
2136
+ await Promise.race([
2137
+ settledRun,
2138
+ new Promise((resolve3) => setTimeout(resolve3, graceMs))
2139
+ ]);
2140
+ }
2141
+ }
1746
2142
  }
1747
- const messages = subagent.state.messages;
1748
- deps.log(`[Subagent ${task.name}] Total messages: ${messages.length}`);
2143
+ const allMessages = subagent.state.messages;
2144
+ const messages = allMessages.slice(baseline);
2145
+ deps.log(`[Subagent ${task.name}] Total messages: ${allMessages.length} (${messages.length} new)`);
1749
2146
  const lastAssistant = [...messages].reverse().find((m) => m?.role === "assistant");
1750
2147
  const terminalStopReason = lastAssistant?.stopReason;
1751
2148
  const terminalError = typeof lastAssistant?.errorMessage === "string" ? lastAssistant.errorMessage : void 0;
1752
2149
  const collectedResponse = collectLastAssistantText(messages) || void 0;
1753
- const streamInterrupted = terminalStopReason === "error" || terminalStopReason === "aborted" || terminalError || !collectedResponse && terminalStopReason !== "stop";
2150
+ const streamInterrupted = terminalStopReason === "error" || terminalStopReason === "aborted" || terminalError || !collectedResponse && terminalStopReason !== "stop" || terminalStopReason === "length" && !messageHasOwnText(lastAssistant);
1754
2151
  if (streamInterrupted) {
2152
+ if (terminalStopReason === "length") {
2153
+ throw new Error(`Subagent output token limit reached (stopReason=length): the provider cut the final response before the report was produced${collectedResponse ? " (earlier-turn narration discarded)" : ", no text response"}. The output/thinking budget was too small for this task at its reasoning level. Rerun with a narrower task or a lower thinkingLevel.`);
2154
+ }
1755
2155
  const detail = terminalError ? `: ${terminalError.slice(0, 200)}` : "";
1756
- throw new Error(`Subagent stream interrupted (SSE stream aborted before the report was produced, stopReason=${terminalStopReason ?? "none"}${collectedResponse ? ", partial transcript discarded" : ", no text response"})${detail}`);
2156
+ throw new Error(`Subagent response failed (provider/transport failure before the report was produced, stopReason=${terminalStopReason ?? "none"}${collectedResponse ? ", partial transcript discarded" : ", no text response"})${detail}`);
1757
2157
  }
1758
2158
  let response = collectedResponse ?? "[No textual report: the subagent finished normally (stopReason=stop) but produced no text \u2014 every assistant message was tool calls only. Review the task's effects directly, or rerun it with an explicit instruction to summarize findings.]";
1759
2159
  if (!collectedResponse) {
@@ -1802,6 +2202,29 @@ ${formatSkillsForPrompt(deps.skills ?? [])}`;
1802
2202
  if (heartbeat) {
1803
2203
  clearInterval(heartbeat);
1804
2204
  }
2205
+ const finalize = async () => {
2206
+ try {
2207
+ if (session) {
2208
+ if (runStarted) session.runCount++;
2209
+ session.lastUsedAt = Date.now();
2210
+ session.revision++;
2211
+ task.sessionRunCount = session.runCount;
2212
+ await persistSubagentSession(deps, session, task);
2213
+ }
2214
+ } finally {
2215
+ await releaseLease?.();
2216
+ if (session) {
2217
+ session.busy = false;
2218
+ if (sessions) retainLiveSession(sessions, session, Math.max(1, deps.maxLiveSessions ?? DEFAULT_MAX_LIVE_SESSIONS));
2219
+ }
2220
+ }
2221
+ };
2222
+ if (!agentSettled && pendingRun) {
2223
+ task.sessionBusy = true;
2224
+ void pendingRun.then(finalize).catch((error) => deps.debugLog?.("session", "late session cleanup failed", { error: String(error) }));
2225
+ } else {
2226
+ await finalize();
2227
+ }
1805
2228
  }
1806
2229
  }
1807
2230
  function buildParallelResult(tasks, totalDuration) {
@@ -1838,6 +2261,12 @@ function buildParallelResult(tasks, totalDuration) {
1838
2261
  resultText += `Evidence: ${task.evidence.slice(0, 5).map((entry) => `${entry.type}:${entry.value}`).join("; ")}
1839
2262
  `;
1840
2263
  }
2264
+ if (task.sessionId) {
2265
+ resultText += task.sessionBusy ? `Session: ${task.sessionId} (busy \u2014 previous run is still settling; recall will fail until it settles` : `Session: ${task.sessionId} (resumable \u2014 pass as "sessionId" in a later spawn to continue this subagent with its accumulated context`;
2266
+ if (task.sessionRunCount) resultText += `; run ${task.sessionRunCount}`;
2267
+ if (task.sessionPersisted) resultText += "; persisted to disk, survives restarts";
2268
+ resultText += ")\n";
2269
+ }
1841
2270
  if (task.mode === "edit") {
1842
2271
  resultText += "Parent follow-up: review changes and validate reported tests. Partial edits are preserved on failure; no automatic rollback or task retry.\n";
1843
2272
  } else if (task.needsParentWrite) {
@@ -1862,7 +2291,8 @@ function normalizeTaskDefs(args) {
1862
2291
  return { error: "Invalid spawn_subagents_parallel arguments: tasks must be a non-empty array." };
1863
2292
  }
1864
2293
  const normalized = [];
1865
- const allowedFields = [...TASK_FIELD_ALIASES, "name", "priority", "timeout", "model", "thinkingLevel", "mode"];
2294
+ const allowedFields = [...TASK_FIELD_ALIASES, "name", "priority", "timeout", "model", "thinkingLevel", "mode", "sessionId"];
2295
+ const seenSessionIds = /* @__PURE__ */ new Set();
1866
2296
  for (const [index, def] of taskDefs.entries()) {
1867
2297
  if (!def || typeof def !== "object") {
1868
2298
  return { error: `Invalid task #${index + 1}: expected an object with the objective in "task" (aliases "description"/"prompt"/"objective" accepted).` };
@@ -1895,6 +2325,16 @@ function normalizeTaskDefs(args) {
1895
2325
  if (def.thinkingLevel !== void 0 && (typeof def.thinkingLevel !== "string" || !SUBAGENT_THINKING_LEVELS.includes(def.thinkingLevel))) {
1896
2326
  return { error: `Invalid task #${index + 1}: "thinkingLevel" must be one of ${SUBAGENT_THINKING_LEVELS.map((level) => `"${level}"`).join(", ")}.` };
1897
2327
  }
2328
+ const sessionId = typeof def.sessionId === "string" && def.sessionId.trim() ? def.sessionId.trim() : void 0;
2329
+ if (def.sessionId !== void 0 && !sessionId) {
2330
+ return { error: `Invalid task #${index + 1}: "sessionId" must be a non-empty session id from an earlier result's Session line.` };
2331
+ }
2332
+ if (sessionId) {
2333
+ if (seenSessionIds.has(sessionId)) {
2334
+ return { error: `Invalid task #${index + 1}: duplicate "sessionId" "${sessionId}" \u2014 recalls to the same session must run in separate calls.` };
2335
+ }
2336
+ seenSessionIds.add(sessionId);
2337
+ }
1898
2338
  normalized.push({
1899
2339
  name: typeof def.name === "string" && def.name.trim() ? def.name.trim() : void 0,
1900
2340
  task,
@@ -1902,7 +2342,10 @@ function normalizeTaskDefs(args) {
1902
2342
  mode: def.mode ?? "read",
1903
2343
  timeoutSeconds,
1904
2344
  modelOverride,
1905
- thinkingLevel: def.thinkingLevel
2345
+ thinkingLevel: def.thinkingLevel,
2346
+ sessionId,
2347
+ requestedMode: def.mode,
2348
+ requestedTimeoutSeconds: "timeout" in def ? timeoutSeconds : void 0
1906
2349
  });
1907
2350
  }
1908
2351
  if (normalized.length !== 1 && normalized.some((task) => task.mode === "edit")) {
@@ -1911,25 +2354,7 @@ function normalizeTaskDefs(args) {
1911
2354
  return normalized;
1912
2355
  }
1913
2356
  var DEFAULT_MAX_CONCURRENT_SUBAGENTS = 4;
1914
- var MAX_TRANSIENT_RETRIES = 2;
1915
- var TRANSIENT_RETRY_BACKOFF_MS = 2e3;
1916
- var TRANSIENT_ERROR_PATTERN = /econnreset|econnaborted|etimedout|timeout|enotfound|eai_again|econnrefused|socket hang up|sse stream aborted|fetch failed|status 5\d\d|bad gateway|service unavailable|overloaded/i;
1917
- function isTransientNetworkError(message) {
1918
- return TRANSIENT_ERROR_PATTERN.test(message);
1919
- }
1920
- function sleep(ms, signal) {
1921
- return new Promise((resolve3) => {
1922
- const timer = setTimeout(resolve3, ms);
1923
- signal?.addEventListener(
1924
- "abort",
1925
- () => {
1926
- clearTimeout(timer);
1927
- resolve3();
1928
- },
1929
- { once: true }
1930
- );
1931
- });
1932
- }
2357
+ var taskIdCounter = 0;
1933
2358
  async function runWithConcurrencyLimit(jobs, limit) {
1934
2359
  let next = 0;
1935
2360
  const workers = Array.from({ length: Math.max(1, Math.min(limit, jobs.length)) }, async () => {
@@ -1941,6 +2366,13 @@ async function runWithConcurrencyLimit(jobs, limit) {
1941
2366
  await Promise.all(workers);
1942
2367
  }
1943
2368
  function createSubagentCapability(deps) {
2369
+ const liveSessions = /* @__PURE__ */ new Map();
2370
+ if (deps.sessionStore?.sweep) {
2371
+ try {
2372
+ void Promise.resolve(deps.sessionStore.sweep({ maxAgeMs: DEFAULT_SESSION_STORE_MAX_AGE_MS, maxRecords: DEFAULT_SESSION_STORE_MAX_RECORDS })).catch(() => void 0);
2373
+ } catch {
2374
+ }
2375
+ }
1944
2376
  const spawnParallelTool = {
1945
2377
  name: "spawn_subagents_parallel",
1946
2378
  label: "Spawn Subagents",
@@ -1953,7 +2385,9 @@ Arguments shape (each tasks[] item puts the objective in "task" \u2014 "descript
1953
2385
 
1954
2386
  "model" overrides the automatic model-class selection for that task when the user explicitly asks for a specific model (e.g. "anthropic/claude-..."). Prefer "provider/model-id"; a bare model id works only when it is unambiguous across providers. When the override cannot be resolved, the task falls back to automatic selection and reports why.
1955
2387
 
1956
- "thinkingLevel" defaults to "medium"; set it only when the user asks (e.g. "xhigh" for deep review) or for cheap lookup tasks ("off"). Non-reasoning models ignore it.`,
2388
+ "thinkingLevel" defaults to "medium"; set it only when the user asks (e.g. "xhigh" for deep review) or for cheap lookup tasks ("off"). Non-reasoning models ignore it.
2389
+
2390
+ Sessions: every task that starts execution returns a Session id (persisted to disk when the host supports it). Pass "sessionId" in a later call to continue that subagent with its accumulated context \u2014 cheaper than re-briefing a fresh agent for iterative work on the same area. A recall may override "mode" (including upgrading read\u2192edit), "model", and "thinkingLevel": the session is reconfigured with freshly resolved tools and credentials. Recalls never replay on failure, and a session runs one task at a time. Prefer a FRESH task for independent reviews \u2014 accumulated context anchors judgment, and each recall carries the whole transcript.`,
1957
2391
  parameters: Type.Object({
1958
2392
  tasks: Type.Array(
1959
2393
  Type.Object(
@@ -1967,9 +2401,10 @@ Arguments shape (each tasks[] item puts the objective in "task" \u2014 "descript
1967
2401
  priority: Type.Optional(Type.Union([Type.Literal("fast"), Type.Literal("thorough")], { description: "fast uses lower-cost defaults; thorough raises model class when needed." })),
1968
2402
  model: Type.Optional(Type.String({ description: 'Explicit model override for this task, e.g. "provider/model-id". Only set when the user explicitly requests a specific model; otherwise Pie picks the model class automatically.' })),
1969
2403
  thinkingLevel: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("minimal"), Type.Literal("low"), Type.Literal("medium"), Type.Literal("high"), Type.Literal("xhigh")], { description: `Reasoning level for this task. Default: "medium". Only override when the user asks (e.g. "xhigh" for deep review tasks, "off" for cheap lookups). Ignored by non-reasoning models.` })),
1970
- timeout: Type.Optional(Type.Number({ description: "Timeout in seconds. Default: 1200s (20 minutes). For longer tasks, explicitly set a higher value, e.g. 3600." }))
2404
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds. Default: 1200s (20 minutes). For longer tasks, explicitly set a higher value, e.g. 3600." })),
2405
+ sessionId: Type.Optional(Type.String({ description: "Resume a previous subagent session by id (from an earlier result's Session line). The session's accumulated transcript is reused \u2014 cheaper for iterative work on the same area. Optional mode/model/thinkingLevel on a recall RECONFIGURE the session with freshly resolved tools and credentials (read sessions may be upgraded to edit). Recalls never replay on failure and must target an idle session." }))
1971
2406
  },
1972
- { additionalProperties: false, description: "Objective goes in 'task' (or an accepted alias); plus optional name/priority/model/thinkingLevel/timeout." }
2407
+ { additionalProperties: false, description: "Objective goes in 'task' (or an accepted alias); plus optional name/priority/model/thinkingLevel/timeout/sessionId." }
1973
2408
  ),
1974
2409
  { description: "Array of tasks to run in parallel; at least one of task/description/prompt/objective must be a non-empty string per item.", minItems: 1, maxItems: 10 }
1975
2410
  )
@@ -1996,7 +2431,9 @@ Arguments shape (each tasks[] item puts the objective in "task" \u2014 "descript
1996
2431
  };
1997
2432
  }
1998
2433
  const tasks = normalized.map((def, index) => ({
1999
- id: `subagent-${Date.now()}-${index}`,
2434
+ // Date.now()+index alone collides when two spawn calls land in the
2435
+ // same millisecond (ids double as session ids and store filenames).
2436
+ id: `subagent-${Date.now().toString(36)}-${(++taskIdCounter).toString(36)}${Math.random().toString(36).slice(2, 6)}-${index}`,
2000
2437
  name: def.name || `Task-${index + 1}`,
2001
2438
  status: "pending",
2002
2439
  task: def.task,
@@ -2005,6 +2442,10 @@ Arguments shape (each tasks[] item puts the objective in "task" \u2014 "descript
2005
2442
  timeoutSeconds: def.timeoutSeconds,
2006
2443
  modelOverride: def.modelOverride,
2007
2444
  thinkingLevel: def.thinkingLevel,
2445
+ sessionId: def.sessionId,
2446
+ requestedMode: def.requestedMode,
2447
+ requestedTimeoutSeconds: def.requestedTimeoutSeconds,
2448
+ batchSize: normalized.length,
2008
2449
  evidence: [],
2009
2450
  confidenceSignals: [],
2010
2451
  uncertainty: "medium",
@@ -2018,24 +2459,26 @@ Arguments shape (each tasks[] item puts the objective in "task" \u2014 "descript
2018
2459
  toolContext.reportProgress?.({ message: `Launching ${tasks.length} parallel subagents...`, increment: 5 });
2019
2460
  const startTime = Date.now();
2020
2461
  const runTask = async (task) => {
2021
- for (let attempt = 0; ; attempt++) {
2022
- await runSubagentWithProgress(deps, task, toolContext.reportProgress, signal);
2023
- const canRetry = task.mode === "read" && task.status === "failed" && isTransientNetworkError(task.error ?? "") && attempt < MAX_TRANSIENT_RETRIES && !signal?.aborted;
2024
- if (!canRetry) return;
2025
- const failureReason = task.error ?? "unknown";
2026
- task.evidence.length = 0;
2027
- task.runtimeEvidence.length = 0;
2028
- task.reportedEvidence.length = 0;
2029
- task.confidenceSignals.length = 0;
2030
- task.status = "pending";
2031
- task.error = void 0;
2032
- task.completedAt = void 0;
2033
- const backoffMs = (deps.transientRetryBackoffMs ?? TRANSIENT_RETRY_BACKOFF_MS) * (attempt + 1);
2034
- toolContext.reportProgress?.({ message: `[${task.name}] Transient error, retrying in ${Math.round(backoffMs / 1e3)}s (attempt ${attempt + 2}/${MAX_TRANSIENT_RETRIES + 1})...` });
2035
- deps.log(`[subagent] ${task.name} failed with a transient error (${failureReason}); retrying`);
2036
- await sleep(backoffMs, signal);
2037
- if (signal?.aborted) return;
2038
- }
2462
+ await runSubagentWithProgress(deps, task, toolContext.reportProgress, signal, liveSessions);
2463
+ if (task.wasRecall || task.mode !== "read" || task.status !== "failed" || !task.error) return;
2464
+ if (signal?.aborted) return;
2465
+ if (!isRetryableSubagentFailure(task.error)) return;
2466
+ const settleMs = Math.max(0, (deps.transientRetryBackoffMs ?? 2e3) * 10);
2467
+ toolContext.reportProgress?.({ message: `[${task.name}] Provider/transport failure \u2014 replaying the read task once after ${Math.round(settleMs / 1e3)}s`, increment: 0 });
2468
+ await cancellableBackoff(settleMs, signal);
2469
+ if (signal?.aborted) return;
2470
+ task.evidence = [];
2471
+ task.confidenceSignals = [];
2472
+ task.runtimeEvidence = [];
2473
+ task.reportedEvidence = [];
2474
+ task.error = void 0;
2475
+ task.result = void 0;
2476
+ task.details = void 0;
2477
+ task.parentHandoffRequired = false;
2478
+ task.sessionId = void 0;
2479
+ task.requestedMode = void 0;
2480
+ task.wasRecall = void 0;
2481
+ await runSubagentWithProgress(deps, task, toolContext.reportProgress, signal, liveSessions);
2039
2482
  };
2040
2483
  await runWithConcurrencyLimit(
2041
2484
  tasks.map((task) => () => runTask(task)),
@@ -2053,6 +2496,9 @@ Arguments shape (each tasks[] item puts the objective in "task" \u2014 "descript
2053
2496
  name: task.name,
2054
2497
  status: task.status,
2055
2498
  mode: task.mode,
2499
+ sessionId: task.sessionId,
2500
+ sessionRunCount: task.sessionRunCount,
2501
+ sessionPersisted: task.sessionPersisted,
2056
2502
  task: task.task,
2057
2503
  timeoutSeconds: task.timeoutSeconds,
2058
2504
  plan: task.plan,
@@ -4987,6 +5433,163 @@ function createSessionManager(options) {
4987
5433
  return new SessionManager(options);
4988
5434
  }
4989
5435
 
5436
+ // ../../packages/agent-framework/src/observer.ts
5437
+ function notifyObserver(listener, event) {
5438
+ try {
5439
+ void Promise.resolve(listener(event)).catch(() => {
5440
+ });
5441
+ } catch {
5442
+ }
5443
+ }
5444
+
5445
+ // ../../packages/agent-framework/src/session/controller/end-hook.ts
5446
+ async function runEndHook(hook, context, isCurrent, emit) {
5447
+ if (!hook) return;
5448
+ emit({ type: "agent_end_hook_start", hasQueuedMessages: context.hasQueuedMessages });
5449
+ try {
5450
+ await hook(context);
5451
+ if (isCurrent()) emit({ type: "agent_end_hook_end" });
5452
+ } catch (error) {
5453
+ if (isCurrent()) emit({ type: "agent_end_hook_end", errorMessage: error instanceof Error ? error.message : String(error) });
5454
+ }
5455
+ }
5456
+
5457
+ // ../../packages/agent-framework/src/session/controller/convergence.ts
5458
+ var SessionConvergence = class {
5459
+ constructor(options) {
5460
+ this.options = options;
5461
+ }
5462
+ options;
5463
+ generation = 0;
5464
+ active = false;
5465
+ disposed = false;
5466
+ operations = /* @__PURE__ */ new Set();
5467
+ outcome;
5468
+ monitoring = /* @__PURE__ */ new Set();
5469
+ invalidated;
5470
+ invalidation = new Promise((resolve3) => {
5471
+ this.invalidated = resolve3;
5472
+ });
5473
+ get identity() {
5474
+ return { sessionId: this.options.sessionId(), generation: this.generation };
5475
+ }
5476
+ begin() {
5477
+ if (this.disposed || this.active) return;
5478
+ this.active = true;
5479
+ this.outcome = void 0;
5480
+ this.generation++;
5481
+ notifyObserver(this.options.emit, { type: "session_work_started", ...this.identity });
5482
+ }
5483
+ invalidate(reason) {
5484
+ if (this.disposed) return;
5485
+ if (reason === "dispose") this.disposed = true;
5486
+ this.invalidated();
5487
+ this.invalidation = new Promise((resolve3) => {
5488
+ this.invalidated = resolve3;
5489
+ });
5490
+ this.generation++;
5491
+ this.active = false;
5492
+ this.operations.clear();
5493
+ notifyObserver(this.options.emit, { type: "session_invalidated", ...this.identity, reason });
5494
+ }
5495
+ run(action) {
5496
+ if (this.disposed) return Promise.reject(new Error("Controller disposed"));
5497
+ const wasActive = this.active;
5498
+ const preBeginGeneration = this.generation;
5499
+ this.begin();
5500
+ if (this.disposed) return Promise.reject(new Error("Controller disposed"));
5501
+ if (!wasActive && (!this.active || this.generation !== preBeginGeneration + 1)) {
5502
+ return Promise.resolve();
5503
+ }
5504
+ const generation = this.generation;
5505
+ let resolve3;
5506
+ let reject;
5507
+ const operation = new Promise((yes, no) => {
5508
+ resolve3 = yes;
5509
+ reject = no;
5510
+ });
5511
+ this.operations.add(operation);
5512
+ try {
5513
+ resolve3(action());
5514
+ } catch (error) {
5515
+ reject(error);
5516
+ }
5517
+ void operation.catch(() => {
5518
+ if (generation === this.generation) this.outcome = "failed";
5519
+ }).finally(() => {
5520
+ this.operations.delete(operation);
5521
+ this.check();
5522
+ });
5523
+ this.check();
5524
+ return operation;
5525
+ }
5526
+ observe(event) {
5527
+ if (event.type === "agent_event" && event.event.type === "agent_start") this.begin();
5528
+ if (event.type === "turn_failed_terminal" || event.type === "turn_policy_error" || event.type === "agent_end_hook_end" && event.errorMessage) this.outcome = "failed";
5529
+ if (event.type === "turn_cancelled") this.outcome = "cancelled";
5530
+ this.check();
5531
+ }
5532
+ async wait() {
5533
+ const generation = this.generation;
5534
+ const invalidation = this.invalidation;
5535
+ while (generation === this.generation && !this.disposed) {
5536
+ await Promise.race([Promise.allSettled([...this.operations]), invalidation]);
5537
+ if (generation !== this.generation || this.disposed) return;
5538
+ await Promise.race([this.options.wait(), invalidation]);
5539
+ if (generation !== this.generation || this.disposed) return;
5540
+ if (!this.operations.size && !this.options.busy()) return;
5541
+ }
5542
+ }
5543
+ check() {
5544
+ const generation = this.generation;
5545
+ if (!this.active || this.disposed || this.monitoring.has(generation)) return;
5546
+ this.monitoring.add(generation);
5547
+ void this.wait().then(() => {
5548
+ if (generation !== this.generation || !this.active || this.disposed) return;
5549
+ this.active = false;
5550
+ notifyObserver(this.options.emit, {
5551
+ type: "session_quiescent",
5552
+ ...this.identity,
5553
+ outcome: this.outcome ?? (this.options.queued() ? "blocked" : "success")
5554
+ });
5555
+ }).catch(() => {
5556
+ if (generation !== this.generation || !this.active || this.disposed) return;
5557
+ this.active = false;
5558
+ notifyObserver(this.options.emit, { type: "session_quiescent", ...this.identity, outcome: "failed" });
5559
+ }).finally(() => this.monitoring.delete(generation));
5560
+ }
5561
+ };
5562
+
5563
+ // ../../packages/agent-framework/src/session/controller/pending-operations.ts
5564
+ var PendingEndOperations = class {
5565
+ operations = /* @__PURE__ */ new Set();
5566
+ get pending() {
5567
+ return this.operations.size > 0;
5568
+ }
5569
+ clear() {
5570
+ this.operations.clear();
5571
+ }
5572
+ async wait() {
5573
+ await Promise.allSettled([...this.operations]);
5574
+ }
5575
+ track(action) {
5576
+ let resolve3;
5577
+ let reject;
5578
+ const operation = new Promise((yes, no) => {
5579
+ resolve3 = yes;
5580
+ reject = no;
5581
+ });
5582
+ this.operations.add(operation);
5583
+ void operation.finally(() => this.operations.delete(operation)).catch(() => {
5584
+ });
5585
+ try {
5586
+ resolve3(action());
5587
+ } catch (error) {
5588
+ reject(error);
5589
+ }
5590
+ }
5591
+ };
5592
+
4990
5593
  // ../../packages/agent-framework/src/session/controller/auto-compact-policy.ts
4991
5594
  function defaultSetTimeout(callback, delay) {
4992
5595
  return setTimeout(callback, delay);
@@ -5086,6 +5689,8 @@ var AgentSessionAutoContinueController = class {
5086
5689
  }
5087
5690
  options;
5088
5691
  autoContinueTimer;
5692
+ generation = 0;
5693
+ cancelExecutionWait;
5089
5694
  pendingAutoContinue;
5090
5695
  resolvePendingAutoContinue;
5091
5696
  get hasPendingAutoContinue() {
@@ -5101,21 +5706,30 @@ var AgentSessionAutoContinueController = class {
5101
5706
  this.ensurePending();
5102
5707
  const setTimer = this.options.retry?.setTimeout ?? defaultSetTimeout2;
5103
5708
  const snapshot = messages.slice();
5709
+ const generation = this.generation;
5104
5710
  this.autoContinueTimer = setTimer(() => {
5105
5711
  this.autoContinueTimer = void 0;
5106
- void this.maybeScheduleAutoContinue(snapshot).finally(() => this.resolvePending());
5712
+ void this.maybeScheduleAutoContinue(snapshot, generation).catch((error) => {
5713
+ if (generation === this.generation && !this.options.getDisposed()) {
5714
+ this.options.emit({ type: "turn_policy_error", message: error instanceof Error ? error.message : String(error) });
5715
+ }
5716
+ }).finally(() => {
5717
+ if (generation === this.generation && this.autoContinueTimer === void 0) this.resolvePending();
5718
+ });
5107
5719
  }, 0);
5108
5720
  }
5109
5721
  cancel() {
5110
- if (this.autoContinueTimer === void 0) {
5111
- return;
5722
+ this.generation++;
5723
+ if (this.autoContinueTimer !== void 0) {
5724
+ const clearTimer = this.options.retry?.clearTimeout ?? defaultClearTimeout2;
5725
+ clearTimer(this.autoContinueTimer);
5726
+ this.autoContinueTimer = void 0;
5112
5727
  }
5113
- const clearTimer = this.options.retry?.clearTimeout ?? defaultClearTimeout2;
5114
- clearTimer(this.autoContinueTimer);
5115
- this.autoContinueTimer = void 0;
5728
+ this.cancelExecutionWait?.();
5729
+ this.cancelExecutionWait = void 0;
5116
5730
  this.resolvePending();
5117
5731
  }
5118
- async maybeScheduleAutoContinue(messages) {
5732
+ async maybeScheduleAutoContinue(messages, generation) {
5119
5733
  if (!this.options.getAutoContinueMessage || this.options.getDisposed() || this.options.getRuntimeGuardTriggered() || this.options.isBlockedByRuntimePolicy() || this.options.hasQueuedMessages() || this.options.isStreaming()) {
5120
5734
  if (this.options.hasQueuedMessages()) {
5121
5735
  this.options.emit({ type: "auto_continue_skipped", reason: "queued_messages" });
@@ -5134,6 +5748,7 @@ var AgentSessionAutoContinueController = class {
5134
5748
  messages,
5135
5749
  hasQueuedMessages: this.options.hasQueuedMessages()
5136
5750
  });
5751
+ if (generation !== this.generation || this.options.getDisposed() || this.options.getRuntimeGuardTriggered()) return;
5137
5752
  const message = result && "role" in result ? result : result?.message;
5138
5753
  const reason = result && "role" in result ? void 0 : result?.reason;
5139
5754
  if (!message) {
@@ -5148,9 +5763,11 @@ var AgentSessionAutoContinueController = class {
5148
5763
  this.options.agent.followUp(message);
5149
5764
  const setTimer = this.options.retry?.setTimeout ?? defaultSetTimeout2;
5150
5765
  await new Promise((resolve3) => {
5766
+ this.cancelExecutionWait = resolve3;
5151
5767
  this.autoContinueTimer = setTimer(() => {
5152
5768
  this.autoContinueTimer = void 0;
5153
- if (this.options.getDisposed()) {
5769
+ this.cancelExecutionWait = void 0;
5770
+ if (generation !== this.generation || this.options.getDisposed()) {
5154
5771
  resolve3();
5155
5772
  return;
5156
5773
  }
@@ -5203,7 +5820,7 @@ async function runAgentSessionCompaction(params) {
5203
5820
  throw new Error("No compaction handler configured");
5204
5821
  }
5205
5822
  const abortedResult = () => ({ aborted: true });
5206
- const isAborted = () => params.signal?.aborted === true;
5823
+ const isAborted = () => params.signal?.aborted === true || params.isCurrent?.() === false;
5207
5824
  params.emit({ type: "auto_compaction_start", reason: params.reason, willRetry: params.willRetry });
5208
5825
  try {
5209
5826
  if (isAborted()) {
@@ -5344,7 +5961,7 @@ async function runPreRequestAutoCompact(params) {
5344
5961
  try {
5345
5962
  await params.runAutoCompact();
5346
5963
  } catch {
5347
- params.setRecoveryState("none");
5964
+ if (params.isCurrent?.() !== false) params.setRecoveryState("none");
5348
5965
  }
5349
5966
  }
5350
5967
 
@@ -5518,6 +6135,7 @@ var AgentSessionQueueDispatcher = class {
5518
6135
  }
5519
6136
  options;
5520
6137
  dispatchTimer;
6138
+ generation = 0;
5521
6139
  pendingDispatch;
5522
6140
  resolvePendingDispatch;
5523
6141
  get hasPendingDispatch() {
@@ -5532,25 +6150,32 @@ var AgentSessionQueueDispatcher = class {
5532
6150
  }
5533
6151
  this.ensurePending();
5534
6152
  const setTimer = this.options.retry?.setTimeout ?? defaultSetTimeout3;
6153
+ const generation = this.generation;
5535
6154
  this.dispatchTimer = setTimer(() => {
5536
6155
  this.dispatchTimer = void 0;
5537
- void this.dispatchNext().finally(() => this.resolvePending());
6156
+ void this.dispatchNext().catch((error) => {
6157
+ if (generation === this.generation) this.options.emit({ type: "turn_policy_error", message: String(error) });
6158
+ }).finally(() => {
6159
+ if (generation === this.generation) this.resolvePending();
6160
+ });
5538
6161
  }, delayMs);
5539
6162
  }
5540
6163
  cancel() {
5541
- if (this.dispatchTimer === void 0) {
5542
- return;
6164
+ this.generation++;
6165
+ if (this.dispatchTimer !== void 0) {
6166
+ const clearTimer = this.options.retry?.clearTimeout ?? defaultClearTimeout3;
6167
+ clearTimer(this.dispatchTimer);
6168
+ this.dispatchTimer = void 0;
5543
6169
  }
5544
- const clearTimer = this.options.retry?.clearTimeout ?? defaultClearTimeout3;
5545
- clearTimer(this.dispatchTimer);
5546
- this.dispatchTimer = void 0;
5547
6170
  this.resolvePending();
5548
6171
  }
5549
6172
  async dispatchNext() {
6173
+ const generation = this.generation;
5550
6174
  if (this.options.getDisposed() || this.options.isStreaming() || this.options.isBlockedByRuntimePolicy()) {
5551
6175
  return false;
5552
6176
  }
5553
6177
  await this.options.agent.waitForIdle();
6178
+ if (generation !== this.generation) return false;
5554
6179
  if (this.options.getDisposed() || this.options.isStreaming() || this.options.isBlockedByRuntimePolicy()) {
5555
6180
  this.schedule(100);
5556
6181
  return false;
@@ -5565,6 +6190,7 @@ var AgentSessionQueueDispatcher = class {
5565
6190
  cwd: this.options.cwd,
5566
6191
  queuedInput: cloneQueuedInput(queuedInput)
5567
6192
  });
6193
+ if (generation !== this.generation) return false;
5568
6194
  const dispatchDecision = typeof decision === "boolean" ? { allowed: decision } : decision ?? { allowed: true };
5569
6195
  if (!dispatchDecision.allowed) {
5570
6196
  this.options.emit({
@@ -5578,6 +6204,7 @@ var AgentSessionQueueDispatcher = class {
5578
6204
  return false;
5579
6205
  }
5580
6206
  await this.options.runPreRequestAutoCompact();
6207
+ if (generation !== this.generation) return false;
5581
6208
  if (this.options.getDisposed() || this.options.isStreaming() || this.options.isBlockedByRuntimePolicy()) {
5582
6209
  this.schedule(100);
5583
6210
  return false;
@@ -5601,6 +6228,7 @@ var AgentSessionQueueDispatcher = class {
5601
6228
  clearEditor: dispatched.clearEditor,
5602
6229
  visibility: dispatched.visibility
5603
6230
  });
6231
+ if (generation !== this.generation) return false;
5604
6232
  if (!prepared.handled) {
5605
6233
  if (prepared.systemPrompt !== void 0) {
5606
6234
  this.options.agent.setSystemPrompt(prepared.systemPrompt);
@@ -5716,6 +6344,7 @@ var AgentSessionRetryController = class {
5716
6344
  /** Total attempts across kinds in this recovery episode, for reporting. */
5717
6345
  totalRetryAttempts = 0;
5718
6346
  retryTimer;
6347
+ retryEpoch = 0;
5719
6348
  pendingRetry;
5720
6349
  resolvePendingRetry;
5721
6350
  get policy() {
@@ -5771,18 +6400,26 @@ var AgentSessionRetryController = class {
5771
6400
  const rawDelayMs = (this.retry.baseDelayMs ?? 2e3) * 2 ** (attempt - 1);
5772
6401
  const delayMs = this.retry.maxDelayMs !== void 0 ? Math.min(rawDelayMs, this.retry.maxDelayMs) : rawDelayMs;
5773
6402
  this.ensurePending();
6403
+ const epoch = this.retryEpoch;
6404
+ const pending = this.pendingRetry;
5774
6405
  this.setRecoveryState("retry_pending");
5775
6406
  this.emit({ type: "turn_recovery_pending", recovery: "retry", failure, attempt, maxRetries, delayMs });
6407
+ if (epoch !== this.retryEpoch) return;
5776
6408
  this.emit({ type: "retry_scheduled", errorMessage: failure.message, attempt, maxRetries, delayMs });
6409
+ if (epoch !== this.retryEpoch) return;
5777
6410
  this.cancelTimerOnly();
5778
6411
  const setTimer = this.retry.setTimeout ?? defaultSetTimeout4;
5779
6412
  this.retryTimer = setTimer(() => {
6413
+ if (epoch !== this.retryEpoch) return;
5780
6414
  this.retryTimer = void 0;
5781
6415
  this.setRecoveryState("retrying");
5782
6416
  this.emit({ type: "retry_start", attempt, maxRetries });
6417
+ if (epoch !== this.retryEpoch) return;
5783
6418
  this.emit({ type: "turn_retry_started", attempt, maxRetries, failure });
6419
+ if (epoch !== this.retryEpoch) return;
5784
6420
  this.removeTrailingAssistantError();
5785
6421
  void this.continueTurn().catch((error) => {
6422
+ if (epoch !== this.retryEpoch || pending !== this.pendingRetry) return;
5786
6423
  const errorMessage = error instanceof Error ? error.message : String(error);
5787
6424
  const terminalFailure = asTerminalFailure(failure, errorMessage);
5788
6425
  this.emit({
@@ -5794,7 +6431,7 @@ var AgentSessionRetryController = class {
5794
6431
  this.emit({ type: "turn_settled" });
5795
6432
  this.resetAttempts();
5796
6433
  this.setRecoveryState("terminal");
5797
- this.resolvePending();
6434
+ this.resolvePending(pending);
5798
6435
  });
5799
6436
  }, delayMs);
5800
6437
  }
@@ -5826,6 +6463,7 @@ var AgentSessionRetryController = class {
5826
6463
  this.resolvePending();
5827
6464
  }
5828
6465
  cancel() {
6466
+ this.retryEpoch++;
5829
6467
  this.cancelTimerOnly();
5830
6468
  this.resetAttempts();
5831
6469
  this.resolvePending();
@@ -5850,7 +6488,8 @@ var AgentSessionRetryController = class {
5850
6488
  this.resolvePendingRetry = resolve3;
5851
6489
  });
5852
6490
  }
5853
- resolvePending() {
6491
+ resolvePending(expected = this.pendingRetry) {
6492
+ if (expected !== this.pendingRetry) return;
5854
6493
  const resolve3 = this.resolvePendingRetry;
5855
6494
  this.pendingRetry = void 0;
5856
6495
  this.resolvePendingRetry = void 0;
@@ -6015,9 +6654,14 @@ function classifyControllerRuntimeError(params) {
6015
6654
  return createFailureClassification({ errorMessage, phase: params.phase }, params.retry, params.classifyFailure);
6016
6655
  }
6017
6656
  async function runAgentSessionRuntimePhase(params) {
6657
+ const startedGeneration = params.getCurrentGeneration?.();
6658
+ const isStale = () => params.getCurrentGeneration !== void 0 && startedGeneration !== void 0 && params.getCurrentGeneration() !== startedGeneration;
6018
6659
  try {
6019
6660
  return await params.action();
6020
6661
  } catch (error) {
6662
+ if (isStale()) {
6663
+ throw error;
6664
+ }
6021
6665
  const failure = classifyControllerRuntimeError({
6022
6666
  error,
6023
6667
  phase: params.phase,
@@ -6179,6 +6823,8 @@ async function handleAgentSessionTurnEndRuntimePolicies(params) {
6179
6823
  if (params.disposed || params.runtimeGuardTriggered) {
6180
6824
  return;
6181
6825
  }
6826
+ const startedGeneration = params.getCurrentGeneration?.();
6827
+ const isStale = () => params.getCurrentGeneration !== void 0 && params.getCurrentGeneration() !== startedGeneration;
6182
6828
  const failure = classifyControllerTurnFailure({
6183
6829
  message: params.message,
6184
6830
  retry: params.retry,
@@ -6198,6 +6844,7 @@ async function handleAgentSessionTurnEndRuntimePolicies(params) {
6198
6844
  message: params.message,
6199
6845
  messages: params.agent.state.messages.slice()
6200
6846
  });
6847
+ if (isStale()) return;
6201
6848
  if (failure?.compactable) {
6202
6849
  params.scheduleAutoCompact(compactDecision ?? { reason: "overflow", willRetry: true }, failure);
6203
6850
  return;
@@ -6216,14 +6863,18 @@ async function handleAgentSessionTurnEndRuntimePolicies(params) {
6216
6863
  return;
6217
6864
  }
6218
6865
  await params.handleRetryTurnEnd(params.message, failure);
6866
+ if (isStale()) return;
6219
6867
  params.setRecoveryState("terminal");
6220
6868
  params.emit({ type: "turn_failed_terminal", failure: asTerminalFailure(failure), message: params.message });
6869
+ if (isStale()) return;
6221
6870
  params.emit({ type: "turn_settled" });
6222
6871
  return;
6223
6872
  }
6224
6873
  await params.handleRetryTurnEnd(params.message);
6874
+ if (isStale()) return;
6225
6875
  if (isAssistantTerminalCompletion(params.message)) {
6226
6876
  params.emit({ type: "turn_completed", message: params.message, toolResults: params.toolResults });
6877
+ if (isStale()) return;
6227
6878
  const postTurnCompactDecision = compactDecision ?? params.getAutoCompactDecision?.({
6228
6879
  agent: params.agent,
6229
6880
  sessionManager: params.sessionManager,
@@ -6231,6 +6882,7 @@ async function handleAgentSessionTurnEndRuntimePolicies(params) {
6231
6882
  message: params.message,
6232
6883
  messages: params.agent.state.messages.slice()
6233
6884
  });
6885
+ if (isStale()) return;
6234
6886
  if (postTurnCompactDecision?.reason === "threshold") {
6235
6887
  params.scheduleAutoCompact(postTurnCompactDecision);
6236
6888
  return;
@@ -6250,6 +6902,7 @@ async function runAgentSessionControllerAutoCompact(params) {
6250
6902
  reason: params.reason,
6251
6903
  willRetry: params.willRetry,
6252
6904
  signal: params.signal,
6905
+ isCurrent: params.isCurrent,
6253
6906
  compaction: params.compaction,
6254
6907
  agent: params.agent,
6255
6908
  sessionManager: params.sessionManager,
@@ -6260,6 +6913,7 @@ async function runAgentSessionControllerAutoCompact(params) {
6260
6913
  markSyncedToAgentState: params.markSyncedToAgentState,
6261
6914
  continueTurn: params.continueTurn
6262
6915
  });
6916
+ if (params.isCurrent?.() === false) return result;
6263
6917
  if (!params.willRetry || result?.aborted) {
6264
6918
  const alreadyCancelled = params.getRecoveryState?.() === "cancelled" || params.getDisposed?.() === true;
6265
6919
  params.setRecoveryState(result?.aborted && cancelOnAbort ? "cancelled" : "none");
@@ -6273,11 +6927,12 @@ async function runAgentSessionControllerAutoCompact(params) {
6273
6927
  params.emit({ type: "turn_settled" });
6274
6928
  }
6275
6929
  if (!result?.aborted && runAfterAgentEndHook) {
6276
- void params.handleAgentEndHook(params.agent.state.messages.slice());
6930
+ await params.handleAgentEndHook(params.agent.state.messages.slice());
6277
6931
  }
6278
6932
  }
6279
6933
  return result;
6280
6934
  } catch (error) {
6935
+ if (params.isCurrent?.() === false) return { aborted: true };
6281
6936
  const errorMessage = error instanceof Error ? error.message : String(error);
6282
6937
  if (!params.willRetry && params.reason !== "overflow") {
6283
6938
  params.setRecoveryState("none");
@@ -6285,7 +6940,7 @@ async function runAgentSessionControllerAutoCompact(params) {
6285
6940
  params.emit({ type: "turn_settled" });
6286
6941
  }
6287
6942
  if (runAfterAgentEndHook) {
6288
- void params.handleAgentEndHook(params.agent.state.messages.slice());
6943
+ await params.handleAgentEndHook(params.agent.state.messages.slice());
6289
6944
  }
6290
6945
  throw error;
6291
6946
  }
@@ -6303,7 +6958,7 @@ async function runAgentSessionControllerAutoCompact(params) {
6303
6958
  params.emit({ type: "turn_settled" });
6304
6959
  throw error;
6305
6960
  } finally {
6306
- params.clearPendingFailure();
6961
+ if (params.isCurrent?.() !== false) params.clearPendingFailure();
6307
6962
  }
6308
6963
  }
6309
6964
 
@@ -6324,8 +6979,22 @@ var AgentSessionController = class {
6324
6979
  unsubscribeSemantic;
6325
6980
  lastSyncedMessageCount = 0;
6326
6981
  disposed = false;
6982
+ transitioning = false;
6983
+ generation = 0;
6984
+ convergence = new SessionConvergence({
6985
+ sessionId: () => this.sessionManager.getActiveSession()?.id,
6986
+ wait: () => this.waitForRuntimePolicies(),
6987
+ busy: () => this.isStreaming || this.hasPendingRuntimePolicy(),
6988
+ queued: () => this.hasQueuedMessages,
6989
+ emit: (event) => this.emit(event)
6990
+ });
6991
+ get workIdentity() {
6992
+ return this.convergence.identity;
6993
+ }
6994
+ pendingEndOperations = new PendingEndOperations();
6327
6995
  baseSystemPrompt;
6328
6996
  prepareTurn;
6997
+ onExecutionEnd;
6329
6998
  afterAgentEnd;
6330
6999
  getAutoCompactDecision;
6331
7000
  classifyFailure;
@@ -6340,6 +7009,7 @@ var AgentSessionController = class {
6340
7009
  this.baseSystemPrompt = options.baseSystemPrompt ?? this.agent.state.systemPrompt;
6341
7010
  this.prepareTurn = options.prepareTurn;
6342
7011
  this.afterAgentEnd = options.afterAgentEnd;
7012
+ this.onExecutionEnd = options.onExecutionEnd;
6343
7013
  this.getAutoCompactDecision = options.getAutoCompactDecision;
6344
7014
  this.classifyFailure = options.classifyFailure;
6345
7015
  if (options.onEvent) {
@@ -6417,7 +7087,7 @@ var AgentSessionController = class {
6417
7087
  return this.agent.state.isStreaming;
6418
7088
  }
6419
7089
  get hasQueuedMessages() {
6420
- return this.queue.hasQueuedMessages;
7090
+ return this.queue.hasQueuedMessages || this.agent.hasQueuedMessages();
6421
7091
  }
6422
7092
  get pendingMessageCount() {
6423
7093
  return this.queue.length;
@@ -6449,18 +7119,32 @@ var AgentSessionController = class {
6449
7119
  };
6450
7120
  }
6451
7121
  subscribe(listener) {
7122
+ if (this.disposed) return () => {
7123
+ };
6452
7124
  this.listeners.add(listener);
6453
7125
  return () => this.listeners.delete(listener);
6454
7126
  }
6455
7127
  syncFromActiveSession(messages) {
6456
- const activeSession = this.sessionManager.getActiveSession();
6457
- this.agent.sessionId = activeSession?.id;
6458
- const sessionMessages = messages ?? this.sessionManager.getMessages();
6459
- const conversationMessages = getConversationMessages(sessionMessages);
6460
- this.agent.replaceMessages(conversationMessages);
6461
- this.lastSyncedMessageCount = conversationMessages.length;
6462
- this.queue.clear();
6463
- this.recordLifecycle({ type: "session_synced" });
7128
+ if (this.disposed || this.transitioning) return;
7129
+ this.transitioning = true;
7130
+ try {
7131
+ this.generation++;
7132
+ this.cancelScheduledWork();
7133
+ this.recoveryState = "none";
7134
+ this.pendingCompactionFailure = void 0;
7135
+ this.pendingEndOperations.clear();
7136
+ const activeSession = this.sessionManager.getActiveSession();
7137
+ this.agent.sessionId = activeSession?.id;
7138
+ const sessionMessages = messages ?? this.sessionManager.getMessages();
7139
+ const conversationMessages = getConversationMessages(sessionMessages);
7140
+ this.agent.replaceMessages(conversationMessages);
7141
+ this.lastSyncedMessageCount = conversationMessages.length;
7142
+ this.queue.clear();
7143
+ this.recordLifecycle({ type: "session_synced" });
7144
+ this.convergence.invalidate("sync");
7145
+ } finally {
7146
+ this.transitioning = false;
7147
+ }
6464
7148
  }
6465
7149
  markSyncedToAgentState() {
6466
7150
  this.lastSyncedMessageCount = this.agent.state.messages.length;
@@ -6478,7 +7162,12 @@ var AgentSessionController = class {
6478
7162
  this.agent.setTools(context.tools);
6479
7163
  }
6480
7164
  }
6481
- async prompt(input, options) {
7165
+ prompt(input, options) {
7166
+ if (this.disposed || this.transitioning) return Promise.reject(new Error("Controller disposed or transitioning"));
7167
+ return this.convergence.run(() => this.promptInternal(input, options));
7168
+ }
7169
+ async promptInternal(input, options) {
7170
+ const generation = this.generation;
6482
7171
  const messages = normalizePromptInput(input);
6483
7172
  if (this.isRecoveryActive()) {
6484
7173
  this.queue.enqueue(messages, options?.streamingBehavior ?? "followUp", {
@@ -6503,8 +7192,9 @@ var AgentSessionController = class {
6503
7192
  throw new Error("Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.");
6504
7193
  }
6505
7194
  await this.runPreRequestAutoCompactIfNeeded();
7195
+ if (generation !== this.generation || this.disposed) return;
6506
7196
  const prepared = await this.runRuntimePhase("prepare", () => this.preparePromptMessages(messages, options));
6507
- if (prepared.handled) {
7197
+ if (generation !== this.generation || this.disposed || prepared.handled) {
6508
7198
  return;
6509
7199
  }
6510
7200
  this.recordLifecycle({ type: "prompt_started" });
@@ -6512,45 +7202,66 @@ var AgentSessionController = class {
6512
7202
  this.agent.setSystemPrompt(prepared.systemPrompt);
6513
7203
  }
6514
7204
  await this.runRuntimePhase("request", () => this.agent.prompt(prepared.messages));
7205
+ if (generation !== this.generation || this.disposed) return;
6515
7206
  await this.runRuntimePhase("post_turn", () => this.syncNewAgentMessages());
6516
7207
  }
6517
- async continue() {
7208
+ continue() {
7209
+ return this.disposed || this.transitioning ? Promise.reject(new Error("Controller disposed or transitioning")) : this.convergence.run(() => this.continueInternal());
7210
+ }
7211
+ async continueInternal() {
7212
+ const generation = this.generation;
6518
7213
  if (this.isStreaming) {
6519
7214
  throw new Error("Agent is already processing. Wait for completion before continuing.");
6520
7215
  }
6521
7216
  await this.runPreRequestAutoCompactIfNeeded();
7217
+ if (generation !== this.generation || this.disposed) return;
6522
7218
  this.recordLifecycle({ type: "prompt_started" });
6523
7219
  await this.runRuntimePhase("request", () => this.agent.continue());
7220
+ if (generation !== this.generation || this.disposed) return;
6524
7221
  await this.runRuntimePhase("post_turn", () => this.syncNewAgentMessages());
6525
7222
  }
6526
7223
  abort() {
6527
- this.recordLifecycle({ type: "abort" });
6528
- const cancelPendingRecovery = this.isRecoveryActive() && !this.isStreaming;
6529
- this.retryController.cancel();
6530
- this.autoCompact.cancel();
6531
- this.queueDispatcher.cancel();
6532
- this.autoContinue.cancel();
6533
- if (cancelPendingRecovery) {
6534
- this.recoveryState = "cancelled";
6535
- this.emit({ type: "turn_cancelled", reason: "user_abort" });
6536
- this.emit({ type: "turn_settled" });
7224
+ if (this.disposed || this.transitioning) return;
7225
+ this.transitioning = true;
7226
+ try {
7227
+ this.generation++;
7228
+ this.pendingEndOperations.clear();
7229
+ this.recordLifecycle({ type: "abort" });
7230
+ const cancelPendingRecovery = this.isRecoveryActive() && !this.isStreaming;
7231
+ this.cancelScheduledWork();
7232
+ if (cancelPendingRecovery) {
7233
+ this.recoveryState = "cancelled";
7234
+ this.emit({ type: "turn_cancelled", reason: "user_abort" });
7235
+ this.emit({ type: "turn_settled" });
7236
+ }
7237
+ this.agent.abort();
7238
+ this.convergence.invalidate("abort");
7239
+ } finally {
7240
+ this.transitioning = false;
6537
7241
  }
6538
- this.agent.abort();
6539
7242
  }
6540
7243
  waitForIdle() {
6541
7244
  return this.agent.waitForIdle();
6542
7245
  }
6543
- async waitForSettled() {
6544
- while (true) {
7246
+ waitForSettled() {
7247
+ return this.convergence.wait();
7248
+ }
7249
+ async waitForRuntimePolicies() {
7250
+ const generation = this.generation;
7251
+ while (!this.disposed && generation === this.generation) {
6545
7252
  await this.agent.waitForIdle();
6546
- if (!this.hasPendingRuntimePolicy()) {
7253
+ if (this.disposed || generation !== this.generation || !this.hasPendingRuntimePolicy()) {
6547
7254
  return;
6548
7255
  }
6549
- await Promise.all([this.retryController.waitForRetry(), this.autoCompact.waitForAutoCompact(), this.autoContinue.waitForAutoContinue(), this.queueDispatcher.waitForDispatch()]);
7256
+ await Promise.all([this.pendingEndOperations.wait(), this.retryController.waitForRetry(), this.autoCompact.waitForAutoCompact(), this.autoContinue.waitForAutoContinue(), this.queueDispatcher.waitForDispatch()]);
6550
7257
  }
6551
7258
  }
6552
7259
  async runAutoCompact(reason, willRetry = false, options) {
6553
- return runAgentSessionControllerAutoCompact({
7260
+ const generation = this.generation;
7261
+ if (this.disposed || this.transitioning) throw new Error("Controller disposed or transitioning");
7262
+ const isCurrent = () => generation === this.generation && !this.disposed;
7263
+ return this.convergence.run(() => runAgentSessionControllerAutoCompact({
7264
+ isCurrent,
6554
7265
  reason,
6555
7266
  willRetry,
6556
7267
  signal: options?.signal,
@@ -6559,7 +7270,9 @@ var AgentSessionController = class {
6559
7270
  sessionManager: this.sessionManager,
6560
7271
  cwd: this.cwd,
6561
7272
  pendingFailure: this.pendingCompactionFailure,
6562
- emit: (event) => this.emit(event),
7273
+ emit: (event) => {
7274
+ if (isCurrent()) this.emit(event);
7275
+ },
6563
7276
  setRecoveryState: (state) => {
6564
7277
  this.recoveryState = state;
6565
7278
  },
@@ -6577,19 +7290,19 @@ var AgentSessionController = class {
6577
7290
  emitSettled: options?.emitSettled,
6578
7291
  runAfterAgentEndHook: options?.runAfterAgentEndHook,
6579
7292
  cancelOnAbort: options?.cancelOnAbort
6580
- });
7293
+ }));
6581
7294
  }
6582
7295
  dispose() {
6583
7296
  if (this.disposed) return;
6584
7297
  this.disposed = true;
6585
- this.retryController.cancel();
6586
- this.autoCompact.cancel();
6587
- this.queueDispatcher.cancel();
6588
- this.autoContinue.cancel();
7298
+ this.generation++;
7299
+ this.pendingEndOperations.clear();
7300
+ this.cancelScheduledWork();
6589
7301
  this.unsubscribeAgent?.();
6590
7302
  this.unsubscribeSemantic?.();
6591
- this.listeners.clear();
6592
7303
  this.queue.clear();
7304
+ this.convergence.invalidate("dispose");
7305
+ this.listeners.clear();
6593
7306
  }
6594
7307
  drainQueuedInputsForEditor() {
6595
7308
  return this.queue.drainForEditor();
@@ -6604,10 +7317,16 @@ var AgentSessionController = class {
6604
7317
  this.queue.replaceText(search, replacement);
6605
7318
  }
6606
7319
  requestQueueDispatch() {
6607
- if (this.queue.length > 0) this.queueDispatcher.schedule();
7320
+ if (!this.disposed && !this.transitioning && this.queue.length > 0) {
7321
+ void this.convergence.run(async () => {
7322
+ this.queueDispatcher.schedule();
7323
+ });
7324
+ }
6608
7325
  }
6609
7326
  async runPreRequestAutoCompactIfNeeded() {
7327
+ const generation = this.generation;
6610
7328
  return runPreRequestAutoCompact({
7329
+ isCurrent: () => generation === this.generation && !this.disposed,
6611
7330
  disposed: this.disposed,
6612
7331
  runtimeGuardTriggered: this.runtimeGuard.triggered,
6613
7332
  isStreaming: this.isStreaming,
@@ -6639,24 +7358,32 @@ var AgentSessionController = class {
6639
7358
  this.queue.markMessageDelivered(event.message);
6640
7359
  }
6641
7360
  if (event.type === "turn_end") {
6642
- void this.syncNewAgentMessages().catch((error) => {
6643
- console.error("[AgentSessionController] Session sync failed after turn:", error);
6644
- });
7361
+ this.pendingEndOperations.track(() => this.syncNewAgentMessages().catch((error) => {
7362
+ this.emit({ type: "turn_policy_error", message: String(error) });
7363
+ }));
6645
7364
  this.recordLifecycle({ type: "turn_end_success" });
6646
- void this.handleTurnEndRuntimePolicies(event.message, event.toolResults).catch((error) => handleTurnEndPolicyThrow((event2) => this.emit(event2), error, {
6647
- recoveryWasActive: this.isRecoveryActive(),
6648
- cancelPendingRecovery: () => {
6649
- this.retryController.cancel();
6650
- this.autoCompact.cancel();
6651
- },
6652
- markRecoveryTerminal: () => {
6653
- this.recoveryState = "terminal";
6654
- }
7365
+ const generation = this.generation;
7366
+ this.pendingEndOperations.track(() => this.handleTurnEndRuntimePolicies(event.message, event.toolResults).catch((error) => {
7367
+ if (generation !== this.generation || this.disposed) return;
7368
+ handleTurnEndPolicyThrow((event2) => this.emit(event2), error, {
7369
+ recoveryWasActive: this.isRecoveryActive(),
7370
+ cancelPendingRecovery: () => {
7371
+ this.retryController.cancel();
7372
+ this.autoCompact.cancel();
7373
+ },
7374
+ markRecoveryTerminal: () => {
7375
+ this.recoveryState = "terminal";
7376
+ }
7377
+ });
6655
7378
  }));
6656
7379
  }
6657
7380
  if (event.type === "agent_end") {
6658
7381
  this.recordLifecycle({ type: "agent_end" });
6659
- void this.handleAgentEndHook(event.messages);
7382
+ const generation = this.generation;
7383
+ this.pendingEndOperations.track(async () => {
7384
+ await runEndHook(this.onExecutionEnd, { messages: event.messages, agent: this.agent, sessionManager: this.sessionManager, cwd: this.cwd, hasQueuedMessages: this.hasQueuedMessages }, () => generation === this.generation && !this.disposed, (e) => this.emit(e));
7385
+ if (generation === this.generation && !this.disposed) await this.handleAgentEndHook(event.messages);
7386
+ });
6660
7387
  this.emit({ type: "streaming_changed", isStreaming: false });
6661
7388
  }
6662
7389
  }
@@ -6702,6 +7429,7 @@ var AgentSessionController = class {
6702
7429
  getAutoCompactDecision: this.getAutoCompactDecision,
6703
7430
  classifyFailure: this.classifyFailure,
6704
7431
  retry: this.retryController.policy,
7432
+ getCurrentGeneration: () => this.generation,
6705
7433
  emit: (event) => this.emit(event),
6706
7434
  setRecoveryState: (state) => {
6707
7435
  this.recoveryState = state;
@@ -6712,27 +7440,12 @@ var AgentSessionController = class {
6712
7440
  });
6713
7441
  }
6714
7442
  async handleAgentEndHook(messages) {
7443
+ const generation = this.generation;
6715
7444
  if (this.disposed || this.runtimeGuard.triggered || this.isBlockedByRuntimePolicy()) {
6716
7445
  return;
6717
7446
  }
6718
- if (this.afterAgentEnd) {
6719
- this.emit({ type: "agent_end_hook_start", hasQueuedMessages: this.hasQueuedMessages });
6720
- try {
6721
- await this.afterAgentEnd({
6722
- messages,
6723
- agent: this.agent,
6724
- sessionManager: this.sessionManager,
6725
- cwd: this.cwd,
6726
- hasQueuedMessages: this.hasQueuedMessages
6727
- });
6728
- this.emit({ type: "agent_end_hook_end" });
6729
- } catch (error) {
6730
- this.emit({
6731
- type: "agent_end_hook_end",
6732
- errorMessage: error instanceof Error ? error.message : String(error)
6733
- });
6734
- }
6735
- }
7447
+ await runEndHook(this.afterAgentEnd, { messages, agent: this.agent, sessionManager: this.sessionManager, cwd: this.cwd, hasQueuedMessages: this.hasQueuedMessages }, () => generation === this.generation && !this.disposed, (event) => this.emit(event));
7448
+ if (generation !== this.generation || this.disposed) return;
6736
7449
  if (this.queue.length > 0) {
6737
7450
  this.queueDispatcher.schedule();
6738
7451
  }
@@ -6753,6 +7466,7 @@ var AgentSessionController = class {
6753
7466
  action,
6754
7467
  retry: this.retryController.policy,
6755
7468
  classifyFailure: this.classifyFailure,
7469
+ getCurrentGeneration: () => this.generation,
6756
7470
  emit: (event) => this.emit(event),
6757
7471
  setRecoveryState: (state) => {
6758
7472
  this.recoveryState = state;
@@ -6765,8 +7479,14 @@ var AgentSessionController = class {
6765
7479
  isBlockedByRuntimePolicy() {
6766
7480
  return this.recoveryState !== "none" || this.retryController.hasPendingRetry || this.autoCompact.hasPendingAutoCompact;
6767
7481
  }
7482
+ cancelScheduledWork() {
7483
+ this.retryController.cancel();
7484
+ this.autoCompact.cancel();
7485
+ this.queueDispatcher.cancel();
7486
+ this.autoContinue.cancel();
7487
+ }
6768
7488
  hasPendingRuntimePolicy() {
6769
- return this.retryController.hasPendingRetry || this.autoCompact.hasPendingAutoCompact || this.autoContinue.hasPendingAutoContinue || this.queueDispatcher.hasPendingDispatch;
7489
+ return this.pendingEndOperations.pending || this.retryController.hasPendingRetry || this.autoCompact.hasPendingAutoCompact || this.autoContinue.hasPendingAutoContinue || this.queueDispatcher.hasPendingDispatch;
6770
7490
  }
6771
7491
  scheduleAutoCompact(decision, failure) {
6772
7492
  this.recordLifecycle({ type: "auto_compact_scheduled" });
@@ -6784,10 +7504,7 @@ var AgentSessionController = class {
6784
7504
  if (this.runtimeGuard.triggered) {
6785
7505
  this.runtimeGuard.markTriggered();
6786
7506
  }
6787
- this.retryController.cancel();
6788
- this.autoCompact.cancel();
6789
- this.queueDispatcher.cancel();
6790
- this.autoContinue.cancel();
7507
+ this.cancelScheduledWork();
6791
7508
  this.recoveryState = "terminal";
6792
7509
  this.emit({ type: "runtime_guard_triggered", toolName, repeatCount, reason });
6793
7510
  this.emit({
@@ -6798,13 +7515,13 @@ var AgentSessionController = class {
6798
7515
  this.agent.abort();
6799
7516
  }
6800
7517
  emit(event) {
7518
+ if (!event.type.startsWith("session_work_") && event.type !== "session_quiescent" && event.type !== "session_invalidated") this.convergence.observe(event);
6801
7519
  for (const listener of this.listeners) {
6802
- listener(event);
7520
+ notifyObserver(listener, event);
6803
7521
  }
6804
7522
  }
6805
7523
  recordLifecycle(input) {
6806
7524
  this.lifecycleState = reduceAgentSessionLifecycle(this.lifecycleState, input);
6807
- void this.lifecycleState.phase;
6808
7525
  }
6809
7526
  };
6810
7527
 
@@ -6987,6 +7704,109 @@ async function requestInteraction(request) {
6987
7704
  return await currentInteractionHandler(request);
6988
7705
  }
6989
7706
 
7707
+ // ../../packages/agent-framework/src/attention.ts
7708
+ function same(a, b) {
7709
+ return a?.sessionId === b.sessionId && a?.generation === b.generation;
7710
+ }
7711
+ var AttentionCoordinator = class {
7712
+ constructor(policy = {}) {
7713
+ this.policy = policy;
7714
+ }
7715
+ policy;
7716
+ current;
7717
+ requests = /* @__PURE__ */ new Map();
7718
+ listeners = /* @__PURE__ */ new Set();
7719
+ sequence = 0;
7720
+ userWork = false;
7721
+ consumed = false;
7722
+ disposed = false;
7723
+ get activeRequests() {
7724
+ return [...this.requests.values()].map((r) => ({ ...r }));
7725
+ }
7726
+ subscribe(listener) {
7727
+ if (this.disposed) return () => {
7728
+ };
7729
+ this.listeners.add(listener);
7730
+ return () => this.listeners.delete(listener);
7731
+ }
7732
+ /** Feed controller work events only. Old turn_settled/skipped events are not outcomes. */
7733
+ handle(event) {
7734
+ if (this.disposed || this.current && event.generation < this.current.generation) return;
7735
+ if (event.type === "session_invalidated") {
7736
+ this.current = { sessionId: event.sessionId, generation: event.generation };
7737
+ this.consumed = true;
7738
+ this.clear();
7739
+ return;
7740
+ }
7741
+ if (event.type === "session_work_started") {
7742
+ if (same(this.current, event)) return;
7743
+ this.current = { sessionId: event.sessionId, generation: event.generation };
7744
+ this.consumed = false;
7745
+ this.clear();
7746
+ return;
7747
+ }
7748
+ if (!same(this.current, event) || this.consumed) return;
7749
+ this.consumed = true;
7750
+ const userWork = this.userWork;
7751
+ this.userWork = false;
7752
+ if (event.outcome === "cancelled") this.clear();
7753
+ else if (event.outcome === "failed" || event.outcome === "blocked") this.request(event, event.outcome);
7754
+ else if (this.policy.completion === "accepted-user-work" && userWork) this.request(event, "completed");
7755
+ }
7756
+ /** Call at actual accepted human delivery, including steer/retry; never infer from message role. */
7757
+ acceptUserWork(identity) {
7758
+ if (this.disposed || !same(this.current, identity) || this.consumed) return false;
7759
+ this.userWork = true;
7760
+ return true;
7761
+ }
7762
+ /** Producers retain the returned ID until answered/cancelled. Duplicate IDs do not broadcast twice. */
7763
+ request(identity, reason, id = `attention-${++this.sequence}`) {
7764
+ if (this.disposed || !same(this.current, identity)) return void 0;
7765
+ const existing = this.requests.get(id);
7766
+ if (existing) return { ...existing };
7767
+ const request = { sessionId: identity.sessionId, generation: identity.generation, id, reason };
7768
+ this.requests.set(id, request);
7769
+ this.emit({ type: "attention_requested", ...request });
7770
+ return { ...request };
7771
+ }
7772
+ /** Only the interaction owner resolves; suppressing/defering host presentation is not resolution. */
7773
+ resolve(id, identity, resolution = "answered") {
7774
+ const request = this.requests.get(id);
7775
+ if (!request || !same(request, identity)) return false;
7776
+ this.requests.delete(id);
7777
+ this.emit({ type: "attention_resolved", ...request, resolution });
7778
+ return true;
7779
+ }
7780
+ dispose() {
7781
+ if (this.disposed) return;
7782
+ this.disposed = true;
7783
+ this.current = void 0;
7784
+ this.consumed = true;
7785
+ this.clear();
7786
+ this.listeners.clear();
7787
+ }
7788
+ clear() {
7789
+ const requests = [...this.requests.values()];
7790
+ this.requests.clear();
7791
+ this.userWork = false;
7792
+ for (const request of requests) this.emit({ type: "attention_resolved", ...request, resolution: "invalidated" });
7793
+ }
7794
+ emit(event) {
7795
+ for (const listener of this.listeners) notifyObserver(listener, event);
7796
+ }
7797
+ };
7798
+
7799
+ // ../../packages/agent-framework/src/interaction-attention.ts
7800
+ function interactionAttentionReason(request) {
7801
+ return request.type === "notify" ? void 0 : "input-required";
7802
+ }
7803
+ function interactionAttentionResolution(response) {
7804
+ if (!response || typeof response !== "object") return "cancelled";
7805
+ const result = response;
7806
+ if (result.timedOut || result.skipped || result.unavailable) return "cancelled";
7807
+ return ["confirmed", "selection", "value", "answers"].some((key) => result[key] !== void 0) ? "answered" : "cancelled";
7808
+ }
7809
+
6990
7810
  // ../../packages/agent-framework/src/context-files.ts
6991
7811
  var AGENTS_CONTEXT_FILE_NAME = "AGENTS.md";
6992
7812
  function normalizeContextPath(path4) {
@@ -8849,14 +9669,23 @@ function createFileObservationState(options = {}) {
8849
9669
  function coverageSpansFile(coveredRanges, totalLines) {
8850
9670
  if (totalLines <= 0) return true;
8851
9671
  const merged = mergeLineRanges(coveredRanges);
8852
- return merged.length > 0 && merged[0].startLine <= 1 && merged[merged.length - 1].endLine >= totalLines;
9672
+ return merged.length === 1 && merged[0].startLine <= 1 && merged[0].endLine >= totalLines;
8853
9673
  }
8854
9674
  function summarize(observed) {
8855
9675
  const merged = mergeLineRanges(observed.coveredRanges);
8856
9676
  const coveredLines = merged.reduce((total, range2) => total + (range2.endLine - range2.startLine + 1), 0);
8857
9677
  let firstUnseenLine;
8858
9678
  if (observed.coveredTotalLines > 0 && coveredLines < observed.coveredTotalLines) {
8859
- firstUnseenLine = merged.length > 0 && merged[0].startLine <= 1 ? merged[merged.length - 1].endLine + 1 : 1;
9679
+ firstUnseenLine = 1;
9680
+ if (merged.length > 0 && merged[0].startLine <= 1) {
9681
+ firstUnseenLine = merged[merged.length - 1].endLine + 1;
9682
+ for (let index = 0; index + 1 < merged.length; index++) {
9683
+ if (merged[index + 1].startLine > merged[index].endLine + 1) {
9684
+ firstUnseenLine = merged[index].endLine + 1;
9685
+ break;
9686
+ }
9687
+ }
9688
+ }
8860
9689
  }
8861
9690
  let lastRange;
8862
9691
  for (const range2 of observed.readRanges.values()) {
@@ -8972,22 +9801,32 @@ function createReadTool(cwd, options) {
8972
9801
  ...rootSchema ? { root: rootSchema } : {},
8973
9802
  offset: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_CROSS_HOST_LINE_NUMBER, description: "Line number to start reading from, 1-indexed. Example: 201." })),
8974
9803
  limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_CROSS_HOST_LINE_NUMBER, description: "Maximum number of lines to read. Example: 120." })),
9804
+ tail: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_CROSS_HOST_LINE_NUMBER, description: "Read the last N lines of the file, without needing to know the total line count. Useful for logs and files that grow. Mutually exclusive with offset/limit. Example: tail=50." })),
8975
9805
  question: Type.Optional(Type.String({ description: "Optional focused question for image, video, audio, or document understanding. Use this when asking what a media/document file contains; omit for ordinary text reads." }))
8976
9806
  });
8977
9807
  const pathResolutionDescription = buildPathResolutionDescription(cwd, options);
8978
9808
  return {
8979
9809
  name: "read",
8980
9810
  label: "read",
8981
- description: `Read a file's contents. Use this before editing, and use offset/limit for large files. Images are attached for the current model when it supports vision; otherwise a configured file-understanding service analyzes them. Video, audio, and documents require a configured understanding service. Pass question to specify what to focus on; it does not force a separate model request. Example: read_file path='Scripts/PlayerController.cs' or read_file path='Data/config.json'. ${pathResolutionDescription} For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). When you need the full file, continue with a larger offset until complete.`,
9811
+ description: `Read a file's contents. Use this before editing, and use offset/limit for large files, or tail=N to read the last N lines when the line count is unknown. Images are attached for the current model when it supports vision; otherwise a configured file-understanding service analyzes them. Video, audio, and documents require a configured understanding service. Pass question to specify what to focus on; it does not force a separate model request. Example: read_file path='Scripts/PlayerController.cs' or read_file path='Data/config.json'. ${pathResolutionDescription} For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). When you need the full file, continue with a larger offset until complete.`,
8982
9812
  parameters: readSchema,
8983
9813
  execute: async (_toolCallId, params, signal) => {
8984
- const { path: path4, root, offset, limit, question } = params;
9814
+ const { path: path4, root, question } = params;
9815
+ const tail = params.tail;
9816
+ let offset = params.offset;
9817
+ let limit = params.limit;
8985
9818
  if (offset !== void 0 && (!Number.isSafeInteger(offset) || offset < 1 || offset > MAX_CROSS_HOST_LINE_NUMBER)) {
8986
9819
  return createReadErrorResult(`Invalid arguments: offset must be a positive safe integer no greater than ${MAX_CROSS_HOST_LINE_NUMBER}.`);
8987
9820
  }
8988
9821
  if (limit !== void 0 && (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_CROSS_HOST_LINE_NUMBER)) {
8989
9822
  return createReadErrorResult(`Invalid arguments: limit must be a positive safe integer no greater than ${MAX_CROSS_HOST_LINE_NUMBER}.`);
8990
9823
  }
9824
+ if (tail !== void 0 && (!Number.isSafeInteger(tail) || tail < 1 || tail > MAX_CROSS_HOST_LINE_NUMBER)) {
9825
+ return createReadErrorResult(`Invalid arguments: tail must be a positive safe integer no greater than ${MAX_CROSS_HOST_LINE_NUMBER}.`);
9826
+ }
9827
+ if (tail !== void 0 && (offset !== void 0 || limit !== void 0)) {
9828
+ return createReadErrorResult("Invalid arguments: tail cannot be combined with offset or limit. Use tail=N to read the last N lines, or offset/limit for a window.");
9829
+ }
8991
9830
  if (offset !== void 0 && limit !== void 0 && offset - 1 + limit > MAX_CROSS_HOST_LINE_NUMBER) {
8992
9831
  return createReadErrorResult(`Invalid arguments: offset and limit exceed the ${MAX_CROSS_HOST_LINE_NUMBER}-line cross-host range.`);
8993
9832
  }
@@ -9151,14 +9990,38 @@ Focus: ${question.trim()}` : ""}` },
9151
9990
  });
9152
9991
  return;
9153
9992
  }
9154
- const startLine = offset === void 0 ? 0 : offset - 1;
9993
+ let translatedFromTail = false;
9994
+ let tailWindowIncludesPhantom = false;
9995
+ let tailEffectiveLines;
9996
+ let startLine;
9997
+ let range2;
9998
+ if (tail !== void 0) {
9999
+ const effectiveTail = Math.max(1, Math.min(tail, DEFAULT_MAX_LINES - 1));
10000
+ translatedFromTail = true;
10001
+ tailEffectiveLines = effectiveTail;
10002
+ range2 = await fs2.readTextRange(absolutePath, {
10003
+ tailLines: effectiveTail,
10004
+ maxOutputLines: DEFAULT_MAX_LINES,
10005
+ maxOutputBytes: DEFAULT_MAX_BYTES
10006
+ }, signal);
10007
+ tailWindowIncludesPhantom = range2.endsWithNewline === true;
10008
+ if (range2.selectedLines > 0) {
10009
+ const derivedHead = range2.totalLines - range2.selectedLines + 1;
10010
+ startLine = range2.outputStartLine !== void 0 && !range2.firstLineExceedsLimit ? range2.outputStartLine - 1 : derivedHead - 1;
10011
+ limit = range2.selectedLines;
10012
+ } else {
10013
+ startLine = 0;
10014
+ }
10015
+ } else {
10016
+ startLine = offset === void 0 ? 0 : offset - 1;
10017
+ range2 = await fs2.readTextRange(absolutePath, {
10018
+ offsetLine: startLine,
10019
+ limitLines: limit,
10020
+ maxOutputLines: DEFAULT_MAX_LINES,
10021
+ maxOutputBytes: DEFAULT_MAX_BYTES
10022
+ }, signal);
10023
+ }
9155
10024
  const startLineDisplay = startLine + 1;
9156
- const range2 = await fs2.readTextRange(absolutePath, {
9157
- offsetLine: startLine,
9158
- limitLines: limit,
9159
- maxOutputLines: DEFAULT_MAX_LINES,
9160
- maxOutputBytes: DEFAULT_MAX_BYTES
9161
- }, signal);
9162
10025
  if (options?.allowBinary === false) {
9163
10026
  const selectedLooksBinary = range2.selectedBytesLookBinary ?? (looksLikeBinaryText(range2.content) || range2.content.includes("\uFFFD"));
9164
10027
  const selectedBinary = selectedLooksBinary ? { kind: "document", mimeType: "application/octet-stream" } : void 0;
@@ -9177,7 +10040,7 @@ Focus: ${question.trim()}` : ""}` },
9177
10040
  contentHash: range2.contentHash
9178
10041
  };
9179
10042
  const totalFileLines = range2.totalLines;
9180
- if (startLine >= range2.totalLines) {
10043
+ if (startLine >= range2.totalLines && !translatedFromTail) {
9181
10044
  throw new Error(`Offset ${offset} is beyond end of file (${range2.totalLines} lines total)`);
9182
10045
  }
9183
10046
  let userLimitedLines;
@@ -9198,7 +10061,7 @@ Focus: ${question.trim()}` : ""}` },
9198
10061
  maxLines: DEFAULT_MAX_LINES,
9199
10062
  maxBytes: DEFAULT_MAX_BYTES
9200
10063
  };
9201
- const readStartsAtBeginning = offset === void 0 || offset === 1;
10064
+ const readStartsAtBeginning = startLine === 0;
9202
10065
  const readReachedEndOfFile = startLine + range2.selectedLines >= range2.totalLines;
9203
10066
  const isCompleteTextKnown = readStartsAtBeginning && readReachedEndOfFile && !truncation.truncated && !truncation.firstLineExceedsLimit;
9204
10067
  let callerObservation = fileObservationState;
@@ -9220,13 +10083,14 @@ Focus: ${question.trim()}` : ""}` },
9220
10083
  resolve3({
9221
10084
  content: [{
9222
10085
  type: "text",
9223
- text: `File unchanged since previous read of this range (offset=${startLineDisplay}${limit === void 0 ? ", no limit" : `, limit=${limit}`}). Earlier reads still count toward write_file coverage of this file. Use a different offset/limit if you need another section.`
10086
+ text: translatedFromTail ? `File unchanged since previous tail=${tailEffectiveLines} read (last ${tailWindowIncludesPhantom ? range2.selectedLines - 1 : range2.selectedLines} lines, ${range2.totalLines} lines total). Earlier reads still count toward write_file coverage of this file.` : `File unchanged since previous read of this range (offset=${startLineDisplay}${limit === void 0 ? ", no limit" : `, limit=${limit}`}). Earlier reads still count toward write_file coverage of this file. Use a different offset/limit if you need another section.`
9224
10087
  }],
9225
10088
  details: {
9226
10089
  unchangedSincePreviousRead: true,
9227
10090
  path: absolutePath,
9228
10091
  offset: startLineDisplay,
9229
- limit
10092
+ limit,
10093
+ ...translatedFromTail ? { totalLines: range2.totalLines } : {}
9230
10094
  }
9231
10095
  });
9232
10096
  return;
@@ -9245,7 +10109,12 @@ Base directory: ${nodePath.dirname(absolutePath)}
9245
10109
  const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
9246
10110
  const nextOffset = endLineDisplay + 1;
9247
10111
  outputText = truncation.content;
9248
- if (truncation.truncatedBy === "lines") {
10112
+ if (translatedFromTail) {
10113
+ const endsAtLastLine = truncation.content.length > 0 ? ", and the content above ends at the file's last line" : "";
10114
+ outputText += `
10115
+
10116
+ [Tail window hit the output cap: the earliest lines of the requested tail were dropped${endsAtLastLine}. Read earlier lines with offset, or request a smaller tail.]`;
10117
+ } else if (truncation.truncatedBy === "lines") {
9249
10118
  outputText += `
9250
10119
 
9251
10120
  [Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
@@ -9271,18 +10140,38 @@ Base directory: ${nodePath.dirname(absolutePath)}
9271
10140
  snapshot,
9272
10141
  range2.selectedContentHash,
9273
10142
  isCompleteTextKnown,
9274
- {
9275
- // firstLineExceedsLimit yields an intentionally empty range
9276
- // (start - 1): zero lines were actually shown, so it must
9277
- // contribute nothing to write coverage.
9278
- startLine: startLineDisplay,
9279
- endLine: startLineDisplay + (truncation.firstLineExceedsLimit ? 0 : truncation.truncated ? truncation.outputLines : range2.selectedLines) - 1,
9280
- totalLines: range2.totalLines
9281
- }
10143
+ (() => {
10144
+ if (translatedFromTail && truncation.truncated && !truncation.firstLineExceedsLimit) {
10145
+ const outputStart = range2.outputStartLine ?? startLineDisplay + Math.max(0, range2.selectedLines - range2.outputLines);
10146
+ const clampedStart = Math.min(outputStart, range2.totalLines + 1);
10147
+ const clampedEnd = Math.min(range2.totalLines, clampedStart + range2.outputLines - 1);
10148
+ return {
10149
+ startLine: clampedStart,
10150
+ endLine: clampedStart > range2.totalLines ? clampedStart - 1 : clampedEnd,
10151
+ totalLines: range2.totalLines
10152
+ };
10153
+ }
10154
+ return {
10155
+ // firstLineExceedsLimit yields an intentionally empty range
10156
+ // (start - 1): zero lines were actually shown, so it must
10157
+ // contribute nothing to write coverage.
10158
+ startLine: startLineDisplay,
10159
+ endLine: startLineDisplay + (truncation.firstLineExceedsLimit ? 0 : truncation.truncated ? truncation.outputLines : range2.selectedLines) - 1,
10160
+ totalLines: range2.totalLines
10161
+ };
10162
+ })()
9282
10163
  );
9283
10164
  if (callerObservation !== fileObservationState) {
9284
10165
  callerObservation.recordTextRead(absolutePath, rangeKey, snapshot, range2.selectedContentHash, isCompleteTextKnown);
9285
10166
  }
10167
+ if (translatedFromTail && tail !== void 0 && tailEffectiveLines !== void 0 && tailEffectiveLines < tail && startLineDisplay > 1) {
10168
+ outputText += `
10169
+
10170
+ [Tail clamped to ${tailEffectiveLines} lines: the per-read line cap is ${DEFAULT_MAX_LINES}, so the ${startLineDisplay - 1} earliest lines of the file are not shown. Use offset reads to walk earlier lines, or a smaller tail.]`;
10171
+ }
10172
+ if (translatedFromTail) {
10173
+ details = { ...details ?? {}, totalLines: range2.totalLines };
10174
+ }
9286
10175
  outputText = sourceHeader + outputText;
9287
10176
  const textContent = [{ type: "text", text: outputText }];
9288
10177
  if (aborted) return;
@@ -9661,13 +10550,15 @@ function createWriteTool(cwd, options) {
9661
10550
  const observedSnapshot = fileObservationState.getCompleteTextSnapshot(absolutePath);
9662
10551
  if (!observedSnapshot) {
9663
10552
  const summary = fileObservationState.describeObservation(absolutePath);
9664
- const coverageHint = summary && summary.totalLines > 0 ? ` Previous reads cover ${summary.coveredLines}/${summary.totalLines} lines${summary.lastRange ? ` (last read: lines ${summary.lastRange.startLine}-${summary.lastRange.endLine})` : ""}${summary.firstUnseenLine ? `; read the rest with read_file offset=${summary.firstUnseenLine}` : "; read the full file with read_file (no offset/limit)"}.` : " Read the current full file with read_file (no offset/limit) first.";
10553
+ const coverageHint = summary && summary.totalLines > 0 ? ` Previous reads cover ${summary.coveredLines}/${summary.totalLines} lines${summary.lastRange ? ` (last read: lines ${summary.lastRange.startLine}-${summary.lastRange.endLine})` : ""}${summary.firstUnseenLine ? `; read the rest with read_file offset=${summary.firstUnseenLine}, or a tail read spanning the whole file` : "; read the full file with read_file (no offset/limit, or a tail read spanning the whole file)"}.` : " Read the current full file with read_file (no offset/limit, or a tail read spanning the whole file) first.";
9665
10554
  throw new Error(
9666
10555
  usesExternalFileObservationState ? `write_file blocked: overwriting requires the file to be fully observed by read_file.${coverageHint} Alternatively, use edit_file for targeted changes.` : "Existing-file overwrite requires write_file to share a fileObservationState with read_file; use createSharedFileSystemTools or pass the same fileObservationState to both tools."
9667
10556
  );
9668
10557
  }
9669
10558
  if (!snapshotsHaveSameContent(observedSnapshot, currentSnapshot)) {
9670
- throw new Error("File changed since it was read. Read the latest file contents before overwriting it.");
10559
+ throw new Error(
10560
+ `File changed since it was read (${observedSnapshot.size} \u2192 ${currentSnapshot.size} bytes; out-of-band edits such as bash sed/redirects cause this). Re-read with the read_file tool \u2014 offset/limit/tail reads accumulate coverage; bash head/cat does not count \u2014 or use edit_file for targeted changes.`
10561
+ );
9671
10562
  }
9672
10563
  }
9673
10564
  await fs2.writeFile(absolutePath, content, "utf-8");
@@ -9681,7 +10572,7 @@ function createWriteTool(cwd, options) {
9681
10572
  signal.removeEventListener("abort", onAbort);
9682
10573
  }
9683
10574
  resolve3({
9684
- content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${filePath}` }],
10575
+ content: [{ type: "text", text: `Successfully wrote ${writtenStat.size} bytes to ${filePath}` }],
9685
10576
  details: void 0
9686
10577
  });
9687
10578
  } catch (error) {
@@ -9714,6 +10605,8 @@ function restoreLineEndings(text, ending) {
9714
10605
  return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
9715
10606
  }
9716
10607
  var MAX_FUZZY_DIAGNOSTIC_CHARS = 256 * 1024;
10608
+ var MAX_CLOSEST_DIFF_REGION_LINES = 80;
10609
+ var MAX_CLOSEST_DIFF_LINES = 30;
9717
10610
  var RELAXED_MATCH_WORK_BUDGET = 1e6;
9718
10611
  function normalizeForFuzzyMatch(text) {
9719
10612
  return buildFuzzyIndexMap(text).normalized;
@@ -9862,6 +10755,9 @@ function leadingWhitespace(line) {
9862
10755
  const match2 = line.match(/^[ \t]*/);
9863
10756
  return match2 ? match2[0] : "";
9864
10757
  }
10758
+ function stripLeadingWhitespace(line) {
10759
+ return line.replace(/^[ \t]*/, "");
10760
+ }
9865
10761
  var RelaxedWindow;
9866
10762
  ((RelaxedWindow2) => {
9867
10763
  RelaxedWindow2.EMPTY_MAP = /* @__PURE__ */ new Map();
@@ -9944,6 +10840,9 @@ function reindentNewText(newText, qLines, fileLines, window) {
9944
10840
  nIdx.push(i);
9945
10841
  }
9946
10842
  if (nIdx.length === 0) return newText;
10843
+ if (qIdx.length === nIdx.length && qIdx.every((q, pos) => qLines[q].trim() === newLines[nIdx[pos]].trim()) && qIdx.some((q, pos) => leadingWhitespace(qLines[q]) !== leadingWhitespace(newLines[nIdx[pos]]))) {
10844
+ return newText;
10845
+ }
9947
10846
  const counterpart = /* @__PURE__ */ new Map();
9948
10847
  let p = 0;
9949
10848
  while (p < qIdx.length && p < nIdx.length && qLines[qIdx[p]].trim() === newLines[nIdx[p]].trim()) {
@@ -10002,7 +10901,7 @@ function reindentNewText(newText, qLines, fileLines, window) {
10002
10901
  }
10003
10902
  const nPos = nPosOfLine.get(i) ?? 0;
10004
10903
  const qPos = counterpart.get(nPos) ?? 0;
10005
- result.push(qIndent(qIdx[qPos] ?? qIdx[0]) + newLines[i].trim());
10904
+ result.push(qIndent(qIdx[qPos] ?? qIdx[0]) + stripLeadingWhitespace(newLines[i]));
10006
10905
  }
10007
10906
  return result.join("\n");
10008
10907
  }
@@ -10061,7 +10960,8 @@ function describeClosestRegion(content, oldText) {
10061
10960
  let matched = 0;
10062
10961
  let fi = i;
10063
10962
  let firstDiff;
10064
- for (let qi = firstNonBlank; qi < qLines.length; qi++) {
10963
+ let qi = firstNonBlank;
10964
+ for (; qi < qLines.length; qi++) {
10065
10965
  budget -= 1;
10066
10966
  if (budget < 0) {
10067
10967
  return `Closest-region diagnostics were skipped because the file and the quoted block are too repetitive to compare within the relaxed-match budget \u2014 quote a larger region with distinguishing context lines and retry.`;
@@ -10070,24 +10970,102 @@ function describeClosestRegion(content, oldText) {
10070
10970
  if (!q) continue;
10071
10971
  while (fi < fileLines.length && !fileLines[fi].trim()) fi++;
10072
10972
  if (fi >= fileLines.length || fileLines[fi].trim() !== q) {
10073
- firstDiff = { fileNo: fi + 1, fileLine: fileLines[fi] ?? "<end of file>", qLine: qLines[qi] };
10973
+ firstDiff = { fileNo: fi + 1, fileLine: fileLines[fi] ?? "<end of file>", qLine: qLines[qi], qIdx: qi };
10074
10974
  break;
10075
10975
  }
10076
10976
  matched++;
10077
10977
  fi++;
10078
10978
  }
10079
- if (!best || matched > best.matched) best = { start: i, matched, firstDiff };
10979
+ if (!best || matched > best.matched) best = { start: i, walkEnd: fi, matched, firstDiff };
10080
10980
  }
10081
10981
  const total = qLines.filter((line) => line.trim() !== "").length;
10082
10982
  if (!best) {
10083
10983
  return `No line matching ${JSON.stringify(clip(anchor))} exists in this file \u2014 the text may live in a different file, or the file changed since it was read.`;
10084
10984
  }
10085
- const parts = [`Closest match: file lines ${best.start + 1}\u2013${best.start + total} (${best.matched}/${total} lines matched by trimmed content).`];
10985
+ const regionStart = best.start + 1;
10986
+ let regionEnd = Math.max(regionStart, best.walkEnd + (best.firstDiff ? 1 : 0));
10987
+ const parts = [];
10988
+ let alignment = "unknown";
10989
+ let shiftDelta = 0;
10086
10990
  if (best.firstDiff) {
10087
10991
  parts.push(`First difference at line ${best.firstDiff.fileNo}: file has ${JSON.stringify(clip(best.firstDiff.fileLine.trim()))}, edit expected ${JSON.stringify(clip(best.firstDiff.qLine.trim()))}.`);
10992
+ const suffixIdx = [];
10993
+ for (let s = best.firstDiff.qIdx + 1; s < qLines.length && suffixIdx.length < 3; s++) {
10994
+ if (qLines[s].trim()) suffixIdx.push(s);
10995
+ }
10996
+ if (suffixIdx.length === 0) {
10997
+ alignment = "aligned";
10998
+ } else {
10999
+ budget -= 20;
11000
+ const nextNonBlank = (from) => {
11001
+ let i = from;
11002
+ while (i < fileLines.length && !fileLines[i].trim()) i++;
11003
+ return i;
11004
+ };
11005
+ const tryAlign = (fileStart) => {
11006
+ let score = 0;
11007
+ let fi = fileStart;
11008
+ for (const s of suffixIdx) {
11009
+ fi = nextNonBlank(fi);
11010
+ if (fi >= fileLines.length || fileLines[fi].trim() !== qLines[s].trim()) break;
11011
+ score++;
11012
+ fi++;
11013
+ }
11014
+ return { score, endFileIdx: fi };
11015
+ };
11016
+ const atSameLength = tryAlign(best.walkEnd + 1);
11017
+ if (atSameLength.score === suffixIdx.length) {
11018
+ alignment = "aligned";
11019
+ regionEnd = Math.max(regionEnd, Math.min(atSameLength.endFileIdx, best.start + MAX_CLOSEST_DIFF_REGION_LINES));
11020
+ } else {
11021
+ const diffQuoteLine = best.firstDiff.qLine.trim();
11022
+ const nextFile = nextNonBlank(best.walkEnd + 1);
11023
+ const quoteSkipsAFileLine = nextFile < fileLines.length && fileLines[nextFile].trim() === diffQuoteLine;
11024
+ const shiftedCandidates = [best.walkEnd - 1, best.walkEnd, best.walkEnd + 2, best.walkEnd + 3];
11025
+ const shiftedResults = shiftedCandidates.map((candidate) => tryAlign(candidate));
11026
+ let bestShiftedIndex = 0;
11027
+ for (let index = 1; index < shiftedResults.length; index++) {
11028
+ if (shiftedResults[index].score > shiftedResults[bestShiftedIndex].score) bestShiftedIndex = index;
11029
+ }
11030
+ const bestShifted = shiftedResults[bestShiftedIndex];
11031
+ if (quoteSkipsAFileLine) {
11032
+ alignment = "shifted";
11033
+ shiftDelta = nextFile - best.walkEnd;
11034
+ const throughSuffix = tryAlign(nextFile + 1);
11035
+ const end = throughSuffix.score > 0 ? throughSuffix.endFileIdx : nextFile + 1;
11036
+ regionEnd = Math.max(regionEnd, Math.min(end, best.start + MAX_CLOSEST_DIFF_REGION_LINES));
11037
+ } else if (bestShifted.score > atSameLength.score && bestShifted.score === suffixIdx.length) {
11038
+ alignment = "shifted";
11039
+ shiftDelta = shiftedCandidates[bestShiftedIndex] - (best.walkEnd + 1);
11040
+ regionEnd = Math.max(regionEnd, Math.min(bestShifted.endFileIdx, best.start + MAX_CLOSEST_DIFF_REGION_LINES));
11041
+ } else if (atSameLength.score > 0) {
11042
+ alignment = "aligned";
11043
+ regionEnd = Math.max(regionEnd, Math.min(atSameLength.endFileIdx, best.start + MAX_CLOSEST_DIFF_REGION_LINES));
11044
+ }
11045
+ }
11046
+ }
11047
+ if (alignment === "shifted") {
11048
+ parts.push("The remainder of the quote re-aligns only at a SHIFTED position \u2014 an extra, missing, or duplicated line changed the line count; fixing the quoted difference line alone cannot repair the quote.");
11049
+ } else if (alignment === "unknown") {
11050
+ parts.push("The remainder of the quote does not re-align within a few lines of the difference \u2014 the quote is likely stale; re-read the region and re-quote it.");
11051
+ }
11052
+ const quoteSpanLines = qLines.length - firstNonBlank + (alignment === "shifted" ? shiftDelta : 0);
11053
+ regionEnd = Math.max(regionEnd, Math.min(best.start + quoteSpanLines, best.start + MAX_CLOSEST_DIFF_REGION_LINES));
10088
11054
  } else {
10089
11055
  parts.push("All non-empty lines match by trimmed content \u2014 the mismatch is leading whitespace or blank-line placement.");
10090
11056
  }
11057
+ parts.unshift(`Closest match: file lines ${regionStart}\u2013${regionEnd} (${best.matched}/${total} lines matched by trimmed content).`);
11058
+ if (best.firstDiff && alignment !== "unknown" && regionEnd - regionStart + 1 <= MAX_CLOSEST_DIFF_REGION_LINES) {
11059
+ const regionText = fileLines.slice(best.start, Math.min(regionEnd, fileLines.length)).join("\n");
11060
+ const { diff } = generateDiffString(regionText, oldText);
11061
+ const body = diff.split("\n").slice(3);
11062
+ if (body.length > 0) {
11063
+ const shown = body.slice(0, MAX_CLOSEST_DIFF_LINES);
11064
+ const suffix = body.length > shown.length ? ["\u2026"] : [];
11065
+ parts.push(`Diff of the region against the quote (- file, + quote):
11066
+ ${[...shown, ...suffix].join("\n")}`);
11067
+ }
11068
+ }
10091
11069
  return parts.join(" ");
10092
11070
  }
10093
11071
  function generateDiffString(oldContent, newContent, contextLines = 4) {
@@ -16743,6 +17721,9 @@ export {
16743
17721
  registerInteractionHandler,
16744
17722
  hasInteractionHandler,
16745
17723
  requestInteraction,
17724
+ AttentionCoordinator,
17725
+ interactionAttentionReason,
17726
+ interactionAttentionResolution,
16746
17727
  AGENTS_CONTEXT_FILE_NAME,
16747
17728
  buildProjectContextSection,
16748
17729
  attachAgentEventsToSessionTrace,
@@ -16772,6 +17753,7 @@ export {
16772
17753
  modalityForReadFileUnderstandingKind,
16773
17754
  createReadFileUnderstandingHandler,
16774
17755
  formatSkillSummariesForPrompt,
17756
+ SUBAGENT_SESSION_RECORD_VERSION,
16775
17757
  classifyShellCommand,
16776
17758
  interpretShellExit,
16777
17759
  createSubagentCapability,