dsh-client-auto-continue 0.11.2 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -203,6 +203,46 @@ function fillTemplate(template, ctx) {
203
203
  return template.replace(/\{code\}/g, ctx.facts?.code ?? "").replace(/\{message\}/g, ctx.facts?.message ?? "").replace(/\{status\}/g, ctx.facts?.status !== void 0 ? String(ctx.facts.status) : "").replace(/\{tool\}/g, ctx.tool ?? "").replace(/\{turn\}/g, ctx.turn !== void 0 ? String(ctx.turn) : "").replace(/\{errorCount\}/g, ctx.errorCount !== void 0 ? String(ctx.errorCount) : "").replace(/\{sessionTitle\}/g, ctx.sessionTitle ?? "").replace(/\{elapsed\}/g, formatElapsed(ctx.elapsedMs)).replace(/\{result\}/g, ctx.result ?? "");
204
204
  }
205
205
  var TOOL_RESULT_CAP = 160;
206
+ function stableFingerprint(value) {
207
+ let first = 2166136261;
208
+ let second = 2654435769;
209
+ let length = 0;
210
+ const feed = (text) => {
211
+ length += text.length;
212
+ for (let i = 0; i < text.length; i += 1) {
213
+ const code = text.charCodeAt(i);
214
+ first = Math.imul(first ^ code, 16777619) >>> 0;
215
+ second = Math.imul(second ^ code, 2246822507) >>> 0;
216
+ second = (second ^ second >>> 13) >>> 0;
217
+ }
218
+ };
219
+ const walk = (part) => {
220
+ if (part === null) {
221
+ feed("null");
222
+ } else if (Array.isArray(part)) {
223
+ feed("[");
224
+ for (const item of part) {
225
+ walk(item);
226
+ feed(",");
227
+ }
228
+ feed("]");
229
+ } else if (typeof part === "object") {
230
+ feed("{");
231
+ const record = part;
232
+ for (const key of Object.keys(record).sort()) {
233
+ feed(JSON.stringify(key));
234
+ feed(":");
235
+ walk(record[key]);
236
+ feed(",");
237
+ }
238
+ feed("}");
239
+ } else {
240
+ feed(`${typeof part}:${JSON.stringify(part) ?? String(part)}`);
241
+ }
242
+ };
243
+ walk(value);
244
+ return `${first.toString(16).padStart(8, "0")}${second.toString(16).padStart(8, "0")}:${length}`;
245
+ }
206
246
  function extractText(blocks, cap) {
207
247
  let out = "";
208
248
  const walk = (value) => {
@@ -222,10 +262,262 @@ function extractText(blocks, cap) {
222
262
  walk(blocks);
223
263
  return out.slice(0, cap);
224
264
  }
265
+ function toolCorrelationKey(data, callId) {
266
+ if (callId === void 0 || typeof data.turn !== "number" || typeof data.step !== "number") {
267
+ return void 0;
268
+ }
269
+ return JSON.stringify([data.turn, data.step, callId]);
270
+ }
271
+ function resultBlock(data) {
272
+ return data.message?.content?.find((part) => part.type === "tool-result");
273
+ }
274
+ function toolResultCallId(data) {
275
+ if (resultBlock(data) === void 0) return void 0;
276
+ const sourceId = data.message?.source?.kind === "tool" ? data.message.source.callId : void 0;
277
+ const blockId = resultBlock(data)?.toolCallId;
278
+ const source = typeof sourceId === "string" && sourceId !== "" ? sourceId : void 0;
279
+ const block = typeof blockId === "string" && blockId !== "" ? blockId : void 0;
280
+ if (source !== void 0 && block !== void 0 && source !== block) return void 0;
281
+ return source ?? block;
282
+ }
225
283
  function toolResultFacts(data) {
226
- const failed = data.error !== void 0 || data.message?.content?.[0]?.isError === true;
227
- return { ok: !failed, excerpt: extractText(data.message?.content?.[0]?.content, TOOL_RESULT_CAP) };
284
+ const result = resultBlock(data);
285
+ const failed = data.error !== void 0 || result?.isError === true;
286
+ return {
287
+ ok: !failed,
288
+ excerpt: extractText(result?.content, TOOL_RESULT_CAP),
289
+ identity: stableFingerprint({ content: result?.content ?? [], isError: failed })
290
+ };
228
291
  }
292
+ var MAX_PENDING_TOOL_CALLS = 64;
293
+ var MAX_SEEN_TOOL_CALL_IDS = 256;
294
+ var ToolInvocationTracker = class {
295
+ constructor() {
296
+ this.pendingById = /* @__PURE__ */ new Map();
297
+ this.pendingInOrder = [];
298
+ this.seenCalls = /* @__PURE__ */ new Map();
299
+ this.seenInOrder = [];
300
+ this.lastEventSeq = -1;
301
+ }
302
+ reset() {
303
+ this.pendingById.clear();
304
+ this.pendingInOrder.length = 0;
305
+ this.seenCalls.clear();
306
+ this.seenInOrder.length = 0;
307
+ this.latest = void 0;
308
+ this.run = void 0;
309
+ this.repeatSignal = void 0;
310
+ this.lastEventSeq = -1;
311
+ }
312
+ /** 新回合边界:清空工具态,同时把重放水位推进到 turn/start。 */
313
+ startTurn(seq) {
314
+ if (!Number.isSafeInteger(seq) || seq < 0 || seq <= this.lastEventSeq) return;
315
+ this.reset();
316
+ this.lastEventSeq = seq;
317
+ }
318
+ /** 回合已结束:保留最后一次调用的护栏,丢弃不再可用的 loop 关联态。 */
319
+ resetRepeat() {
320
+ this.pendingById.clear();
321
+ this.pendingInOrder.length = 0;
322
+ this.seenCalls.clear();
323
+ this.seenInOrder.length = 0;
324
+ this.run = void 0;
325
+ this.repeatSignal = void 0;
326
+ }
327
+ recordCall(event) {
328
+ if (!this.acceptEventSeq(event.seq)) return false;
329
+ this.repeatSignal = void 0;
330
+ const data = event.data;
331
+ if (typeof data.name !== "string") {
332
+ this.breakCorrelation();
333
+ return true;
334
+ }
335
+ const key = `${data.name}
336
+ ${typeof data.arguments === "string" ? data.arguments : ""}`;
337
+ const callId = typeof data.callId === "string" && data.callId !== "" ? data.callId : void 0;
338
+ const id = toolCorrelationKey(data, callId);
339
+ if (id === void 0) {
340
+ this.breakCorrelation({
341
+ id: void 0,
342
+ name: data.name,
343
+ key,
344
+ result: void 0,
345
+ resultSeq: void 0
346
+ });
347
+ return true;
348
+ }
349
+ const seen = this.seenCalls.get(id);
350
+ if (seen !== void 0) {
351
+ this.breakCorrelation({
352
+ id: void 0,
353
+ name: data.name,
354
+ key,
355
+ result: void 0,
356
+ resultSeq: void 0
357
+ });
358
+ return true;
359
+ }
360
+ const call = {
361
+ id,
362
+ name: data.name,
363
+ key,
364
+ result: void 0,
365
+ resultSeq: void 0
366
+ };
367
+ this.latest = call;
368
+ this.pendingById.set(id, call);
369
+ this.pendingInOrder.push(call);
370
+ this.seenCalls.set(id, call);
371
+ this.seenInOrder.push(id);
372
+ this.trim(call);
373
+ return true;
374
+ }
375
+ recordResult(event) {
376
+ if (!this.acceptEventSeq(event.seq)) return void 0;
377
+ const data = event.data;
378
+ const id = toolCorrelationKey(data, toolResultCallId(data));
379
+ if (id === void 0) {
380
+ this.breakCorrelation(this.latest);
381
+ return void 0;
382
+ }
383
+ const surfaceOp = event.surfaceOp;
384
+ if (typeof surfaceOp === "object" && surfaceOp !== null) {
385
+ const call2 = this.seenCalls.get(id);
386
+ if (surfaceOp.start !== surfaceOp.end || call2 === void 0 || call2.result === void 0 || call2.resultSeq !== surfaceOp.start) {
387
+ this.breakCorrelation(this.latest);
388
+ return void 0;
389
+ }
390
+ call2.result = toolResultFacts(data);
391
+ call2.resultSeq = event.seq;
392
+ this.breakCorrelation(this.latest);
393
+ return void 0;
394
+ }
395
+ const call = this.pendingById.get(id);
396
+ if (call === void 0) {
397
+ const seen = this.seenCalls.get(id);
398
+ const duplicate = seen?.result;
399
+ const incoming = toolResultFacts(data);
400
+ if (seen !== void 0 && duplicate !== void 0 && seen.resultSeq === event.seq && duplicate.identity === incoming.identity) {
401
+ return void 0;
402
+ }
403
+ if (seen !== void 0 && duplicate !== void 0) {
404
+ seen.result = void 0;
405
+ seen.resultSeq = void 0;
406
+ }
407
+ this.breakCorrelation(this.latest);
408
+ return void 0;
409
+ }
410
+ if (call.result !== void 0) {
411
+ const incoming = toolResultFacts(data);
412
+ if (call.resultSeq === event.seq && call.result.identity === incoming.identity) {
413
+ return void 0;
414
+ }
415
+ call.result = void 0;
416
+ call.resultSeq = void 0;
417
+ this.breakCorrelation(this.latest);
418
+ return void 0;
419
+ }
420
+ call.result = toolResultFacts(data);
421
+ call.resultSeq = event.seq;
422
+ return this.drainCompleted();
423
+ }
424
+ guard() {
425
+ const latest = this.latest;
426
+ if (latest === void 0) return { kind: "none" };
427
+ if (latest.result === void 0) return { kind: "pending", tool: latest.name };
428
+ if (latest.result.ok) {
429
+ return { kind: "done", tool: latest.name, result: latest.result.excerpt };
430
+ }
431
+ return { kind: "failed", tool: latest.name };
432
+ }
433
+ lastTool() {
434
+ return this.latest?.name;
435
+ }
436
+ /** 下一模型 step 是稳定边界;此前 replacement/新调用会先清除候选。 */
437
+ confirmRepeatAtStep(seq) {
438
+ if (!this.acceptEventSeq(seq)) return void 0;
439
+ const signal = this.pendingInOrder.length === 0 ? this.repeatSignal : void 0;
440
+ this.repeatSignal = void 0;
441
+ return signal;
442
+ }
443
+ /** 非工具 surface range replacement(如 compaction summary)同样终止旧工具证据。 */
444
+ recordSurfaceReplacement(seq) {
445
+ if (!this.acceptEventSeq(seq)) return;
446
+ this.breakCorrelation(this.latest);
447
+ }
448
+ restore(events, untilSeq) {
449
+ this.reset();
450
+ for (const event of events) {
451
+ if (event.seq >= untilSeq) continue;
452
+ if (event.type === "turn/start") this.startTurn(event.seq);
453
+ else if (event.type === "step/start") this.confirmRepeatAtStep(event.seq);
454
+ else if (event.type === "tool/call") this.recordCall(event);
455
+ else if (event.type === "tool/result") this.recordResult(event);
456
+ else if ((event.type === "user/message" || event.type === "assistant/message") && typeof event.surfaceOp === "object" && event.surfaceOp !== null) {
457
+ this.recordSurfaceReplacement(event.seq);
458
+ }
459
+ }
460
+ }
461
+ acceptEventSeq(seq) {
462
+ if (!Number.isSafeInteger(seq) || seq < 0) {
463
+ this.breakCorrelation(this.latest);
464
+ return false;
465
+ }
466
+ if (seq <= this.lastEventSeq) return false;
467
+ this.lastEventSeq = seq;
468
+ return true;
469
+ }
470
+ breakCorrelation(latest, preserve) {
471
+ this.pendingById.clear();
472
+ this.pendingInOrder.length = 0;
473
+ this.invalidateRunHistory();
474
+ this.latest = latest;
475
+ if (preserve?.id !== void 0 && preserve.result === void 0 && this.seenCalls.get(preserve.id) === preserve) {
476
+ this.pendingById.set(preserve.id, preserve);
477
+ this.pendingInOrder.push(preserve);
478
+ }
479
+ }
480
+ invalidateRunHistory() {
481
+ this.run = void 0;
482
+ this.repeatSignal = void 0;
483
+ }
484
+ trim(current) {
485
+ while (this.pendingInOrder.length > MAX_PENDING_TOOL_CALLS) {
486
+ this.breakCorrelation(current, current);
487
+ }
488
+ while (this.seenInOrder.length > MAX_SEEN_TOOL_CALL_IDS) {
489
+ const id = this.seenInOrder.shift();
490
+ if (id !== void 0) {
491
+ this.seenCalls.delete(id);
492
+ this.breakCorrelation(current, current);
493
+ }
494
+ }
495
+ }
496
+ drainCompleted() {
497
+ let advanced = false;
498
+ while (this.pendingInOrder[0]?.result !== void 0) {
499
+ const call = this.pendingInOrder.shift();
500
+ if (call === void 0 || call.result === void 0) break;
501
+ advanced = true;
502
+ if (call.id !== void 0) this.pendingById.delete(call.id);
503
+ this.advanceRun(call);
504
+ }
505
+ if (!advanced) return void 0;
506
+ return this.refreshRepeatSignal();
507
+ }
508
+ advanceRun(call) {
509
+ if (call.result === void 0) return;
510
+ if (this.run?.key === call.key && this.run.identity === call.result.identity) {
511
+ this.run.count += 1;
512
+ } else {
513
+ this.run = { key: call.key, tool: call.name, identity: call.result.identity, count: 1 };
514
+ }
515
+ }
516
+ refreshRepeatSignal() {
517
+ this.repeatSignal = this.pendingInOrder.length === 0 && this.run !== void 0 ? { tool: this.run.tool, count: this.run.count } : void 0;
518
+ return this.repeatSignal;
519
+ }
520
+ };
229
521
  function effectiveCooldown(consecutive, base, factor, max) {
230
522
  const multiplier = Math.pow(factor, consecutive);
231
523
  return Math.min(Math.max(base, base * multiplier), Math.max(base, max));
@@ -244,38 +536,53 @@ function emptyDayStats() {
244
536
  }
245
537
  var freshState = () => ({
246
538
  consecutive: 0,
247
- lastAutoAt: 0,
248
539
  lastAttemptAt: 0,
249
- lastSentText: "",
540
+ pendingEchoMessageIds: /* @__PURE__ */ new Map(),
250
541
  pendingTimer: void 0,
251
542
  running: void 0,
252
543
  queued: 0,
253
544
  subagent: false,
254
545
  lastFailure: void 0,
255
546
  lastFailureAt: 0,
256
- lastTool: void 0,
257
- lastToolResult: void 0,
547
+ tools: new ToolInvocationTracker(),
258
548
  lastTurn: void 0,
259
549
  pendingRecoveryAt: 0,
260
550
  shortRun: 0,
261
551
  lastShortAt: 0,
262
552
  lastAssistantText: "",
263
553
  sameTextRun: 0,
264
- toolRun: void 0,
265
554
  loopFired: false,
266
- loopCancelled: false,
267
555
  loopRetryTimer: void 0
268
556
  });
269
557
  var RECOVERY_WINDOW_MS = 10 * 60 * 1e3;
270
558
  var ECHO_WINDOW_MS = 10 * 60 * 1e3;
559
+ var MAX_PENDING_ECHO_MESSAGE_IDS = 64;
560
+ function prunePendingEchoMessageIds(state, now) {
561
+ for (const [messageId, queuedAt] of state.pendingEchoMessageIds) {
562
+ if (now - queuedAt > ECHO_WINDOW_MS) state.pendingEchoMessageIds.delete(messageId);
563
+ }
564
+ }
565
+ function trackPendingEcho(state, messageId) {
566
+ const now = Date.now();
567
+ prunePendingEchoMessageIds(state, now);
568
+ state.pendingEchoMessageIds.set(messageId, now);
569
+ while (state.pendingEchoMessageIds.size > MAX_PENDING_ECHO_MESSAGE_IDS) {
570
+ const oldest = state.pendingEchoMessageIds.keys().next();
571
+ if (oldest.done) break;
572
+ state.pendingEchoMessageIds.delete(oldest.value);
573
+ }
574
+ }
575
+ function forgetPendingEcho(state, messageId) {
576
+ state.pendingEchoMessageIds.delete(messageId);
577
+ }
271
578
  function isOurEcho(state, event) {
272
579
  if (event.type !== "user/message") return false;
273
580
  const message = event.data;
274
581
  if (message.source.kind !== "user") return false;
275
- if (state.lastSentText === "") return false;
276
- if (Date.now() - state.lastAutoAt > ECHO_WINDOW_MS) return false;
277
- const text = message.content.filter((part) => part.type === "text").map((part) => part.text).join("");
278
- return text === state.lastSentText;
582
+ if (state.pendingEchoMessageIds.size === 0) return false;
583
+ const now = Date.now();
584
+ prunePendingEchoMessageIds(state, now);
585
+ return state.pendingEchoMessageIds.delete(message.id);
279
586
  }
280
587
 
281
588
  // src/host/engine.ts
@@ -301,6 +608,39 @@ var NOTICE_COPY = {
301
608
  stoppedBody: (sessionId, count) => `${sessionId}: ${count} consecutive failures; manual intervention required`
302
609
  }
303
610
  };
611
+ var LOOP_GUARD_CANCEL_CAUSE = {
612
+ kind: "hook",
613
+ reason: "dsh-auto-continue:loop-guard"
614
+ };
615
+ function snapshotSessionEvents(session) {
616
+ const compatible = session;
617
+ if (typeof compatible.snapshotEvents === "function") return compatible.snapshotEvents();
618
+ if (compatible.events !== void 0) return compatible.events;
619
+ throw new TypeError("session exposes neither snapshotEvents() nor events");
620
+ }
621
+ function parseFailureFacts(value) {
622
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
623
+ const failure = value;
624
+ const code = typeof failure.code === "string" && failure.code.trim() !== "" ? failure.code : void 0;
625
+ const message = typeof failure.message === "string" && failure.message.trim() !== "" ? failure.message : void 0;
626
+ const status = typeof failure.status === "number" && Number.isFinite(failure.status) ? failure.status : void 0;
627
+ if (code === void 0 && message === void 0 && status === void 0) return void 0;
628
+ return {
629
+ code: code ?? "UNKNOWN",
630
+ message: message ?? code ?? `HTTP ${status}`,
631
+ ...status !== void 0 ? { status } : {}
632
+ };
633
+ }
634
+ function readReasonKind(value) {
635
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
636
+ const kind = value.kind;
637
+ return typeof kind === "string" && kind.trim() !== "" ? kind : void 0;
638
+ }
639
+ function isLoopGuardCancelReason(value) {
640
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
641
+ const cause = value;
642
+ return cause.kind === LOOP_GUARD_CANCEL_CAUSE.kind && cause.reason === LOOP_GUARD_CANCEL_CAUSE.reason;
643
+ }
304
644
  var AutoContinueRunner = class {
305
645
  /**
306
646
  * @param ctx - host plugin context (agents registry, session events, settings).
@@ -316,10 +656,15 @@ var AutoContinueRunner = class {
316
656
  this.noticeListeners = /* @__PURE__ */ new Set();
317
657
  this.stateListeners = /* @__PURE__ */ new Set();
318
658
  this.disposed = false;
319
- this.disposeSessionEvents = ctx.on(
320
- "session/event",
321
- (session, event) => this.onHostEvent(session, event)
322
- );
659
+ this.disposeSessionEvents = ctx.on("session/event", (session, event) => {
660
+ try {
661
+ this.onHostEvent(session, event);
662
+ } catch (error) {
663
+ console.error(
664
+ `[auto-continue] 会话事件处理异常 ${session.id}: ${error instanceof Error ? error.message : String(error)}`
665
+ );
666
+ }
667
+ });
323
668
  const config = this.getConfig();
324
669
  if (config.scanOnBoot) {
325
670
  void this.bootScanLoop();
@@ -404,40 +749,20 @@ var AutoContinueRunner = class {
404
749
  */
405
750
  onHostEvent(session, event) {
406
751
  const sessionId = session.id;
752
+ if ((event.type === "user/message" || event.type === "assistant/message") && typeof event.surfaceOp === "object" && event.surfaceOp !== null) {
753
+ this.state(sessionId).tools.recordSurfaceReplacement(event.seq);
754
+ return;
755
+ }
407
756
  if (event.type === "tool/call") {
408
- const name = event.data.name;
409
- if (typeof name === "string") {
410
- const state = this.state(sessionId);
411
- state.lastTool = name;
412
- state.lastToolResult = "pending";
413
- state.shortRun = 0;
414
- const key = `${name}
415
- ${event.data.arguments}`;
416
- if (state.toolRun?.key === key) {
417
- state.toolRun.waiting = true;
418
- } else {
419
- state.toolRun = { key, count: 1, lastResult: void 0, waiting: false };
420
- }
421
- }
757
+ const state = this.state(sessionId);
758
+ if (state.tools.recordCall(event)) state.shortRun = 0;
422
759
  } else if (event.type === "tool/result") {
423
760
  const state = this.state(sessionId);
424
- if (state.lastToolResult === "pending") {
425
- const facts = toolResultFacts(event.data);
426
- state.lastToolResult = facts;
427
- const run = state.toolRun;
428
- if (run !== void 0 && run.waiting) {
429
- run.waiting = false;
430
- if (run.lastResult !== void 0 && run.lastResult === facts.excerpt) {
431
- run.count += 1;
432
- this.checkLoop(sessionId, state);
433
- } else {
434
- run.lastResult = facts.excerpt;
435
- run.count = 1;
436
- }
437
- } else if (run !== void 0 && !run.waiting) {
438
- run.lastResult = facts.excerpt;
439
- }
440
- }
761
+ state.tools.recordResult(event);
762
+ } else if (event.type === "step/start") {
763
+ const state = this.state(sessionId);
764
+ const repeat = state.tools.confirmRepeatAtStep(event.seq);
765
+ if (repeat !== void 0) this.checkLoop(sessionId, state, repeat);
441
766
  } else if (event.type === "assistant/message") {
442
767
  const state = this.state(sessionId);
443
768
  this.onAssistantMessage(sessionId, state, event);
@@ -474,50 +799,47 @@ ${event.data.arguments}`;
474
799
  this.checkLoop(sessionId, state);
475
800
  }
476
801
  /** 两个循环信号的公共检查; 命中且本回合未打断过则打断。 */
477
- checkLoop(sessionId, state) {
802
+ checkLoop(sessionId, state, toolRepeat) {
478
803
  if (!this.getConfig().loopGuard) return;
479
804
  if (state.loopFired) return;
480
805
  if (!state.running) return;
481
806
  const config = this.getConfig();
482
807
  if (state.sameTextRun >= config.loopRepeatText) {
483
808
  this.log(`检测到空转循环 ${sessionId}: 连续 ${state.sameTextRun} 条相同消息`);
484
- void this.interruptLoop(sessionId, state);
809
+ this.interruptLoop(sessionId, state);
485
810
  } else if (state.shortRun >= config.loopShortCount) {
486
811
  this.log(`检测到空转循环 ${sessionId}: 连续 ${state.shortRun} 条短句且无工具调用`);
487
- void this.interruptLoop(sessionId, state);
488
- } else if (state.toolRun !== void 0 && state.toolRun.count >= config.loopToolRepeat) {
489
- const toolName = state.toolRun.key.split("\n")[0] ?? "?";
490
- this.log(`检测到工具死循环 ${sessionId}: 「${toolName}」连续 ${state.toolRun.count} 次(同参数同结果)`);
491
- void this.interruptLoop(sessionId, state);
812
+ this.interruptLoop(sessionId, state);
813
+ } else if (toolRepeat !== void 0 && toolRepeat.count >= config.loopToolRepeat) {
814
+ this.log(`检测到工具死循环 ${sessionId}: 「${toolRepeat.tool}」连续 ${toolRepeat.count} 次(同参数同结果)`);
815
+ this.interruptLoop(sessionId, state);
492
816
  }
493
817
  }
494
818
  /**
495
819
  * 打断运行中的回合: cancel(带来源标记)+ 进冷却。
496
- * 随后的 turn/end aborted 会因 loopCancelled 走「可恢复中断」路径,
497
- * loopText 重启回合——不会与用户手动停止混淆。
820
+ * 只有随后持久化的 turn/end 精确携带专属 hook cause 时,
821
+ * 才会用 loopText 重启回合——DSH 的 first-cause 语义保证用户 Stop 优先。
498
822
  */
499
- async interruptLoop(sessionId, state) {
823
+ interruptLoop(sessionId, state) {
500
824
  if (state.loopFired) return;
501
825
  if (Date.now() - state.lastAttemptAt < this.cooldownFor(state)) {
502
826
  this.log(`跳过循环打断 ${sessionId}: 处于冷却期`);
503
827
  return;
504
828
  }
505
829
  state.loopFired = true;
506
- state.loopCancelled = true;
507
830
  state.lastAttemptAt = Date.now();
508
- this.bumpStat({ looped: 1 });
509
831
  try {
510
832
  const agent = this.ctx.agents.get(sessionId);
511
833
  if (agent === void 0) {
512
834
  this.log(`打断循环失败 ${sessionId}: 无 live agent`);
513
- state.loopCancelled = false;
835
+ state.loopFired = false;
514
836
  return;
515
837
  }
516
- agent.cancel({ kind: "user" }, { keepInbox: true });
838
+ agent.cancel(LOOP_GUARD_CANCEL_CAUSE, { keepInbox: true });
517
839
  this.log(`已打断循环 ${sessionId}: cancel 已受理`);
518
840
  } catch (error) {
519
841
  this.log(`打断循环失败 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
520
- state.loopCancelled = false;
842
+ state.loopFired = false;
521
843
  }
522
844
  }
523
845
  onSessionEvent(sessionId, event) {
@@ -525,15 +847,12 @@ ${event.data.arguments}`;
525
847
  switch (event.type) {
526
848
  case "turn/start":
527
849
  state.running = true;
528
- state.lastTool = void 0;
529
- state.lastToolResult = void 0;
850
+ state.tools.startTurn(event.seq);
530
851
  state.shortRun = 0;
531
852
  state.lastShortAt = 0;
532
853
  state.lastAssistantText = "";
533
854
  state.sameTextRun = 0;
534
- state.toolRun = void 0;
535
855
  state.loopFired = false;
536
- state.loopCancelled = false;
537
856
  if (state.loopRetryTimer !== void 0) {
538
857
  clearTimeout(state.loopRetryTimer);
539
858
  state.loopRetryTimer = void 0;
@@ -542,22 +861,28 @@ ${event.data.arguments}`;
542
861
  break;
543
862
  case "turn/end": {
544
863
  state.running = false;
864
+ const loopCancelPending = state.loopFired;
865
+ state.loopFired = false;
545
866
  this.cancelPending(sessionId, "收到新的 turn/end");
546
867
  const reason = event.data.reason;
547
- if (reason.kind === "completed") {
868
+ const reasonKind = readReasonKind(reason);
869
+ if (reasonKind === void 0) {
870
+ console.error(`[auto-continue] 忽略畸形 turn/end ${sessionId}: reason 无法解释`);
871
+ break;
872
+ }
873
+ if (reasonKind === "completed") {
548
874
  state.consecutive = 0;
549
875
  state.lastFailure = void 0;
550
876
  this.noteRecovery(sessionId, "completed");
551
- } else if (reason.kind === "aborted") {
552
- if (state.loopCancelled) {
553
- state.loopCancelled = false;
554
- state.loopFired = false;
877
+ } else if (reasonKind === "aborted") {
878
+ if (isLoopGuardCancelReason(reason.reason)) {
879
+ if (loopCancelPending) this.bumpStat({ looped: 1 });
555
880
  state.pendingRecoveryAt = 0;
556
881
  state.shortRun = 0;
557
882
  state.lastShortAt = 0;
558
883
  state.lastAssistantText = "";
559
884
  state.sameTextRun = 0;
560
- state.toolRun = void 0;
885
+ state.tools.resetRepeat();
561
886
  const cooldown = this.cooldownFor(state);
562
887
  const remaining = cooldown - (Date.now() - state.lastAttemptAt);
563
888
  if (remaining > 0) {
@@ -578,22 +903,22 @@ ${event.data.arguments}`;
578
903
  state.consecutive = 0;
579
904
  state.pendingRecoveryAt = 0;
580
905
  }
581
- } else if (reason.kind === "blocked") {
582
- } else if (reason.kind === "interrupted") {
906
+ } else if (reasonKind === "blocked") {
907
+ } else if (reasonKind === "interrupted") {
583
908
  state.consecutive = 0;
584
909
  state.pendingRecoveryAt = 0;
585
- } else if (reason.kind === "error") {
586
- const error = reason.error;
587
- state.lastFailure = {
588
- code: typeof error.code === "string" ? error.code : "UNKNOWN",
589
- message: typeof error.message === "string" ? error.message : String(error),
590
- ...typeof error.status === "number" ? { status: error.status } : {}
591
- };
910
+ } else if (reasonKind === "error") {
911
+ const failure = parseFailureFacts(reason.error);
912
+ if (failure === void 0) {
913
+ console.error(`[auto-continue] 忽略畸形 turn/end ${sessionId}: error details 无法解释`);
914
+ break;
915
+ }
916
+ state.lastFailure = failure;
592
917
  state.lastTurn = event.data.turn;
593
918
  state.lastFailureAt = Date.now();
594
919
  this.noteRecovery(sessionId, "error");
595
920
  this.onTurnFailure(sessionId, "turn/end:error", state.lastFailure);
596
- } else if (reason.kind === "max-tokens") {
921
+ } else if (reasonKind === "max-tokens") {
597
922
  state.lastFailureAt = Date.now();
598
923
  this.noteRecovery(sessionId, "error");
599
924
  this.schedule(sessionId, "turn/end:max-tokens");
@@ -621,6 +946,7 @@ ${event.data.arguments}`;
621
946
  this.bumpStat({ skipped: 1, code: failure.code });
622
947
  if (config.notify) {
623
948
  this.notify(
949
+ sessionId,
624
950
  copy.notContinuedTitle,
625
951
  copy.permanentErrorBody(sessionId, summary),
626
952
  this.notifyOptions(sessionId, config.locale)
@@ -666,11 +992,12 @@ ${event.data.arguments}`;
666
992
  }
667
993
  }
668
994
  /** 通知桥: 产生一条通知事件, SSE 端点推给 browser 侧展示。 */
669
- notify(title, body, options) {
995
+ notify(sessionId, title, body, options) {
670
996
  const notice = {
671
997
  id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
672
998
  title,
673
999
  body,
1000
+ sessionId,
674
1001
  ...options?.actions !== void 0 && options.actions.length > 0 ? { actions: options.actions } : { actions: [] },
675
1002
  at: Date.now()
676
1003
  };
@@ -787,22 +1114,26 @@ ${event.data.arguments}`;
787
1114
  }
788
1115
  state.lastAttemptAt = Date.now();
789
1116
  try {
790
- agent.followup(
791
- createUserMessage({
792
- content: [{ type: "text", text }],
793
- source: { kind: "user" }
794
- })
795
- );
1117
+ const message = createUserMessage({
1118
+ content: [{ type: "text", text }],
1119
+ source: { kind: "user" }
1120
+ });
1121
+ trackPendingEcho(state, message.id);
1122
+ try {
1123
+ agent.followup(message);
1124
+ } catch (error) {
1125
+ forgetPendingEcho(state, message.id);
1126
+ throw error;
1127
+ }
796
1128
  const now = Date.now();
797
1129
  state.consecutive += 1;
798
- state.lastAutoAt = now;
799
- state.lastSentText = text;
800
1130
  state.pendingRecoveryAt = now;
801
1131
  this.bumpStat({ sent: 1, ...state.lastFailure !== void 0 ? { code: state.lastFailure.code } : {} });
802
1132
  this.log(`已自动发送「${text}」到 ${sessionId}(${reason}), 第 ${state.consecutive} 次连续`);
803
1133
  if (config.notify) {
804
1134
  const copy = NOTICE_COPY[config.locale];
805
1135
  this.notify(
1136
+ sessionId,
806
1137
  copy.continuedTitle,
807
1138
  copy.continuedBody(sessionId, text, state.consecutive),
808
1139
  this.notifyOptions(sessionId, config.locale)
@@ -814,6 +1145,7 @@ ${event.data.arguments}`;
814
1145
  if (config.notify) {
815
1146
  const copy = NOTICE_COPY[config.locale];
816
1147
  this.notify(
1148
+ sessionId,
817
1149
  copy.stoppedTitle,
818
1150
  copy.stoppedBody(sessionId, state.consecutive),
819
1151
  this.notifyOptions(sessionId, config.locale)
@@ -834,7 +1166,7 @@ ${event.data.arguments}`;
834
1166
  buildContinueText(config, state, template) {
835
1167
  let text = fillTemplate(template, {
836
1168
  facts: state.lastFailure,
837
- tool: state.lastTool,
1169
+ tool: state.tools.lastTool(),
838
1170
  turn: state.lastTurn,
839
1171
  errorCount: state.consecutive + 1,
840
1172
  elapsedMs: state.lastFailureAt > 0 ? Date.now() - state.lastFailureAt : void 0
@@ -850,12 +1182,7 @@ ${event.data.arguments}`;
850
1182
  }
851
1183
  /** 上一步工具调用的护栏状态(实时路径, 由 mux 帧维护)。 */
852
1184
  currentGuard(state) {
853
- if (state.lastTool === void 0 || state.lastToolResult === void 0) return { kind: "none" };
854
- if (state.lastToolResult === "pending") return { kind: "pending", tool: state.lastTool };
855
- if (state.lastToolResult.ok) {
856
- return { kind: "done", tool: state.lastTool, result: state.lastToolResult.excerpt };
857
- }
858
- return { kind: "failed", tool: state.lastTool };
1185
+ return state.tools.guard();
859
1186
  }
860
1187
  async bootScanLoop() {
861
1188
  await this.scanLoop(Infinity, 3e3);
@@ -888,8 +1215,21 @@ ${event.data.arguments}`;
888
1215
  for (const agent of this.ctx.agents.list()) {
889
1216
  const session = agent.session;
890
1217
  if (session.header.origin === "subagent") continue;
891
- candidates.push({ sessionId: session.id, events: session.events });
1218
+ const events = snapshotSessionEvents(session);
1219
+ const lastActivityAt = events.reduce(
1220
+ (latest, event) => Math.max(latest, event.time),
1221
+ Number.isFinite(session.header.createdAt) ? session.header.createdAt : 0
1222
+ );
1223
+ candidates.push({
1224
+ sessionId: session.id,
1225
+ events,
1226
+ lastActivityAt,
1227
+ listIndex: candidates.length
1228
+ });
892
1229
  }
1230
+ candidates.sort(
1231
+ (left, right) => right.lastActivityAt - left.lastActivityAt || left.listIndex - right.listIndex
1232
+ );
893
1233
  for (const candidate of candidates.slice(0, config.scanLimit)) {
894
1234
  if (this.disposed) return true;
895
1235
  const state = this.state(candidate.sessionId);
@@ -908,7 +1248,8 @@ ${event.data.arguments}`;
908
1248
  }
909
1249
  if (lastEnd === void 0) continue;
910
1250
  const reason = lastEnd.data.reason;
911
- if (!isNonHumanReason(reason.kind)) continue;
1251
+ const reasonKind = readReasonKind(reason);
1252
+ if (reasonKind === void 0 || !isNonHumanReason(reasonKind)) continue;
912
1253
  if (lastEnd.time < now - config.freshMs) continue;
913
1254
  let superseded = false;
914
1255
  for (const event of events) {
@@ -919,30 +1260,27 @@ ${event.data.arguments}`;
919
1260
  }
920
1261
  if (superseded) continue;
921
1262
  this.applyGuardFromEvents(state, events, lastEnd.seq);
922
- this.log(`扫描发现中断 ${candidate.sessionId}(turn/end:${reason.kind}), 安排自动继续`);
923
- this.schedule(candidate.sessionId, `scan:turn/end:${reason.kind}`);
1263
+ const scanReason = `scan:turn/end:${reasonKind}`;
1264
+ this.log(`扫描发现中断 ${candidate.sessionId}(turn/end:${reasonKind}), 交给恢复策略处理`);
1265
+ if (reasonKind === "error") {
1266
+ const failure = parseFailureFacts(reason.error);
1267
+ if (failure === void 0) {
1268
+ console.error(`[auto-continue] 忽略畸形扫描 turn/end ${candidate.sessionId}: error details 无法解释`);
1269
+ continue;
1270
+ }
1271
+ state.lastFailure = failure;
1272
+ state.lastTurn = lastEnd.data.turn;
1273
+ state.lastFailureAt = lastEnd.time;
1274
+ this.onTurnFailure(candidate.sessionId, scanReason, state.lastFailure);
1275
+ } else {
1276
+ this.schedule(candidate.sessionId, scanReason);
1277
+ }
924
1278
  }
925
1279
  return true;
926
1280
  }
927
1281
  /** 从历史事件恢复上一步工具调用状态(扫描路径的幂等护栏)。 */
928
1282
  applyGuardFromEvents(state, events, untilSeq) {
929
- state.lastTool = void 0;
930
- state.lastToolResult = void 0;
931
- let call;
932
- for (const event of events) {
933
- if (event.seq >= untilSeq) continue;
934
- if (event.type === "tool/call") call = event;
935
- }
936
- if (call === void 0) return;
937
- state.lastTool = call.data.name;
938
- state.lastToolResult = "pending";
939
- for (const event of events) {
940
- if (event.seq <= call.seq || event.seq >= untilSeq) continue;
941
- if (event.type === "tool/result") {
942
- state.lastToolResult = toolResultFacts(event.data);
943
- break;
944
- }
945
- }
1283
+ state.tools.restore(events, untilSeq);
946
1284
  }
947
1285
  };
948
1286