agent-relay-runner 0.12.0 → 0.12.2

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,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.12.0",
4
+ "version": "0.12.2",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
package/src/runner.ts CHANGED
@@ -71,10 +71,17 @@ const LOG_TAIL_BYTES = 128 * 1024;
71
71
  // A UserPromptSubmit echo matching a runner-injected prompt within this window is
72
72
  // the same prompt arriving back from the provider — drop it to avoid a duplicate.
73
73
  const PROMPT_ECHO_DEDUP_MS = 30_000;
74
- // Busy reconciler: how often to probe real provider activity, and how many
75
- // consecutive idle probes confirm a stuck-busy state should be cleared.
74
+ // Busy reconciler: a conservative LAST-RESORT backstop for a turn that ended
75
+ // without the provider's Stop hook clearing busy (e.g. ESC straight into the web
76
+ // terminal). It must never fire during a live turn, so it (a) only counts idle
77
+ // after it has actually observed the provider busy, and (b) requires a long,
78
+ // unbroken idle streak — an active turn shows its working spinner well within
79
+ // this window, which resets the streak. ~32s of uninterrupted idle = really done.
76
80
  const BUSY_RECONCILE_POLL_MS = 4_000;
77
- const BUSY_RECONCILE_IDLE_CONFIRM = 3;
81
+ const BUSY_RECONCILE_IDLE_CONFIRM = 8;
82
+ // After a dashboard interrupt, give the provider a moment to drop out of its turn,
83
+ // then reconcile immediately so the user sees "stopped" without waiting for the backstop.
84
+ const INTERRUPT_RECONCILE_DELAY_MS = 1_500;
78
85
  // Relay-injected content (delivered messages, memory context) is wrapped with
79
86
  // these markers; a UserPromptSubmit echo starting with one is a runner injection,
80
87
  // not a human typing into the terminal, so it must not be mirrored as a prompt.
@@ -139,12 +146,18 @@ export class AgentRunner {
139
146
  // Busy reconciler: consecutive idle probes observed while claims still say busy.
140
147
  private busyReconcileIdleStreak = 0;
141
148
  private busyReconcileTimer?: ReturnType<typeof setInterval>;
149
+ // The reconciler only trusts an "idle" reading once it has seen the provider
150
+ // actually busy this turn — so a flaky/always-idle probe can never false-clear.
151
+ private busyReconcileSawBusy = false;
152
+ // Verbose session-mirror diagnostics (turn lifecycle, reconciler probes, tail
153
+ // emits) → a dedicated clean log. Always on for key transitions; AGENT_RELAY_SESSION_DEBUG=1 adds the high-frequency probe/emit lines.
154
+ private readonly sessionDebugVerbose = process.env.AGENT_RELAY_SESSION_DEBUG === "1";
142
155
  // Tracks whether the provider is in a legitimate blocked/approval state, so the
143
156
  // busy reconciler doesn't mistake a permission prompt for a stuck-busy turn.
144
157
  private providerBlocked = false;
145
158
  // Reasoning tailer (item 5): streams the in-flight turn's reasoning/tool steps
146
159
  // from the Claude transcript into chat as discreet session events.
147
- private reasoningTail?: { timer: ReturnType<typeof setInterval>; emitted: number };
160
+ private reasoningTail?: { timer: ReturnType<typeof setInterval>; seen: Set<string> };
148
161
  private scratch?: SessionScratchLayout;
149
162
 
150
163
  constructor(private readonly options: RunnerOptions) {
@@ -487,7 +500,9 @@ export class AgentRunner {
487
500
  providerResult = await this.options.adapter.clearContext(this.process);
488
501
  } else if (type === "agent.interrupt") {
489
502
  if (!this.options.adapter.interrupt || !this.process) throw new Error("provider does not support interrupt");
503
+ this.sessionLog("interrupt requested from dashboard");
490
504
  providerResult = await this.options.adapter.interrupt(this.process);
505
+ this.scheduleInterruptReconcile();
491
506
  } else if (type === "agent.injectContext") {
492
507
  if (!this.process) throw new Error("provider process is unavailable");
493
508
  providerResult = await this.injectContext(params);
@@ -832,9 +847,13 @@ export class AgentRunner {
832
847
  this.providerBlocked = false;
833
848
  }
834
849
  if (status === "busy" && reason === "provider-turn") {
835
- if (!this.currentTurnId) this.currentTurnId = typeof update !== "string" && update.id ? update.id : crypto.randomUUID();
850
+ if (!this.currentTurnId) {
851
+ this.currentTurnId = typeof update !== "string" && update.id ? update.id : crypto.randomUUID();
852
+ this.sessionLog(`turn started (turn ${this.currentTurnId})`);
853
+ }
836
854
  this.armBusyReconciler();
837
855
  } else if (status === "idle" && reason === "provider-turn") {
856
+ if (this.currentTurnId) this.sessionLog(`turn ended via provider idle (turn ${this.currentTurnId})`);
838
857
  this.currentTurnId = undefined;
839
858
  this.disarmBusyReconciler();
840
859
  this.stopReasoningTail();
@@ -916,8 +935,12 @@ export class AgentRunner {
916
935
  }
917
936
  // A pure tool-use turn with no closing text is fine to skip — its reasoning and
918
937
  // tool steps already carried the visibility into chat.
919
- if (!body) return;
938
+ if (!body) {
939
+ this.sessionLog(`response capture: no closing text for turn ${turnId ?? "?"} (skipped)`);
940
+ return;
941
+ }
920
942
 
943
+ this.sessionLog(`response captured for turn ${turnId ?? "?"} (${body.length} chars${replyToMessageId ? `, replyTo #${replyToMessageId}` : ", no replyTo"})`);
921
944
  await this.publishSessionEvent({
922
945
  from: this.agentId,
923
946
  to: "user",
@@ -960,12 +983,15 @@ export class AgentRunner {
960
983
  if (!this.currentTurnId) this.currentTurnId = crypto.randomUUID();
961
984
  const text = input.prompt.trim();
962
985
  if (text && !this.isRunnerInjectedPrompt(text)) {
986
+ this.sessionLog(`prompt echoed from terminal (${text.length} chars)`);
963
987
  await this.publishSessionEvent({
964
988
  from: "user",
965
989
  to: this.agentId,
966
990
  body: text,
967
991
  session: { type: "prompt", origin: "terminal", turnId: this.currentTurnId },
968
992
  });
993
+ } else if (text) {
994
+ this.sessionDebug("user-prompt hook: skipped echo (runner-injected)");
969
995
  }
970
996
  if (input.transcriptPath) this.startReasoningTail(input.transcriptPath);
971
997
  }
@@ -1038,6 +1064,7 @@ export class AgentRunner {
1038
1064
  private armBusyReconciler(): void {
1039
1065
  if (this.busyReconcileTimer || !this.options.adapter.probeActivity) return;
1040
1066
  this.busyReconcileIdleStreak = 0;
1067
+ this.busyReconcileSawBusy = false;
1041
1068
  this.busyReconcileTimer = setInterval(() => { void this.runBusyReconcile(); }, BUSY_RECONCILE_POLL_MS);
1042
1069
  }
1043
1070
 
@@ -1045,6 +1072,7 @@ export class AgentRunner {
1045
1072
  if (this.busyReconcileTimer) clearInterval(this.busyReconcileTimer);
1046
1073
  this.busyReconcileTimer = undefined;
1047
1074
  this.busyReconcileIdleStreak = 0;
1075
+ this.busyReconcileSawBusy = false;
1048
1076
  }
1049
1077
 
1050
1078
  private async runBusyReconcile(): Promise<void> {
@@ -1055,13 +1083,47 @@ export class AgentRunner {
1055
1083
  if (!this.claims.activeWork().some((w) => w.kind === "provider-turn")) { this.disarmBusyReconciler(); return; }
1056
1084
  let activity: "busy" | "idle" | "unknown";
1057
1085
  try { activity = await this.options.adapter.probeActivity(this.process); } catch { return; }
1058
- if (activity !== "idle") { this.busyReconcileIdleStreak = 0; return; }
1086
+ if (activity === "busy") this.busyReconcileSawBusy = true;
1087
+ // Reset the streak on anything that isn't a confident idle — and never start
1088
+ // counting until we've actually observed the provider busy this turn.
1089
+ if (activity !== "idle" || !this.busyReconcileSawBusy) {
1090
+ if (activity !== "idle") this.busyReconcileIdleStreak = 0;
1091
+ this.sessionDebug(`reconcile probe=${activity} sawBusy=${this.busyReconcileSawBusy} streak=${this.busyReconcileIdleStreak}`);
1092
+ return;
1093
+ }
1059
1094
  this.busyReconcileIdleStreak += 1;
1095
+ this.sessionDebug(`reconcile probe=idle streak=${this.busyReconcileIdleStreak}/${BUSY_RECONCILE_IDLE_CONFIRM}`);
1060
1096
  if (this.busyReconcileIdleStreak < BUSY_RECONCILE_IDLE_CONFIRM) return;
1061
- this.logRunnerDiagnostic(`busy reconciler cleared a stuck provider-turn (idle confirmed ${this.busyReconcileIdleStreak}x)`);
1062
- const turnId = this.currentTurnId;
1063
1097
  this.disarmBusyReconciler();
1064
- this.setProviderStatus({ status: "idle", reason: "provider-turn", id: turnId ?? "provider-turn" });
1098
+ this.forceClearProviderTurn("backstop reconciler");
1099
+ }
1100
+
1101
+ // Force-clear a stuck provider-turn claim directly. Unlike the idle status path
1102
+ // it does NOT depend on a matching claim id (the Stop hook keys busy as
1103
+ // provider-turn:provider-turn, but reconciliation has no specific id), and it
1104
+ // deliberately leaves the reasoning tail alone so a late clear can't truncate
1105
+ // a turn's activity stream.
1106
+ private forceClearProviderTurn(reason: string): void {
1107
+ if (!this.claims.activeWork().some((w) => w.kind === "provider-turn")) return;
1108
+ this.sessionLog(`force-clearing stuck provider-turn (${reason})`);
1109
+ this.claims.clearWorkKind("provider-turn");
1110
+ this.currentTurnId = undefined;
1111
+ this.publishStatus();
1112
+ }
1113
+
1114
+ // After a dashboard interrupt, the provider should drop out of its turn; reconcile
1115
+ // promptly so the busy indicator clears even if the Stop hook doesn't fire.
1116
+ private scheduleInterruptReconcile(): void {
1117
+ setTimeout(() => {
1118
+ if (this.stopped || !this.process) return;
1119
+ void (async () => {
1120
+ if (this.claims.currentStatus() !== "busy" || this.providerBlocked) return;
1121
+ let activity: "busy" | "idle" | "unknown" = "unknown";
1122
+ try { if (this.options.adapter.probeActivity) activity = await this.options.adapter.probeActivity(this.process!); } catch { return; }
1123
+ this.sessionDebug(`post-interrupt reconcile probe=${activity}`);
1124
+ if (activity === "idle") this.forceClearProviderTurn("post-interrupt");
1125
+ })();
1126
+ }, INTERRUPT_RECONCILE_DELAY_MS);
1065
1127
  }
1066
1128
 
1067
1129
  // --- Reasoning tailer (item 5) ------------------------------------------------------
@@ -1071,14 +1133,24 @@ export class AgentRunner {
1071
1133
  private startReasoningTail(transcriptPath: string): void {
1072
1134
  if (this.options.providerConfig.reasoningCapture === false) return;
1073
1135
  this.stopReasoningTail();
1074
- const state = { emitted: 0, timer: undefined as unknown as ReturnType<typeof setInterval> };
1136
+ // Track emitted steps by content signature, not by index/count: the "latest
1137
+ // turn" window in the transcript can shrink/reset (a tool_result entry, a
1138
+ // mid-turn user line), and an index cursor would then either re-emit or stall
1139
+ // and drop the rest of the turn. A seen-set is idempotent under any reshuffle.
1140
+ const seen = new Set<string>();
1141
+ const turnIdAtStart = this.currentTurnId;
1075
1142
  const poll = async (): Promise<void> => {
1076
1143
  let jsonl: string;
1077
1144
  try { jsonl = await readFile(transcriptPath, "utf8"); } catch { return; }
1078
- const steps = extractLatestTurnSteps(jsonl);
1079
- const turnId = this.currentTurnId;
1080
- for (let i = state.emitted; i < steps.length; i++) {
1081
- const step = steps[i]!;
1145
+ let steps: ReturnType<typeof extractLatestTurnSteps>;
1146
+ try { steps = extractLatestTurnSteps(jsonl); } catch { return; }
1147
+ const turnId = this.currentTurnId ?? turnIdAtStart;
1148
+ let emitted = 0;
1149
+ for (const step of steps) {
1150
+ const sig = `${step.type}${step.label ?? ""}${step.text}`;
1151
+ if (seen.has(sig)) continue;
1152
+ seen.add(sig);
1153
+ emitted += 1;
1082
1154
  void this.publishSessionEvent({
1083
1155
  from: this.agentId,
1084
1156
  to: "user",
@@ -1086,10 +1158,10 @@ export class AgentRunner {
1086
1158
  session: { type: step.type, origin: "provider", ...(turnId ? { turnId } : {}), ...(step.label ? { label: step.label } : {}) },
1087
1159
  });
1088
1160
  }
1089
- if (steps.length > state.emitted) state.emitted = steps.length;
1161
+ if (emitted) this.sessionDebug(`reasoning tail emitted ${emitted} step(s) (turn ${turnId ?? "?"}, ${seen.size} total)`);
1090
1162
  };
1091
- state.timer = setInterval(() => { void poll(); }, REASONING_POLL_MS);
1092
- this.reasoningTail = state;
1163
+ this.reasoningTail = { seen, timer: setInterval(() => { void poll(); }, REASONING_POLL_MS) };
1164
+ this.sessionLog(`reasoning tail started (turn ${turnIdAtStart ?? "?"})`);
1093
1165
  void poll();
1094
1166
  }
1095
1167
 
@@ -1201,6 +1273,24 @@ export class AgentRunner {
1201
1273
  }
1202
1274
  }
1203
1275
 
1276
+ // Session-mirror diagnostics → a dedicated, ANSI-free, greppable log per agent
1277
+ // (NOT the provider's TUI stdout, which is unreadable). This is the single place
1278
+ // to look when chat/terminal sync misbehaves. Key transitions always log here.
1279
+ private sessionLog(message: string): void {
1280
+ try {
1281
+ const logDir = join(process.env.HOME || ".", ".agent-relay", "logs");
1282
+ mkdirSync(logDir, { recursive: true });
1283
+ appendFileSync(join(logDir, `session-mirror-${safeLogName(this.agentId)}.log`), `[${new Date().toISOString()}] ${message}\n`);
1284
+ } catch {
1285
+ // best-effort
1286
+ }
1287
+ }
1288
+
1289
+ // Verbose, high-frequency lines (per-probe, per-emit) — only when AGENT_RELAY_SESSION_DEBUG=1.
1290
+ private sessionDebug(message: string): void {
1291
+ if (this.sessionDebugVerbose) this.sessionLog(message);
1292
+ }
1293
+
1204
1294
  private ensureScratch(): void {
1205
1295
  try {
1206
1296
  this.scratch = ensureSessionScratch({