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 +1 -1
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/runner.ts +108 -18
package/package.json
CHANGED
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:
|
|
75
|
-
//
|
|
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 =
|
|
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>;
|
|
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)
|
|
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)
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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 (
|
|
1161
|
+
if (emitted) this.sessionDebug(`reasoning tail emitted ${emitted} step(s) (turn ${turnId ?? "?"}, ${seen.size} total)`);
|
|
1090
1162
|
};
|
|
1091
|
-
|
|
1092
|
-
this.
|
|
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({
|