jeopi-cli 16.6.5 → 16.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "jeopi-cli",
4
- "version": "16.6.5",
4
+ "version": "16.6.8",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/akillness/jeopi",
7
7
  "author": "Can Boluk",
@@ -57,17 +57,17 @@
57
57
  "@agentclientprotocol/sdk": "0.25.0",
58
58
  "@babel/parser": "^7.29.7",
59
59
  "@mozilla/readability": "^0.6.0",
60
- "jeopi-hashline": "16.6.5",
61
- "jeopi-stats": "16.6.5",
62
- "jeopi-agent-core": "16.6.5",
63
- "jeopi-ai": "16.6.5",
64
- "jeopi-catalog": "16.6.5",
65
- "jeopi-mnemopi": "16.6.5",
66
- "jeopi-natives": "16.6.5",
67
- "jeopi-tui": "16.6.5",
68
- "jeopi-utils": "16.6.5",
69
- "jeopi-wire": "16.6.5",
70
- "jeopi-snapcompact": "16.6.5",
60
+ "jeopi-hashline": "16.6.8",
61
+ "jeopi-stats": "16.6.8",
62
+ "jeopi-agent-core": "16.6.8",
63
+ "jeopi-ai": "16.6.8",
64
+ "jeopi-catalog": "16.6.8",
65
+ "jeopi-mnemopi": "16.6.8",
66
+ "jeopi-natives": "16.6.8",
67
+ "jeopi-tui": "16.6.8",
68
+ "jeopi-utils": "16.6.8",
69
+ "jeopi-wire": "16.6.8",
70
+ "jeopi-snapcompact": "16.6.8",
71
71
  "@opentelemetry/api": "^1.9.1",
72
72
  "@opentelemetry/context-async-hooks": "^2.7.1",
73
73
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -645,6 +645,12 @@ type CompactionCheckResult = Readonly<{
645
645
  historyRewritten?: boolean;
646
646
  }>;
647
647
 
648
+ interface RetryErrorOptions {
649
+ allowModelFallback?: boolean;
650
+ fireworksFastFallback?: boolean;
651
+ hardErrorFallback?: boolean;
652
+ }
653
+
648
654
  const COMPACTION_CHECK_NONE: CompactionCheckResult = {
649
655
  deferredHandoff: false,
650
656
  continuationScheduled: false,
@@ -1545,6 +1551,8 @@ type ScheduledAgentContinueOptions = {
1545
1551
  delayMs?: number;
1546
1552
  generation?: number;
1547
1553
  shouldContinue?: () => boolean;
1554
+ signal?: AbortSignal;
1555
+ onSettled?: () => Promise<void>;
1548
1556
  onSkip?: (reason: AgentContinueSkipReason) => void;
1549
1557
  onError?: () => void;
1550
1558
  };
@@ -1694,6 +1702,7 @@ export class AgentSession {
1694
1702
 
1695
1703
  // Retry state
1696
1704
  #retryAbortController: AbortController | undefined = undefined;
1705
+ #retryGeneration = 0;
1697
1706
  #retryAttempt = 0;
1698
1707
  /** Rung-4 classifier-refusal resend streak (post fallback-ladder). */
1699
1708
  #refusalBackoff: { startedAt: number; attempt: number } | undefined = undefined;
@@ -4179,7 +4188,11 @@ export class AgentSession {
4179
4188
 
4180
4189
  #schedulePostPromptTask(
4181
4190
  task: (signal: AbortSignal) => Promise<void>,
4182
- options?: { delayMs?: number; generation?: number; onSkip?: (reason: PostPromptSkipReason) => void },
4191
+ options?: {
4192
+ delayMs?: number;
4193
+ generation?: number;
4194
+ onSkip?: (reason: PostPromptSkipReason) => void | Promise<void>;
4195
+ },
4183
4196
  ): void {
4184
4197
  const delayMs = options?.delayMs ?? 0;
4185
4198
  const signal = this.#postPromptTasksAbortController.signal;
@@ -4188,15 +4201,16 @@ export class AgentSession {
4188
4201
  try {
4189
4202
  await scheduler.wait(delayMs, { signal });
4190
4203
  } catch {
4204
+ await options?.onSkip?.("aborted");
4191
4205
  return;
4192
4206
  }
4193
4207
  }
4194
4208
  if (signal.aborted) {
4195
- options?.onSkip?.("aborted");
4209
+ await options?.onSkip?.("aborted");
4196
4210
  return;
4197
4211
  }
4198
4212
  if (options?.generation !== undefined && this.#promptGeneration !== options.generation) {
4199
- options.onSkip?.("stale-generation");
4213
+ await options.onSkip?.("stale-generation");
4200
4214
  return;
4201
4215
  }
4202
4216
  await task(signal);
@@ -4212,41 +4226,74 @@ export class AgentSession {
4212
4226
  #scheduleAgentContinue(options?: ScheduledAgentContinueOptions): void {
4213
4227
  this.#schedulePostPromptTask(
4214
4228
  async signal => {
4215
- // Defense in depth: if compaction/handoff slipped onto the post-prompt queue
4216
- // alongside us (e.g. via a scheduler we don't own), refuse to start a fresh
4217
- // streaming turn — agent.continue() here would race the handoff's session
4218
- // reset. The first-class fix is in #checkCompaction/the agent_end handler,
4219
- // but this guard catches anything that bypasses that path.
4220
- if (signal.aborted || this.#isDisposed || this.isCompacting || this.isGeneratingHandoff) {
4221
- this.#skipAgentContinue("session-unavailable", options);
4222
- return;
4223
- }
4224
- if (options?.shouldContinue && !options.shouldContinue()) {
4225
- this.#skipAgentContinue("should-continue-false", options);
4226
- return;
4227
- }
4228
- this.#beginInFlight();
4229
4229
  try {
4230
- await this.#maybeRestoreRetryFallbackPrimary();
4231
- if (signal.aborted || this.#isDisposed) {
4232
- this.#skipAgentContinue("post-restore-unavailable", options);
4230
+ // Defense in depth: if compaction/handoff slipped onto the post-prompt queue
4231
+ // alongside us (e.g. via a scheduler we don't own), refuse to start a fresh
4232
+ // streaming turn — agent.continue() here would race the handoff's session
4233
+ // reset. The first-class fix is in #checkCompaction/the agent_end handler,
4234
+ // but this guard catches anything that bypasses that path.
4235
+ if (signal.aborted || this.#isDisposed || this.isCompacting || this.isGeneratingHandoff) {
4236
+ this.#skipAgentContinue("session-unavailable", options);
4233
4237
  return;
4234
4238
  }
4235
- await this.agent.continue();
4236
- } catch (error) {
4237
- logger.warn("agent.continue failed after scheduling", {
4238
- error: error instanceof Error ? error.message : String(error),
4239
- stack: error instanceof Error ? error.stack : undefined,
4240
- });
4241
- options?.onError?.();
4239
+ if (options?.shouldContinue && !options.shouldContinue()) {
4240
+ this.#skipAgentContinue("should-continue-false", options);
4241
+ return;
4242
+ }
4243
+ this.#beginInFlight();
4244
+ try {
4245
+ if (options?.signal) {
4246
+ const restoreSignal = AbortSignal.any([signal, options.signal]);
4247
+ const cancelled = Promise.withResolvers<void>();
4248
+ const onAbort = () => cancelled.resolve();
4249
+ restoreSignal.addEventListener("abort", onAbort, { once: true });
4250
+ try {
4251
+ if (!restoreSignal.aborted) {
4252
+ await Promise.race([
4253
+ this.#maybeRestoreRetryFallbackPrimary(restoreSignal),
4254
+ cancelled.promise,
4255
+ ]);
4256
+ }
4257
+ } finally {
4258
+ restoreSignal.removeEventListener("abort", onAbort);
4259
+ }
4260
+ } else {
4261
+ await this.#maybeRestoreRetryFallbackPrimary(signal);
4262
+ }
4263
+ if (options?.signal && options.shouldContinue && !options.shouldContinue()) {
4264
+ this.#skipAgentContinue("should-continue-false", options);
4265
+ return;
4266
+ }
4267
+ if (signal.aborted || this.#isDisposed) {
4268
+ this.#skipAgentContinue("post-restore-unavailable", options);
4269
+ return;
4270
+ }
4271
+ if (options?.onSettled) await options.onSettled();
4272
+ if (options?.signal && options.shouldContinue && !options.shouldContinue()) {
4273
+ this.#skipAgentContinue("should-continue-false", options);
4274
+ return;
4275
+ }
4276
+ await this.agent.continue();
4277
+ } catch (error) {
4278
+ logger.warn("agent.continue failed after scheduling", {
4279
+ error: error instanceof Error ? error.message : String(error),
4280
+ stack: error instanceof Error ? error.stack : undefined,
4281
+ });
4282
+ options?.onError?.();
4283
+ } finally {
4284
+ this.#endInFlight();
4285
+ }
4242
4286
  } finally {
4243
- this.#endInFlight();
4287
+ if (options?.onSettled) await options.onSettled();
4244
4288
  }
4245
4289
  },
4246
4290
  {
4247
4291
  delayMs: options?.delayMs,
4248
4292
  generation: options?.generation,
4249
- onSkip: reason => this.#skipAgentContinue(reason, options),
4293
+ onSkip: async reason => {
4294
+ this.#skipAgentContinue(reason, options);
4295
+ if (options?.onSettled) await options.onSettled();
4296
+ },
4250
4297
  },
4251
4298
  );
4252
4299
  }
@@ -13183,6 +13230,7 @@ export class AgentSession {
13183
13230
  selector: RetryFallbackSelector,
13184
13231
  currentSelector: string,
13185
13232
  options?: { pinFallback?: boolean },
13233
+ cancelled?: () => boolean,
13186
13234
  ): Promise<void> {
13187
13235
  const resolved = resolveModelOverride([selector.raw], this.#modelRegistry, this.settings);
13188
13236
  const candidate = resolved.model ?? this.#modelRegistry.find(selector.provider, selector.id);
@@ -13190,6 +13238,7 @@ export class AgentSession {
13190
13238
  throw new Error(`Retry fallback model not found: ${selector.raw}`);
13191
13239
  }
13192
13240
  const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
13241
+ if (cancelled?.()) return;
13193
13242
  if (!apiKey) {
13194
13243
  throw new Error(`No API key for retry fallback ${selector.raw}`);
13195
13244
  }
@@ -13223,7 +13272,11 @@ export class AgentSession {
13223
13272
  });
13224
13273
  }
13225
13274
 
13226
- async #tryRetryModelFallback(currentSelector: string, options?: { pinFallback?: boolean }): Promise<boolean> {
13275
+ async #tryRetryModelFallback(
13276
+ currentSelector: string,
13277
+ options?: { pinFallback?: boolean },
13278
+ cancelled?: () => boolean,
13279
+ ): Promise<boolean> {
13227
13280
  const role = this.#activeRetryFallback?.role ?? this.#resolveRetryFallbackRole(currentSelector);
13228
13281
  if (!role) return false;
13229
13282
 
@@ -13233,8 +13286,9 @@ export class AgentSession {
13233
13286
  const candidate = resolved.model ?? this.#modelRegistry.find(selector.provider, selector.id);
13234
13287
  if (!candidate) continue;
13235
13288
  const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
13289
+ if (cancelled?.()) return false;
13236
13290
  if (!apiKey) continue;
13237
- await this.#applyRetryFallbackCandidate(role, selector, currentSelector, options);
13291
+ await this.#applyRetryFallbackCandidate(role, selector, currentSelector, options, cancelled);
13238
13292
  return true;
13239
13293
  }
13240
13294
 
@@ -13305,13 +13359,13 @@ export class AgentSession {
13305
13359
  * fallback that makes Fast a safe default. Returns false when the current
13306
13360
  * model is not a fast variant, the base id is missing, or it has no key.
13307
13361
  */
13308
- async #tryFireworksFastFallback(currentSelector: string): Promise<boolean> {
13362
+ async #tryFireworksFastFallback(currentSelector: string, cancelled?: () => boolean): Promise<boolean> {
13309
13363
  const model = this.#activeFireworksFastModel();
13310
13364
  if (!model) return false;
13311
13365
  const baseModel = this.#modelRegistry.find("fireworks", toFireworksBaseModelId(model.id));
13312
13366
  if (!baseModel) return false;
13313
13367
  const apiKey = await this.#modelRegistry.getApiKey(baseModel, this.sessionId);
13314
- if (!apiKey) return false;
13368
+ if (cancelled?.() || !apiKey) return false;
13315
13369
  const baseSelector = formatModelStringWithRouting(baseModel);
13316
13370
  this.#setModelWithProviderSessionReset(baseModel);
13317
13371
  this.sessionManager.appendModelChange(baseSelector, EPHEMERAL_MODEL_CHANGE_ROLE);
@@ -13325,7 +13379,7 @@ export class AgentSession {
13325
13379
  return true;
13326
13380
  }
13327
13381
 
13328
- async #maybeRestoreRetryFallbackPrimary(): Promise<void> {
13382
+ async #maybeRestoreRetryFallbackPrimary(signal?: AbortSignal): Promise<void> {
13329
13383
  if (!this.#activeRetryFallback) return;
13330
13384
  if (this.#activeRetryFallback.pinned) return;
13331
13385
  if (this.#getRetryFallbackRevertPolicy() !== "cooldown-expiry") return;
@@ -13357,7 +13411,7 @@ export class AgentSession {
13357
13411
  resolvedPrimary.model ?? this.#modelRegistry.find(originalSelector.provider, originalSelector.id);
13358
13412
  if (!primaryModel) return;
13359
13413
  const apiKey = await this.#modelRegistry.getApiKey(primaryModel, this.sessionId);
13360
- if (!apiKey) return;
13414
+ if (signal?.aborted || !apiKey) return;
13361
13415
 
13362
13416
  const currentThinkingLevel = this.configuredThinkingLevel();
13363
13417
  const thinkingToApply =
@@ -13435,9 +13489,79 @@ export class AgentSession {
13435
13489
  * the error without a same-model backoff retry.
13436
13490
  * @returns true if retry was initiated, false if max retries exceeded or disabled
13437
13491
  */
13438
- async #handleRetryableError(
13492
+ async #handleRetryableError(message: AssistantMessage, options?: RetryErrorOptions): Promise<boolean> {
13493
+ if (!this.settings.get("retry.enabled") && !options?.fireworksFastFallback) return false;
13494
+ const generation = this.#promptGeneration;
13495
+ const retryGeneration = this.#retryGeneration;
13496
+ const controller = new AbortController();
13497
+ this.#retryAbortController?.abort();
13498
+ this.#retryAbortController = controller;
13499
+ const deadline = this.agent.deadline;
13500
+ const cancelled = () =>
13501
+ controller.signal.aborted ||
13502
+ this.#retryGeneration !== retryGeneration ||
13503
+ this.#promptGeneration !== generation ||
13504
+ this.#abortInProgress ||
13505
+ this.#isDisposed ||
13506
+ (deadline !== undefined && Date.now() >= deadline);
13507
+ const cancellation = Promise.withResolvers<boolean>();
13508
+ const onAbort = () => cancellation.resolve(false);
13509
+ controller.signal.addEventListener("abort", onAbort, { once: true });
13510
+ let deadlineTimer: Timer | undefined;
13511
+ if (deadline !== undefined) {
13512
+ if (deadline <= Date.now()) controller.abort("Deadline exceeded");
13513
+ else deadlineTimer = setTimeout(() => controller.abort("Deadline exceeded"), deadline - Date.now());
13514
+ }
13515
+ let scheduled = false;
13516
+ let finished = false;
13517
+ const finish = async () => {
13518
+ if (finished) return;
13519
+ finished = true;
13520
+ controller.signal.removeEventListener("abort", onAbort);
13521
+ if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
13522
+ const ownsRetry = this.#retryAbortController === controller;
13523
+ if (ownsRetry) this.#retryAbortController = undefined;
13524
+ if (ownsRetry && cancelled()) {
13525
+ const attempt = this.#retryAttempt;
13526
+ this.#retryAttempt = 0;
13527
+ this.#refusalBackoff = undefined;
13528
+ await this.#emitSessionEvent({
13529
+ type: "auto_retry_end",
13530
+ success: false,
13531
+ attempt,
13532
+ finalError: deadline !== undefined && Date.now() >= deadline ? "Deadline exceeded" : "Retry cancelled",
13533
+ });
13534
+ this.#resolveRetry();
13535
+ }
13536
+ };
13537
+ const scheduleRetry = () => {
13538
+ scheduled = true;
13539
+ this.#scheduleAgentContinue({
13540
+ delayMs: 1,
13541
+ generation,
13542
+ signal: controller.signal,
13543
+ shouldContinue: () => !cancelled(),
13544
+ onSkip: () => this.#resolveRetry(),
13545
+ onSettled: finish,
13546
+ });
13547
+ };
13548
+ try {
13549
+ if (cancelled()) return false;
13550
+ return await Promise.race([
13551
+ this.#prepareRetry(message, options, controller, cancelled, scheduleRetry),
13552
+ cancellation.promise,
13553
+ ]);
13554
+ } finally {
13555
+ if (!scheduled || cancelled()) await finish();
13556
+ }
13557
+ }
13558
+
13559
+ async #prepareRetry(
13439
13560
  message: AssistantMessage,
13440
- options?: { allowModelFallback?: boolean; fireworksFastFallback?: boolean; hardErrorFallback?: boolean },
13561
+ options: RetryErrorOptions | undefined,
13562
+ retryAbortController: AbortController,
13563
+ cancelled: () => boolean,
13564
+ scheduleRetry: () => void,
13441
13565
  ): Promise<boolean> {
13442
13566
  const retrySettings = this.settings.getGroup("retry");
13443
13567
  // The Fireworks Fast→base degrade is an intrinsic model-selection safety net,
@@ -13446,7 +13570,7 @@ export class AgentSession {
13446
13570
  if (!retrySettings.enabled && !options?.fireworksFastFallback) return false;
13447
13571
  const classifierRefusal = this.#isClassifierRefusal(message);
13448
13572
 
13449
- const generation = this.#promptGeneration;
13573
+ if (cancelled()) return false;
13450
13574
  this.#retryAttempt++;
13451
13575
 
13452
13576
  // Create retry promise on first attempt so waitForRetry() can await it
@@ -13508,14 +13632,23 @@ export class AgentSession {
13508
13632
  errorMessage,
13509
13633
  errorId: message.errorId,
13510
13634
  });
13511
- this.#scheduleAgentContinue({ delayMs: 1, generation });
13635
+ if (cancelled()) return false;
13636
+ scheduleRetry();
13512
13637
  return true;
13513
13638
  }
13514
13639
  }
13515
13640
 
13516
13641
  const id = this.#classifyRetryMessage(message);
13517
- const staleOpenAIResponsesReplayError = AIError.is(id, AIError.Flag.StaleResponsesItem);
13518
13642
  const parsedRetryAfterMs = this.#parseRetryAfterMsFromError(errorMessage);
13643
+ const explicitQuotaExhaustion =
13644
+ /\bquota[\s_-]+exhausted\b|quota will reset|exhausted your capacity|insufficient_quota|billing_hard_limit_reached/i.test(
13645
+ errorMessage,
13646
+ );
13647
+ const hardQuota =
13648
+ AIError.is(id, AIError.Flag.UsageLimit) &&
13649
+ (explicitQuotaExhaustion ||
13650
+ (parsedRetryAfterMs === undefined && parseRateLimitReason(errorMessage) === "QUOTA_EXHAUSTED"));
13651
+ const staleOpenAIResponsesReplayError = AIError.is(id, AIError.Flag.StaleResponsesItem);
13519
13652
  let delayMs = staleOpenAIResponsesReplayError
13520
13653
  ? 0
13521
13654
  : calculateRetryBackoffDelayMs(retrySettings.baseDelayMs, this.#retryAttempt);
@@ -13524,6 +13657,7 @@ export class AgentSession {
13524
13657
  // Set when a usage-limit error pinned the wait to credential
13525
13658
  // availability — suppresses the generic retry-after bump below.
13526
13659
  let usageLimitWaitMs: number | undefined;
13660
+ let waitingForSibling = false;
13527
13661
 
13528
13662
  if (staleOpenAIResponsesReplayError) {
13529
13663
  this.#resetCurrentResponsesProviderSession("stale replay error");
@@ -13538,8 +13672,10 @@ export class AgentSession {
13538
13672
  retryAfterMs,
13539
13673
  baseUrl: this.model.baseUrl,
13540
13674
  modelId: this.model.id,
13675
+ signal: retryAbortController.signal,
13541
13676
  },
13542
13677
  );
13678
+ if (cancelled()) return false;
13543
13679
  if (outcome.switched) {
13544
13680
  switchedCredential = true;
13545
13681
  delayMs = 0;
@@ -13561,6 +13697,7 @@ export class AgentSession {
13561
13697
  // trips the fail-fast cap below.
13562
13698
  usageLimitWaitMs = retryAfterMs;
13563
13699
  if (outcome.retryAtMs !== undefined) {
13700
+ waitingForSibling = true;
13564
13701
  const siblingWaitMs = Math.max(0, outcome.retryAtMs - Date.now()) + SIBLING_UNBLOCK_BUFFER_MS;
13565
13702
  if (siblingWaitMs < usageLimitWaitMs) {
13566
13703
  usageLimitWaitMs = siblingWaitMs;
@@ -13571,6 +13708,7 @@ export class AgentSession {
13571
13708
  }
13572
13709
  }
13573
13710
  }
13711
+ if (cancelled()) return false;
13574
13712
 
13575
13713
  const allowModelFallback = options?.allowModelFallback !== false;
13576
13714
  const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
@@ -13579,14 +13717,20 @@ export class AgentSession {
13579
13717
  if (!classifierRefusal) {
13580
13718
  this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
13581
13719
  }
13582
- switchedModel = await this.#tryRetryModelFallback(currentSelector, { pinFallback: classifierRefusal });
13720
+ switchedModel = await this.#tryRetryModelFallback(
13721
+ currentSelector,
13722
+ { pinFallback: classifierRefusal },
13723
+ cancelled,
13724
+ );
13725
+ if (cancelled()) return false;
13583
13726
  }
13584
13727
  // Auto fallback from a Fireworks Fast variant to its base model. Independent
13585
13728
  // of the role-fallback setting: it's intrinsic to the Fast contract (speed
13586
13729
  // best-effort, degrade to Standard on failure) and triggers on hard router
13587
13730
  // errors the generic retry classifier would otherwise reject.
13588
13731
  if (!switchedModel && allowModelFallback && options?.fireworksFastFallback) {
13589
- switchedModel = await this.#tryFireworksFastFallback(currentSelector);
13732
+ switchedModel = await this.#tryFireworksFastFallback(currentSelector, cancelled);
13733
+ if (cancelled()) return false;
13590
13734
  }
13591
13735
  if (switchedModel) {
13592
13736
  delayMs = 0;
@@ -13661,6 +13805,18 @@ export class AgentSession {
13661
13805
  return false;
13662
13806
  }
13663
13807
 
13808
+ // Explicit account exhaustion cannot recover by resending. Generic quota errors
13809
+ // with concrete retry hints can describe transient token windows instead.
13810
+ if (hardQuota && !switchedCredential && !switchedModel && !waitingForSibling) {
13811
+ const attempt = this.#retryAttempt;
13812
+ this.#retryAttempt = 0;
13813
+ this.#refusalBackoff = undefined;
13814
+ await this.#emitSessionEvent({ type: "auto_retry_end", success: false, attempt, finalError: errorMessage });
13815
+ this.#resolveRetry();
13816
+ return false;
13817
+ }
13818
+
13819
+ if (cancelled()) return false;
13664
13820
  await this.#emitSessionEvent({
13665
13821
  type: "auto_retry_start",
13666
13822
  attempt: this.#retryAttempt,
@@ -13669,6 +13825,7 @@ export class AgentSession {
13669
13825
  errorMessage,
13670
13826
  errorId: message.errorId,
13671
13827
  });
13828
+ if (cancelled()) return false;
13672
13829
 
13673
13830
  // Remove the failed assistant message from active context before retrying.
13674
13831
  this.#removeAssistantMessageFromActiveContext(message, "auto-retry");
@@ -13679,35 +13836,16 @@ export class AgentSession {
13679
13836
  this.#maybeInjectThinkingLoopRedirect(id);
13680
13837
 
13681
13838
  // Wait with exponential backoff (abortable).
13682
- const retryAbortController = new AbortController();
13683
- this.#retryAbortController?.abort();
13684
- this.#retryAbortController = retryAbortController;
13685
13839
  try {
13686
13840
  await scheduler.wait(delayMs, { signal: retryAbortController.signal });
13687
13841
  } catch {
13688
- if (this.#retryAbortController !== retryAbortController) {
13689
- return false;
13690
- }
13691
- // Aborted during sleep - emit end event so UI can clean up
13692
- const attempt = this.#retryAttempt;
13693
- this.#retryAttempt = 0;
13694
- this.#refusalBackoff = undefined;
13695
- this.#retryAbortController = undefined;
13696
- await this.#emitSessionEvent({
13697
- type: "auto_retry_end",
13698
- success: false,
13699
- attempt,
13700
- finalError: "Retry cancelled",
13701
- });
13702
- this.#resolveRetry();
13842
+ retryAbortController.abort();
13703
13843
  return false;
13704
13844
  }
13705
- if (this.#retryAbortController === retryAbortController) {
13706
- this.#retryAbortController = undefined;
13707
- }
13845
+ if (cancelled()) return false;
13708
13846
 
13709
13847
  // Retry via continue() outside the agent_end event callback chain.
13710
- this.#scheduleAgentContinue({ delayMs: 1, generation });
13848
+ scheduleRetry();
13711
13849
 
13712
13850
  return true;
13713
13851
  }
@@ -13745,8 +13883,9 @@ export class AgentSession {
13745
13883
  * Cancel in-progress retry.
13746
13884
  */
13747
13885
  abortRetry(): void {
13886
+ this.#retryGeneration++;
13748
13887
  this.#retryAbortController?.abort();
13749
- // Note: _retryAttempt is reset in the catch block of _autoRetry
13888
+ // The retry owner resets attempt state when cancellation settles.
13750
13889
  this.#resolveRetry();
13751
13890
  }
13752
13891
 
@@ -1,5 +1,5 @@
1
1
  import type { AgentMessage } from "jeopi-agent-core";
2
- import { getBlobsDir, isEnoent, parseJsonlLenient } from "jeopi-utils";
2
+ import { getBlobsDir, isEnoent, parseJsonlLenient, readLines } from "jeopi-utils";
3
3
  import { BlobStore, isBlobRef, resolveImageData, resolveImageDataUrl } from "./blob-store";
4
4
  import { buildSessionContext } from "./session-context";
5
5
  import {
@@ -109,66 +109,24 @@ export async function loadEntriesFromFileStream(filePath: string): Promise<{
109
109
  const entries: FileEntry[] = [];
110
110
  let titleSlot: SessionTitleUpdate | undefined;
111
111
  let sawFirstLine = false;
112
- // Byte buffer (NOT a decoded string): multibyte UTF-8 sequences that straddle
113
- // a stream-chunk boundary stay intact, and Bun.JSONL.parseChunk accepts typed
114
- // arrays directly. Only the unconsumed remainder is held (≤ one record + a
115
- // chunk), so the ≥8MiB memory guard is preserved (the file is never fully
116
- // loaded into memory).
117
- let buffer: Uint8Array = new Uint8Array();
112
+ // readLines grows its byte buffer geometrically and yields complete records.
113
+ // Parse each record once: retrying an incomplete multi-MB summary for every
114
+ // stream chunk repeatedly copies and parses the same growing payload.
118
115
  const decoder = new TextDecoder();
119
116
 
120
- const drain = () => {
121
- while (buffer.length > 0) {
122
- const { values, error, read, done } = Bun.JSONL.parseChunk(buffer);
123
- if (values.length > 0) {
124
- for (const value of values) entries.push(value as FileEntry);
125
- }
126
- if (error) {
127
- // Malformed record: skip past the next newline and continue.
128
- const nextNewline = buffer.indexOf(0x0a, read);
129
- if (nextNewline === -1) break; // rest of the bad line not yet received
130
- buffer = buffer.subarray(nextNewline + 1);
131
- continue;
132
- }
133
- if (read === 0) break; // incomplete record awaiting more data
134
- buffer = buffer.subarray(read);
135
- if (done) {
136
- buffer = new Uint8Array();
137
- break;
138
- }
139
- }
140
- };
141
-
142
117
  try {
143
- for await (const chunk of Bun.file(filePath).stream()) {
144
- buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]);
145
- // The optional fixed-width title slot is a physical first line that is
146
- // NOT JSON; peel it before the parser would (correctly) reject it. The
147
- // first line ends at a '\n' byte, so it is a complete UTF-8 sequence and
148
- // safe to decode. A non-slot first line is a real entry and is left for
149
- // the parser; a blank first line is left for the parser to skip.
118
+ for await (const bytes of readLines(Bun.file(filePath).stream())) {
119
+ const line = decoder.decode(bytes);
150
120
  if (!sawFirstLine) {
151
- const newline = buffer.indexOf(0x0a);
152
- if (newline !== -1) {
153
- sawFirstLine = true;
154
- const firstLine = decoder.decode(buffer.subarray(0, newline)).trim();
155
- if (firstLine) {
156
- const slot = parseTitleSlotLine(firstLine);
157
- if (slot) {
158
- titleSlot = titleUpdateFromSlot(slot);
159
- buffer = buffer.subarray(newline + 1);
160
- }
161
- }
121
+ sawFirstLine = true;
122
+ const slot = parseTitleSlotLine(line.trim());
123
+ if (slot) {
124
+ titleSlot = titleUpdateFromSlot(slot);
125
+ continue;
162
126
  }
163
127
  }
164
- drain();
165
- }
166
- // A trailing record without a final newline: terminate it so the parser
167
- // can complete it (readline yielded it; parseChunk needs the delimiter).
168
- if (buffer.length > 0 && buffer[buffer.length - 1] !== 0x0a) {
169
- buffer = Buffer.concat([buffer, new Uint8Array([0x0a])]);
128
+ for (const entry of parseJsonlLenient<RawFileEntry>(line)) entries.push(entry as FileEntry);
170
129
  }
171
- drain();
172
130
  } catch (err) {
173
131
  if (isEnoent(err)) return { entries: [], titleSlot: undefined };
174
132
  throw err;