@aexol/spectral 0.9.250 → 0.9.254

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/agent/index.d.ts +2 -1
  2. package/dist/agent/index.d.ts.map +1 -1
  3. package/dist/agent/index.js +10 -3
  4. package/dist/generated/zeus/const.d.ts.map +1 -1
  5. package/dist/generated/zeus/const.js +51 -6
  6. package/dist/generated/zeus/index.d.ts +278 -36
  7. package/dist/generated/zeus/index.d.ts.map +1 -1
  8. package/dist/mcp/mcp-auth-flow.d.ts +4 -0
  9. package/dist/mcp/mcp-auth-flow.d.ts.map +1 -1
  10. package/dist/mcp/mcp-auth-flow.js +7 -4
  11. package/dist/memory/commands/status.d.ts.map +1 -1
  12. package/dist/memory/commands/status.js +2 -1
  13. package/dist/memory/hooks/compaction-trigger.d.ts +14 -1
  14. package/dist/memory/hooks/compaction-trigger.d.ts.map +1 -1
  15. package/dist/memory/hooks/compaction-trigger.js +31 -4
  16. package/dist/relay/dispatcher.d.ts.map +1 -1
  17. package/dist/relay/dispatcher.js +4 -47
  18. package/dist/relay/routing.d.ts +1 -1
  19. package/dist/relay/routing.d.ts.map +1 -1
  20. package/dist/relay/routing.js +9 -0
  21. package/dist/sdk/ai/utils/image-source.d.ts +26 -11
  22. package/dist/sdk/ai/utils/image-source.d.ts.map +1 -1
  23. package/dist/sdk/ai/utils/image-source.js +184 -47
  24. package/dist/sdk/ai/utils/transient-error.d.ts +4 -0
  25. package/dist/sdk/ai/utils/transient-error.d.ts.map +1 -1
  26. package/dist/sdk/ai/utils/transient-error.js +15 -0
  27. package/dist/sdk/coding-agent/core/compaction/policy.d.ts +2 -0
  28. package/dist/sdk/coding-agent/core/compaction/policy.d.ts.map +1 -1
  29. package/dist/sdk/coding-agent/core/compaction/policy.js +2 -0
  30. package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
  31. package/dist/sdk/coding-agent/core/system-prompt.js +4 -0
  32. package/dist/sdk/coding-agent/core/tools/read.d.ts.map +1 -1
  33. package/dist/sdk/coding-agent/core/tools/read.js +14 -7
  34. package/dist/sdk/coding-agent/index.d.ts +1 -1
  35. package/dist/sdk/coding-agent/index.d.ts.map +1 -1
  36. package/dist/sdk/coding-agent/index.js +1 -1
  37. package/dist/sdk/coding-agent/utils/image-resize.d.ts +25 -11
  38. package/dist/sdk/coding-agent/utils/image-resize.d.ts.map +1 -1
  39. package/dist/sdk/coding-agent/utils/image-resize.js +54 -36
  40. package/dist/sdk/coding-agent/utils/photon.d.ts.map +1 -1
  41. package/dist/sdk/coding-agent/utils/photon.js +11 -2
  42. package/dist/server/agent-bridge.d.ts +32 -0
  43. package/dist/server/agent-bridge.d.ts.map +1 -1
  44. package/dist/server/agent-bridge.js +59 -9
  45. package/dist/server/handlers/mcp-auth.d.ts +25 -0
  46. package/dist/server/handlers/mcp-auth.d.ts.map +1 -0
  47. package/dist/server/handlers/mcp-auth.js +106 -0
  48. package/dist/server/handlers/mcp-status.d.ts +8 -2
  49. package/dist/server/handlers/mcp-status.d.ts.map +1 -1
  50. package/dist/server/handlers/mcp-status.js +15 -3
  51. package/dist/server/preset-chain.d.ts +0 -13
  52. package/dist/server/preset-chain.d.ts.map +1 -1
  53. package/dist/server/preset-chain.js +0 -25
  54. package/dist/server/session-stream/memory.d.ts.map +1 -1
  55. package/dist/server/session-stream/memory.js +8 -1
  56. package/dist/server/session-stream.d.ts +15 -56
  57. package/dist/server/session-stream.d.ts.map +1 -1
  58. package/dist/server/session-stream.js +41 -271
  59. package/dist/server/wire.d.ts +18 -32
  60. package/dist/server/wire.d.ts.map +1 -1
  61. package/package.json +1 -1
@@ -40,11 +40,10 @@ import { randomUUID } from "node:crypto";
40
40
  import { AgentBridge } from "./agent-bridge.js";
41
41
  import { formatAttachmentAnnotation, persistAttachments, attachmentsRootFor, } from "./attachments/persist.js";
42
42
  import { sweepAttachments } from "./attachments/cleanup.js";
43
- import { expandIfPresetReference, expandPresetPrompt, parsePresetReference, PRESET_CHAIN_BUDGET, resolvePresetByName, } from "./preset-chain.js";
43
+ import { expandIfPresetReference, parsePresetReference, PRESET_CHAIN_BUDGET, resolvePresetByName, } from "./preset-chain.js";
44
44
  import { InterAgentBroker } from "./inter-agent-broker.js";
45
45
  import { setInterAgentBroker } from "../memory/inter-agent-broker-singleton.js";
46
46
  import { configureFanout } from "./fanout/fanout-manager.js";
47
- import { getAutoOlderHistoryContextThresholdTokens } from "../sdk/coding-agent/core/compaction/policy.js";
48
47
  import { getMemoryState, rawTokensSinceLastBound, rawTokensSinceLastCompaction, } from "../memory/branch.js";
49
48
  import { observationPoolTokens, renderSummary } from "../memory/compaction.js";
50
49
  import { loadConfig } from "../memory/config.js";
@@ -58,7 +57,7 @@ import { parseImagesJson } from "./image-attachments.js";
58
57
  // unchanged).
59
58
  import { formatCompactionTokenStats, isSkippedCompactionResult, } from "./session-stream/compaction-events.js";
60
59
  import { getCompactionEndPruneSkipReason, prunePersistedHistoryAfterCompaction, } from "./session-stream/history-prune.js";
61
- import { applyDcpLiteRuntimeStatus, dcpLiteEventFromServerEvent, defaultMemoryStatus, loadCompactionPolicy, persistObservationalMemorySnapshot, } from "./session-stream/memory.js";
60
+ import { applyDcpLiteRuntimeStatus, dcpLiteEventFromServerEvent, defaultMemoryStatus, persistObservationalMemorySnapshot, } from "./session-stream/memory.js";
62
61
  import { selectHistoryForBridge } from "./session-stream/replay-tail.js";
63
62
  import { assistantMessageIdsForTurn, extractCreditsFromEventsJsonl, extractMessageMetricsFromEventsJsonl, isReplayable, snapshotTurn, } from "./session-stream/turn-events.js";
64
63
  import { resolveTurnSilenceMaxRearm, resolveTurnSilenceTimeoutMs, } from "./session-stream/turn-silence.js";
@@ -69,14 +68,12 @@ export { estimateStoredMessageReplayTokens, selectHistoryForBridge, } from "./se
69
68
  export { assistantMessageIdsForTurn, extractCreditsFromEventsJsonl, extractMessageMetricsFromEventsJsonl, isReplayable, snapshotTurn, } from "./session-stream/turn-events.js";
70
69
  export { resolveTurnSilenceMaxRearm, resolveTurnSilenceTimeoutMs, } from "./session-stream/turn-silence.js";
71
70
  const DEFAULT_BRIDGE_FACTORY = (args) => new AgentBridge(args);
72
- /** Safety limit for autonomous loop iterations per session. */
73
- const MAX_LOOP_ITERATIONS = 100;
74
71
  // Streaming-meaningful events that reset the silence timer while a turn is
75
72
  // active. Mirrors Fix C on the landing side, but EXCLUDES `token_usage`
76
73
  // (arrives post-turn) and excludes terminal/control events handled by their
77
74
  // own branches (`agent_end`, `error`) and non-streaming side channels
78
75
  // (`inter_agent_observation`, `agent_notification`, `queue_changed`,
79
- // `session_renamed`, `interval_*`, `loop_*`, `compaction_*`).
76
+ // `session_renamed`, `interval_*`, `compaction_*`).
80
77
  const STREAMING_MEANINGFUL_EVENT_TYPES = new Set([
81
78
  "message_start",
82
79
  "text_delta",
@@ -107,8 +104,6 @@ const UI_HISTORY_TAIL_MESSAGE_LIMIT = 60;
107
104
  * at most the last `BATCH_FLUSH_INTERVAL` events, not the entire turn.
108
105
  */
109
106
  const BATCH_FLUSH_INTERVAL = 10;
110
- /** Marker the agent emits in its response to signal the task is complete. */
111
- const LOOP_DONE_MARKER = "<LOOP_DONE>";
112
107
  export class SessionStreamManager {
113
108
  store;
114
109
  cwd;
@@ -302,8 +297,6 @@ export class SessionStreamManager {
302
297
  intervalMinutes: stream.intervalMinutes,
303
298
  intervalNextTickAt: stream.intervalNextTickAt,
304
299
  intervalLaunchCount: stream.intervalLaunchCount,
305
- intervalHasLoop: stream.intervalConfig?.loop ?? false,
306
- intervalGoal: stream.intervalConfig?.loopGoal ?? null,
307
300
  };
308
301
  }
309
302
  /**
@@ -348,7 +341,7 @@ export class SessionStreamManager {
348
341
  }
349
342
  /**
350
343
  * Set of session IDs with active agent work (streaming, retrying,
351
- * compacting, looping, or a pending continuation). Superset of
344
+ * compacting, or a pending continuation). Superset of
352
345
  * `getActiveTurnSessionIds`; used by REST list enrichment.
353
346
  */
354
347
  getBusySessionIds() {
@@ -379,7 +372,7 @@ export class SessionStreamManager {
379
372
  * session so enabling/disabling a native extension takes effect without
380
373
  * restarting the session or waiting for a new session to be created.
381
374
  *
382
- * Busy sessions (in-flight turn, compaction, loop, pending continuation,
375
+ * Busy sessions (in-flight turn, compaction, pending continuation,
383
376
  * or a `prompt()` dispatch in progress) are SKIPPED, never queued: the
384
377
  * reload swaps the tool registry / extension runner underneath the
385
378
  * running turn, so a dispatched tool can resolve against a torn-down
@@ -452,7 +445,7 @@ export class SessionStreamManager {
452
445
  /**
453
446
  * True when an extension hot-reload must not touch this stream right now:
454
447
  * a turn is in flight, a `prompt()` dispatch is in progress (R1), or the
455
- * session is otherwise busy (compaction, loop, pending continuation).
448
+ * session is otherwise busy (compaction, pending continuation).
456
449
  */
457
450
  isReloadBlocked(stream) {
458
451
  return (stream.currentTurn !== null ||
@@ -977,9 +970,11 @@ export class SessionStreamManager {
977
970
  // emitted an `error` wire event and we drop the prompt rather than
978
971
  // recording a user message we know we won't be able to respond to.
979
972
  //
980
- // Persistence: only the envelope-supplied value is written back. A
981
- // recovery-only application (case b) doesn't update the row — the
982
- // value is already there.
973
+ // Persistence: the model actually applied by the bridge is written
974
+ // back — normally identical to `effectiveModelId`, but the applied
975
+ // (fallback) id wins when `setModel()` substituted one for a stale
976
+ // model. A recovery-only application (case b) rewrites the same value
977
+ // that is already there.
983
978
  const effectiveModelId = modelId ??
984
979
  this.store.getSessionModel(sessionId) ??
985
980
  stream.bridge.getFirstAvailableModelId?.();
@@ -994,7 +989,11 @@ export class SessionStreamManager {
994
989
  }
995
990
  if (effectiveModelId) {
996
991
  try {
997
- this.store.setSessionModel(sessionId, effectiveModelId);
992
+ // Persist the model actually in effect: when `setModel()` substituted
993
+ // a fallback for a stale requested modelId, the bridge reports the
994
+ // applied id and it wins over the requested one.
995
+ const appliedModelId = stream.bridge.getLastAppliedModelId?.() ?? effectiveModelId;
996
+ this.store.setSessionModel(sessionId, appliedModelId);
998
997
  }
999
998
  catch (err) {
1000
999
  // Persisting the sticky model is best-effort: the live turn will
@@ -1099,7 +1098,7 @@ export class SessionStreamManager {
1099
1098
  // that ended without a matching `subagent_end`.
1100
1099
  this.resetSubagentTracking(stream);
1101
1100
  // New logical run. This is the only place `busy` transitions
1102
- // false -> true (loop iterations and auto-dequeue also go through
1101
+ // false -> true (auto-dequeue also goes through
1103
1102
  // `prompt()`). Retries/continuations do NOT bump the generation.
1104
1103
  stream.runState = {
1105
1104
  runId: randomUUID(),
@@ -1110,7 +1109,6 @@ export class SessionStreamManager {
1110
1109
  streaming: true,
1111
1110
  retrying: false,
1112
1111
  compacting: false,
1113
- looping: false,
1114
1112
  queued: false,
1115
1113
  },
1116
1114
  };
@@ -1142,7 +1140,6 @@ export class SessionStreamManager {
1142
1140
  this.disposed = true;
1143
1141
  this.unsubscribeDevProcessEvents?.();
1144
1142
  for (const stream of this.streams.values()) {
1145
- stream.loopActive = false;
1146
1143
  stream.pendingContinue = false;
1147
1144
  this.clearIntervalTimer(stream);
1148
1145
  // Fix E: drop any in-flight safety-net timers on teardown.
@@ -1197,12 +1194,6 @@ export class SessionStreamManager {
1197
1194
  const stream = this.streams.get(sessionId);
1198
1195
  if (!stream)
1199
1196
  return;
1200
- // Stop any active loop before disposing — if the loop was mid-iteration
1201
- // the next agent_end would otherwise trigger another prompt.
1202
- stream.loopActive = false;
1203
- stream.loopOriginalPrompt = null;
1204
- stream.loopGoal = null;
1205
- stream.loopIterationCount = 0;
1206
1197
  this.clearIntervalTimer(stream);
1207
1198
  stream.intervalMinutes = null;
1208
1199
  stream.intervalConfig = null;
@@ -1229,8 +1220,7 @@ export class SessionStreamManager {
1229
1220
  catch {
1230
1221
  // ignore
1231
1222
  }
1232
- // Set startError so any late `await stream.ready` in the loop path
1233
- // (if the loop was between iterations when we disposed) rejects cleanly
1223
+ // Set startError so any late `await stream.ready` rejects cleanly
1234
1224
  // instead of hanging. Also prevents the next prompt() from waiting on
1235
1225
  // a dead bridge's ready promise.
1236
1226
  stream.startError = new Error("Turn cancelled");
@@ -1303,7 +1293,6 @@ export class SessionStreamManager {
1303
1293
  const stream = this.streams.get(sessionId);
1304
1294
  if (!stream)
1305
1295
  return;
1306
- stream.loopActive = false;
1307
1296
  stream.pendingContinue = false;
1308
1297
  this.clearIntervalTimer(stream);
1309
1298
  stream.intervalMinutes = null;
@@ -1346,82 +1335,6 @@ export class SessionStreamManager {
1346
1335
  this.disposeSessionStream(sid);
1347
1336
  }
1348
1337
  }
1349
- /**
1350
- * Set the autonomous iterative loop state for a session.
1351
- *
1352
- * When `active` is true, the manager replays `originalPrompt` after each
1353
- * `agent_end` event — the agent sees its own file changes from prior
1354
- * iterations and iteratively improves its solution (Ralph Wiggum pattern).
1355
- * The loop stops when the agent emits `<LOOP_DONE>` in its response or the
1356
- * safety iteration limit is reached.
1357
- */
1358
- setLoopActive(sessionId, active, originalPrompt, maxIterations, goal) {
1359
- const stream = this.streams.get(sessionId);
1360
- if (!stream)
1361
- return;
1362
- // Bump the epoch on EVERY call (arm and disarm alike) so the async
1363
- // "@preset:" arming tail can detect loop-state changes that happen
1364
- // while its resolver is awaited (see armLoopWithExpandedPreset).
1365
- stream.loopGeneration += 1;
1366
- if (!active) {
1367
- stream.loopActive = false;
1368
- stream.loopOriginalPrompt = null;
1369
- stream.loopIterationCount = 0;
1370
- stream.loopMaxIterations = MAX_LOOP_ITERATIONS;
1371
- stream.loopGoal = null;
1372
- return;
1373
- }
1374
- const raw = originalPrompt ?? null;
1375
- // Expand a leading "@preset:<name>" reference ONCE, at arming time, so
1376
- // every loop iteration replays the resolved prompt instead of the
1377
- // literal reference (which would re-expand — and re-chain — each turn).
1378
- // Non-reference prompts arm synchronously exactly as before; only the
1379
- // reference path defers through the resolver (raw prompt on failure).
1380
- if (!raw || !parsePresetReference(raw)) {
1381
- stream.loopActive = true;
1382
- stream.loopOriginalPrompt = raw;
1383
- if (maxIterations !== undefined && maxIterations > 0) {
1384
- stream.loopMaxIterations = Math.min(maxIterations, MAX_LOOP_ITERATIONS);
1385
- }
1386
- stream.loopGoal = goal?.trim() || null;
1387
- return;
1388
- }
1389
- return this.armLoopWithExpandedPreset(sessionId, raw, maxIterations, goal);
1390
- }
1391
- /**
1392
- * Async tail of {@link setLoopActive} for "@preset:<name>" loop prompts:
1393
- * resolves the preset, then arms the loop with the expanded prompt (raw
1394
- * content when the resolver fails). The chain budget is NOT consumed by
1395
- * loop arming — expansion happens once here, not on every iteration.
1396
- *
1397
- * Returns the expanded prompt so the caller (dispatcher, interval tick)
1398
- * can fire the FIRST turn with the resolved content instead of the raw
1399
- * reference. Resolves to `null` when arming was aborted because loop
1400
- * state changed while the resolver was awaited (the write is skipped so
1401
- * newer loop state is never clobbered) or when no stream exists.
1402
- */
1403
- async armLoopWithExpandedPreset(sessionId, prompt, maxIterations, goal) {
1404
- const stream = this.streams.get(sessionId);
1405
- if (!stream)
1406
- return null;
1407
- // Capture the epoch BEFORE the resolver await: any other setLoopActive
1408
- // call during the await (e.g. a loop stop from a client message) moves
1409
- // the generation and must win over this arming write.
1410
- const generation = stream.loopGeneration;
1411
- const expanded = await expandPresetPrompt(prompt, (name) => this.resolvePresetForSession(sessionId, name));
1412
- if (stream.loopGeneration !== generation) {
1413
- console.log(`[session-stream] ${sessionId}: loop arming aborted — ` +
1414
- `loop state changed while the preset was being resolved`);
1415
- return null;
1416
- }
1417
- stream.loopActive = true;
1418
- stream.loopOriginalPrompt = expanded;
1419
- if (maxIterations !== undefined && maxIterations > 0) {
1420
- stream.loopMaxIterations = Math.min(maxIterations, MAX_LOOP_ITERATIONS);
1421
- }
1422
- stream.loopGoal = goal?.trim() || null;
1423
- return expanded;
1424
- }
1425
1338
  /**
1426
1339
  * Studio team id used for machine-JWT backend calls (preset resolution):
1427
1340
  * the project's team binding wins over the machine default.
@@ -1451,12 +1364,9 @@ export class SessionStreamManager {
1451
1364
  stream.intervalConfig = config;
1452
1365
  stream.intervalLaunchCount = 0;
1453
1366
  this.scheduleIntervalTick(stream, minutes);
1454
- const interval = this.getIntervalEventSnapshot(stream);
1455
1367
  this.broadcast(stream, {
1456
1368
  type: "interval_armed",
1457
- intervalMinutes: interval.intervalMinutes ?? minutes,
1458
- hasLoop: interval.hasLoop,
1459
- goal: interval.goal,
1369
+ intervalMinutes: minutes,
1460
1370
  });
1461
1371
  }
1462
1372
  /**
@@ -1501,8 +1411,6 @@ export class SessionStreamManager {
1501
1411
  return {
1502
1412
  intervalMinutes: stream.intervalMinutes,
1503
1413
  nextTickAt: stream.intervalNextTickAt,
1504
- hasLoop: stream.intervalConfig?.loop ?? false,
1505
- goal: stream.intervalConfig?.loopGoal ?? null,
1506
1414
  occurrence: stream.intervalLaunchCount,
1507
1415
  };
1508
1416
  }
@@ -1511,7 +1419,7 @@ export class SessionStreamManager {
1511
1419
  stream.intervalNextTickAt = Date.now() + minutes * 60_000;
1512
1420
  stream.intervalTimer = setInterval(() => {
1513
1421
  // intervalTick is async and may reject (e.g. a sync throw from
1514
- // teamIdForSession inside the loop-arming resolver): without a
1422
+ // prompt()): without a
1515
1423
  // handler a timer callback rejection would be unhandled.
1516
1424
  this.intervalTick(stream).catch((err) => {
1517
1425
  const msg = err instanceof Error ? err.message : String(err);
@@ -1632,10 +1540,9 @@ export class SessionStreamManager {
1632
1540
  async intervalTick(stream) {
1633
1541
  if (!stream.intervalConfig)
1634
1542
  return;
1635
- const idle = !stream.loopActive && stream.currentTurn === null &&
1543
+ const idle = stream.currentTurn === null &&
1636
1544
  !stream.compacting && !stream.expandingQueueItem;
1637
1545
  if (!idle) {
1638
- const interval = this.getIntervalEventSnapshot(stream);
1639
1546
  this.broadcast(stream, {
1640
1547
  type: "interval_skipped",
1641
1548
  reason: "busy",
@@ -1649,18 +1556,7 @@ export class SessionStreamManager {
1649
1556
  occurrence: interval.occurrence,
1650
1557
  });
1651
1558
  const cfg = stream.intervalConfig;
1652
- let launchPrompt = cfg.prompt;
1653
- if (cfg.loop) {
1654
- // Await so a "@preset:" prompt is expanded before the turn fires.
1655
- const expanded = await this.setLoopActive(stream.sessionId, true, cfg.prompt, cfg.loopMaxIterations, cfg.loopGoal);
1656
- // Same raw-reference fix as the dispatcher path: the first interval
1657
- // launch must run the expanded prompt, not the literal "@preset:X"
1658
- // (null = arming aborted → fall back to the raw prompt).
1659
- if (typeof expanded === "string" && expanded) {
1660
- launchPrompt = expanded;
1661
- }
1662
- }
1663
- this.prompt(stream.sessionId, launchPrompt, cfg.modelId, cfg.images, cfg.reasoningEffort).catch((err) => {
1559
+ this.prompt(stream.sessionId, cfg.prompt, cfg.modelId, cfg.images, cfg.reasoningEffort).catch((err) => {
1664
1560
  console.error(`[interval] launch failed: ${err instanceof Error ? err.message : String(err)}`);
1665
1561
  });
1666
1562
  }
@@ -1707,55 +1603,6 @@ export class SessionStreamManager {
1707
1603
  });
1708
1604
  });
1709
1605
  }
1710
- /** Build the next autonomous-loop prompt after optional policy-driven compaction. */
1711
- buildLoopPrompt(stream) {
1712
- const parts = [];
1713
- if (stream.loopGoal) {
1714
- parts.push(`[GOAL]: ${stream.loopGoal}`);
1715
- }
1716
- parts.push(stream.loopOriginalPrompt);
1717
- parts.push("");
1718
- parts.push("--- LOOP INSTRUCTIONS ---\n" +
1719
- "You are running in an iterative loop. Each iteration you see your previous changes\n" +
1720
- "and can improve them further.\n" +
1721
- (stream.loopGoal
1722
- ? "The [GOAL] above defines what you need to achieve.\n"
1723
- : "\n") +
1724
- "CRITICAL: After EVERY response you MUST self-evaluate:\n" +
1725
- "- If the task IS FULLY COMPLETE (nothing meaningful left to do), " +
1726
- "respond with <LOOP_DONE> as the VERY FIRST text in your reply,\n" +
1727
- " followed by a brief summary of what was accomplished.\n" +
1728
- "- If more work remains, continue working WITHOUT <LOOP_DONE>.\n" +
1729
- "- <LOOP_DONE> is the ONLY way to stop the loop. If you do not emit it,\n" +
1730
- " you WILL be prompted again to keep working.\n" +
1731
- "Be decisive: if the task is done, stop. Do not keep polishing.");
1732
- return parts.join("\n");
1733
- }
1734
- async sendNextLoopIteration(stream) {
1735
- const policy = loadCompactionPolicy(stream.cwd);
1736
- const loopThresholdTokens = getAutoOlderHistoryContextThresholdTokens(policy, stream.contextWindowMax);
1737
- const contextWindowUsed = stream.contextWindowUsed;
1738
- const shouldCompact = loopThresholdTokens !== null &&
1739
- typeof contextWindowUsed === "number" &&
1740
- contextWindowUsed >= loopThresholdTokens;
1741
- if (shouldCompact) {
1742
- console.log(`[loop] compacting context (~${contextWindowUsed.toLocaleString()} tokens >= ` +
1743
- `${loopThresholdTokens.toLocaleString()} policy threshold)`);
1744
- const outcome = await this.requestManagerCompaction(stream, {
1745
- source: "loop",
1746
- });
1747
- if (outcome.status === "completed") {
1748
- console.log("[loop] compaction complete, sending next iteration");
1749
- }
1750
- else if (outcome.status === "skipped") {
1751
- console.log(`[loop] compaction skipped (${outcome.reason}), proceeding`);
1752
- }
1753
- else {
1754
- console.error(`[loop] compaction failed: ${outcome.error.message}`);
1755
- }
1756
- }
1757
- await this.prompt(stream.sessionId, this.buildLoopPrompt(stream), undefined);
1758
- }
1759
1606
  // --- internals ----------------------------------------------------------
1760
1607
  createStream(sessionId, history) {
1761
1608
  // Resolve cwd from the owning project. Sessions without a project
@@ -1793,7 +1640,6 @@ export class SessionStreamManager {
1793
1640
  streaming: false,
1794
1641
  retrying: false,
1795
1642
  compacting: false,
1796
- looping: false,
1797
1643
  queued: false,
1798
1644
  },
1799
1645
  },
@@ -1802,14 +1648,8 @@ export class SessionStreamManager {
1802
1648
  currentTurnSilenceTimer: null,
1803
1649
  currentMessageId: null,
1804
1650
  lastFlushedEventCount: 0,
1805
- loopActive: false,
1806
- loopIterationCount: 0,
1807
- loopOriginalPrompt: null,
1808
- loopMaxIterations: MAX_LOOP_ITERATIONS,
1809
- loopGoal: null,
1810
1651
  presetChainBudget: PRESET_CHAIN_BUDGET,
1811
1652
  expandingQueueItem: false,
1812
- loopGeneration: 0,
1813
1653
  intervalMinutes: null,
1814
1654
  intervalTimer: null,
1815
1655
  intervalNextTickAt: null,
@@ -2036,7 +1876,7 @@ export class SessionStreamManager {
2036
1876
  // row; observed in the wild: a single row with 12 full message cycles,
2037
1877
  // 67MB events_jsonl, content = sum of all cycles' texts). Earlier
2038
1878
  // cycles are already persisted in their own rows; turn-level text is
2039
- // kept separately in `turnAssistantText` for the loop-done marker.
1879
+ // kept separately in `turnAssistantText`.
2040
1880
  if (event.type === "message_start") {
2041
1881
  stream.currentTurn.events = [];
2042
1882
  stream.currentTurn.assistantText = "";
@@ -2283,66 +2123,6 @@ export class SessionStreamManager {
2283
2123
  // compact-and-retry). The deferred settle below re-resolves the final
2284
2124
  // state once the SDK is truly settled.
2285
2125
  stream.pendingContinue = event.willRetry === true;
2286
- // Autonomous iterative loop (Ralph Wiggum pattern).
2287
- // When loopActive is set, check for completion marker, then re-send
2288
- // the ORIGINAL prompt so the agent sees its prior changes and
2289
- // iteratively improves the solution.
2290
- if (stream.loopActive && stream.loopOriginalPrompt) {
2291
- // Use the turn-level accumulator: `assistantText` is rotated per
2292
- // message cycle now, but the marker is emitted in the first portion
2293
- // of the response — an earlier message cycle than the last one.
2294
- const finishedAssistantText = finishedTurn?.turnAssistantText ?? finishedTurn?.assistantText ?? "";
2295
- // Check for the loop-done marker in the first portion of the
2296
- // response (the prompt instructs the agent to emit it first).
2297
- const head = finishedAssistantText.slice(0, 500);
2298
- if (head.includes(LOOP_DONE_MARKER)) {
2299
- console.log(`[loop] completion marker detected after ${stream.loopIterationCount} iteration(s), stopping`);
2300
- const completedIterations = stream.loopIterationCount;
2301
- stream.loopActive = false;
2302
- stream.loopIterationCount = 0;
2303
- stream.loopOriginalPrompt = null;
2304
- stream.loopGoal = null;
2305
- this.broadcast(stream, {
2306
- type: "loop_complete",
2307
- iterations: completedIterations,
2308
- });
2309
- }
2310
- else if (stream.loopIterationCount >= stream.loopMaxIterations) {
2311
- console.log(`[loop] max iterations (${stream.loopMaxIterations}) reached, stopping`);
2312
- stream.loopActive = false;
2313
- stream.loopOriginalPrompt = null;
2314
- stream.loopGoal = null;
2315
- this.broadcast(stream, {
2316
- type: "loop_max_iterations",
2317
- iterations: stream.loopIterationCount,
2318
- });
2319
- }
2320
- else {
2321
- stream.loopIterationCount++;
2322
- console.log(`[loop] iteration ${stream.loopIterationCount}/${stream.loopMaxIterations}`);
2323
- this.broadcast(stream, {
2324
- type: "loop_iteration",
2325
- iteration: stream.loopIterationCount,
2326
- maxIterations: stream.loopMaxIterations,
2327
- prompt: stream.loopOriginalPrompt,
2328
- });
2329
- // Deferred via setImmediate: agent_end fires synchronously while
2330
- // the agent core is still settling the run (isStreaming is still
2331
- // true). Calling prompt() synchronously would throw "Agent is
2332
- // already processing" because finishRun() hasn't cleared
2333
- // isStreaming yet.
2334
- setImmediate(() => {
2335
- if (this.disposed)
2336
- return;
2337
- void this.sendNextLoopIteration(stream).catch((err) => {
2338
- console.error(`[loop] iteration failed: ${err instanceof Error ? err.message : String(err)}`);
2339
- stream.loopActive = false;
2340
- stream.loopOriginalPrompt = null;
2341
- stream.loopGoal = null;
2342
- });
2343
- });
2344
- }
2345
- }
2346
2126
  // Fork & Compact: after the first assistant turn of a forked session,
2347
2127
  // compact the context. The custom instructions reference the user's
2348
2128
  // new message so the summary preserves relevant context.
@@ -2351,8 +2131,8 @@ export class SessionStreamManager {
2351
2131
  stream.forkCompactSourceId = null; // one-shot
2352
2132
  this.triggerForkCompact(stream);
2353
2133
  }
2354
- // Auto-dequeue: after the turn finishes and no loop iteration is
2355
- // pending, check the persistent prompt queue. If there's a queued
2134
+ // Auto-dequeue: after the turn finishes, check the persistent prompt
2135
+ // queue. If there's a queued
2356
2136
  // prompt, start it immediately without broadcasting agent_end —
2357
2137
  // the frontend transitions seamlessly to the next turn.
2358
2138
  // Skip if compaction is in-flight (e.g. fork-compact just started
@@ -2364,17 +2144,13 @@ export class SessionStreamManager {
2364
2144
  // synchronously would throw "Agent is already processing" — the
2365
2145
  // agent core rejects all prompt() calls until finishRun() clears
2366
2146
  // isStreaming, which happens after this listener returns.
2367
- if (!stream.loopActive && !stream.loopOriginalPrompt) {
2368
- this.runTurnCleanup(finishedTurn);
2369
- }
2370
- if (!stream.loopActive || !stream.loopOriginalPrompt) {
2371
- if (!stream.compacting) {
2372
- setImmediate(() => {
2373
- if (this.disposed)
2374
- return;
2375
- this.maybeAutoDequeue(stream);
2376
- });
2377
- }
2147
+ this.runTurnCleanup(finishedTurn);
2148
+ if (!stream.compacting) {
2149
+ setImmediate(() => {
2150
+ if (this.disposed)
2151
+ return;
2152
+ this.maybeAutoDequeue(stream);
2153
+ });
2378
2154
  }
2379
2155
  this.settleRunState(stream);
2380
2156
  }
@@ -2403,7 +2179,7 @@ export class SessionStreamManager {
2403
2179
  /**
2404
2180
  * Resolve the authoritative run-state snapshot for a stream. This is the
2405
2181
  * ONLY place `phase`/`flags`/`busy` are computed. Phase precedence:
2406
- * retrying > compacting > looping > streaming > queued > idle.
2182
+ * retrying > compacting > streaming > queued > idle.
2407
2183
  */
2408
2184
  resolveRunState(stream) {
2409
2185
  const bridge = stream.bridge;
@@ -2411,7 +2187,6 @@ export class SessionStreamManager {
2411
2187
  streaming: stream.currentTurn != null || bridge.isStreaming?.() === true,
2412
2188
  retrying: bridge.isRetrying?.() === true,
2413
2189
  compacting: stream.compacting,
2414
- looping: stream.loopActive,
2415
2190
  // Prompt queue is pending work, not active work. It is exposed as a
2416
2191
  // flag for the badge only — it never feeds `busy`.
2417
2192
  queued: stream.queued,
@@ -2419,7 +2194,6 @@ export class SessionStreamManager {
2419
2194
  const busy = flags.streaming ||
2420
2195
  flags.retrying ||
2421
2196
  flags.compacting ||
2422
- flags.looping ||
2423
2197
  stream.pendingContinue ||
2424
2198
  // R1: a `prompt()` dispatch is in flight — the turn is not armed yet
2425
2199
  // (we may still be awaiting `ready`/`setModel`), but the session is
@@ -2429,15 +2203,13 @@ export class SessionStreamManager {
2429
2203
  ? "retrying"
2430
2204
  : flags.compacting
2431
2205
  ? "compacting"
2432
- : flags.looping
2433
- ? "looping"
2434
- : flags.streaming
2435
- ? "streaming"
2436
- : flags.queued
2437
- ? "queued"
2438
- : busy
2439
- ? "streaming" // pendingContinue gap — keep "streaming"
2440
- : "idle";
2206
+ : flags.streaming
2207
+ ? "streaming"
2208
+ : flags.queued
2209
+ ? "queued"
2210
+ : busy
2211
+ ? "streaming" // pendingContinue gap — keep "streaming"
2212
+ : "idle";
2441
2213
  return { ...stream.runState, busy, phase, flags };
2442
2214
  }
2443
2215
  computeBusy(stream) {
@@ -2597,14 +2369,12 @@ export class SessionStreamManager {
2597
2369
  }
2598
2370
  /**
2599
2371
  * Auto-dequeue the next prompt from the persistent queue and start a
2600
- * new turn. Called from the `agent_end` handler when no loop is active.
2372
+ * new turn. Called from the `agent_end` handler.
2601
2373
  * Returns true if a prompt was dequeued and a turn started.
2602
2374
  */
2603
2375
  maybeAutoDequeue(stream) {
2604
2376
  if (stream.currentTurn || stream.compacting)
2605
2377
  return false;
2606
- if (stream.loopActive && stream.loopOriginalPrompt)
2607
- return false;
2608
2378
  // A dequeued "@preset:" reference is being expanded (async resolver) —
2609
2379
  // never dequeue a second item into the same gap.
2610
2380
  if (stream.expandingQueueItem)
@@ -71,10 +71,10 @@ export interface WireMessage {
71
71
  /**
72
72
  * Authoritative per-session agent run phase. The CLI resolves this single
73
73
  * value server-side (see `SessionStream.resolveRunState`) using the
74
- * precedence: retrying > compacting > looping > streaming > queued > idle.
74
+ * precedence: retrying > compacting > streaming > queued > idle.
75
75
  * The frontend badge renders it but never derives it locally.
76
76
  */
77
- export type RunPhase = "idle" | "streaming" | "retrying" | "compacting" | "looping" | "queued";
77
+ export type RunPhase = "idle" | "streaming" | "retrying" | "compacting" | "queued";
78
78
  /**
79
79
  * Single source of truth for "is the agent working right now". `busy` is the
80
80
  * only field the frontend composer should use to decide send vs queue; `phase`
@@ -93,7 +93,6 @@ export interface WireRunState {
93
93
  streaming: boolean;
94
94
  retrying: boolean;
95
95
  compacting: boolean;
96
- looping: boolean;
97
96
  queued: boolean;
98
97
  };
99
98
  }
@@ -316,18 +315,12 @@ export interface WireCompactionTokenSourceBucket {
316
315
  export type ClientMessage = {
317
316
  type: "user_message";
318
317
  content: string;
319
- /** Enable autonomous iterative loop for this message (Ralph Wiggum pattern). */
320
- loop?: boolean;
321
318
  /** Optional image attachments (hosted `url` preferred, inline base64 legacy). */
322
319
  images?: ImageAttachment[];
323
- /** Optional loop max iterations (server caps at MAX_LOOP_ITERATIONS). */
324
- loopMaxIterations?: number;
325
- /** Optional loop goal / acceptance criteria. */
326
- loopGoal?: string;
327
320
  /**
328
321
  * Optional interval scheduler (minutes). When set, arms a recurring
329
- * scheduler that re-launches the prompt (or a fresh loop, when `loop`
330
- * is also set) every N minutes, guarded by an idle check. The first
322
+ * scheduler that re-launches the prompt every N minutes, guarded by an
323
+ * idle check. The first
331
324
  * launch happens immediately via the normal prompt path; the scheduler
332
325
  * ticks from +N minutes.
333
326
  */
@@ -337,7 +330,7 @@ export type ClientMessage = {
337
330
  * immediately (and resets the cadence); `"stop"` disarms the scheduler.
338
331
  * Sent as a user_message with no content.
339
332
  */
340
- loopControl?: "run_now" | "stop";
333
+ intervalControl?: "run_now" | "stop";
341
334
  };
342
335
  /**
343
336
  * Subset of `ServerEvent` that's safe to buffer per turn and replay on
@@ -394,8 +387,6 @@ export type ServerEvent = {
394
387
  intervalMinutes?: number | null;
395
388
  intervalNextTickAt?: number | null;
396
389
  intervalLaunchCount?: number;
397
- intervalHasLoop?: boolean;
398
- intervalGoal?: string | null;
399
390
  /** Authoritative run-state snapshot for reconnect/hydration. */
400
391
  runState?: WireRunState;
401
392
  } | {
@@ -426,8 +417,6 @@ export type ServerEvent = {
426
417
  intervalMinutes?: number | null;
427
418
  intervalNextTickAt?: number | null;
428
419
  intervalLaunchCount?: number;
429
- intervalHasLoop?: boolean;
430
- intervalGoal?: string | null;
431
420
  /** Authoritative run-state snapshot for reconnect/hydration. */
432
421
  runState?: WireRunState;
433
422
  } | {
@@ -559,6 +548,19 @@ export type ServerEvent = {
559
548
  } | {
560
549
  type: "error";
561
550
  message: string;
551
+ } | {
552
+ /**
553
+ * Emitted when a requested modelId is no longer active in the backend's
554
+ * available-models registry (stale whitelist entry) and the server
555
+ * substituted an active fallback — the backend default (`isDefault`)
556
+ * when present, else the first available model — so the turn proceeds
557
+ * instead of failing. The turn runs with `activeModelId`, which is
558
+ * also persisted as the session's sticky model. Clients should update
559
+ * their model picker to `activeModelId`.
560
+ */
561
+ type: "model_fallback";
562
+ requestedModelId: string;
563
+ activeModelId: string;
562
564
  } | {
563
565
  /**
564
566
  * Best-effort feedback for a `stop_tool_call` request. `stopped` when the
@@ -570,26 +572,10 @@ export type ServerEvent = {
570
572
  status: "stopped" | "not_found";
571
573
  /** Echoed reason from the request, when supplied. */
572
574
  reason?: string;
573
- } | {
574
- /** Emitted at the start of each loop iteration. */
575
- type: "loop_iteration";
576
- iteration: number;
577
- maxIterations: number;
578
- prompt: string;
579
- } | {
580
- /** Emitted when the agent signals completion via <LOOP_DONE> marker. */
581
- type: "loop_complete";
582
- iterations: number;
583
- } | {
584
- /** Emitted when the loop hits the safety iteration limit. */
585
- type: "loop_max_iterations";
586
- iterations: number;
587
575
  } | {
588
576
  /** Emitted when the interval scheduler is armed. */
589
577
  type: "interval_armed";
590
578
  intervalMinutes: number;
591
- hasLoop: boolean;
592
- goal: string | null;
593
579
  } | {
594
580
  /** Emitted each time the interval scheduler launches a new occurrence. */
595
581
  type: "interval_launched";