agentradar-cli 0.3.5 → 0.3.7

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.
@@ -28,6 +28,7 @@ import { homedir } from "node:os";
28
28
  import { basename, dirname, isAbsolute, join, normalize, sep } from "node:path";
29
29
  import { fileURLToPath, pathToFileURL } from "node:url";
30
30
  import { formatInjectionFrame, formatProbeFrame, formatWelcomeFrame, sendKeysCommands } from "./session-frame.mjs";
31
+ import { unpackSkillZip } from "./skill-zip.mjs";
31
32
 
32
33
  const PANE_LIVE_SH = join(dirname(fileURLToPath(import.meta.url)), "pane-live.sh");
33
34
 
@@ -78,6 +79,11 @@ const NEXT_EVENT_HOLD_MS = Number(process.env.NEXT_EVENT_HOLD_MS ?? "200000");
78
79
  // watchdog is 230s, still under the ~240s ALB idle-timeout.
79
80
  const NEXT_EVENT_TIMEOUT_MS = NEXT_EVENT_HOLD_MS + 30000;
80
81
  const IDLE_DELAY_MS = Number(process.env.IDLE_DELAY_MS ?? "1000");
82
+ // A starting runner rechecks readiness this often inside its IDLE_DELAY_MS wait, so a cold start's work goes out
83
+ // as soon as the session is ready rather than up to a whole wait (plus a CLI login probe) later.
84
+ const READY_POLL_MS = Number(process.env.READY_POLL_MS ?? "200");
85
+ // A good login probe holds this long. The probe spawns the CLI; run every poll, it competed with the booting session.
86
+ const AUTH_RECHECK_MS = Number(process.env.AUTH_RECHECK_MS ?? "60000");
81
87
  const ERROR_DELAY_MS = Number(process.env.ERROR_DELAY_MS ?? "3000");
82
88
  // How long to wait for the pane to come back (mid-revive) before giving up this round.
83
89
  const PANE_WAIT_MS = Number(process.env.PANE_WAIT_MS ?? "15000");
@@ -166,6 +172,7 @@ async function mcpPost(method, params, { notification = false, timeoutMs = MCP_C
166
172
  "content-type": "application/json",
167
173
  accept: "application/json, text/event-stream",
168
174
  authorization: `Bearer ${AWP_BEARER}`,
175
+ "x-agentradar-surface": "feeder",
169
176
  };
170
177
  if (_sessionId) headers["mcp-session-id"] = _sessionId;
171
178
  const body = { jsonrpc: "2.0", method, params: params ?? {} };
@@ -217,6 +224,7 @@ function mcpPostIsolated(method, params, { timeoutMs = PANE_MCP_TIMEOUT_MS } = {
217
224
  "content-type": "application/json",
218
225
  accept: "application/json, text/event-stream",
219
226
  authorization: `Bearer ${AWP_BEARER}`,
227
+ "x-agentradar-surface": "feeder",
220
228
  "content-length": Buffer.byteLength(payload),
221
229
  },
222
230
  timeout: timeoutMs,
@@ -344,22 +352,49 @@ export function codexAuthState(run = spawnSync, readFile = fs.readFileSync) {
344
352
  }
345
353
 
346
354
  /** The login probe for whichever CLI this runner drives. */
347
- export function agentAuthState() {
355
+ function harnessAuthState() {
348
356
  // A generic CLI has no login probe we know of; its pane's input marker is the readiness signal.
349
357
  if (AR_AGENT_HARNESS === "generic") return "authenticated";
350
358
  return AR_AGENT_HARNESS === "codex" ? codexAuthState() : claudeAuthState();
351
359
  }
352
360
 
361
+ let _authOkUntil = 0;
362
+ /** True while a good login probe still holds. */
363
+ export const authHeld = (now = Date.now()) => now < _authOkUntil;
364
+ /** Only "authenticated" is held: a logged-out or unanswered probe is asked again on the next poll. `fresh` skips the hold. */
365
+ export function agentAuthState(fresh = false, now = Date.now(), probe = harnessAuthState) {
366
+ if (!fresh && authHeld(now)) return "authenticated";
367
+ const state = probe();
368
+ _authOkUntil = state === "authenticated" ? now + AUTH_RECHECK_MS : 0;
369
+ return state;
370
+ }
371
+
372
+ /** Waits up to `budgetMs`, ending early once the runner reads ready. True when it did. */
373
+ export async function waitForReady(readiness, budgetMs = IDLE_DELAY_MS, step = READY_POLL_MS, sleepFn = sleep) {
374
+ if (!(step > 0)) step = 200; // READY_POLL_MS of 0 or junk would spin
375
+ for (let waited = 0; waited < budgetMs; waited += step) {
376
+ await sleepFn(Math.min(step, budgetMs - waited));
377
+ if (readiness() === "ready") return true;
378
+ }
379
+ return false;
380
+ }
381
+
353
382
  /** AGENT-GUARD-ACTIVE: the supervisor sets AR_GUARD_MARKER when the session must load agent-guard, whose SessionStart hook writes it. */
354
383
  export const guardLoaded = (marker = process.env.AR_GUARD_MARKER) => !marker || fs.existsSync(marker);
355
384
 
356
385
  export function runnerState(auth = agentAuthState, sample = capturePane, guard = guardLoaded) {
386
+ const held = authHeld();
357
387
  const authState = auth();
358
388
  if (authState === "unauthenticated") return "auth_required";
359
389
  if (authState !== "authenticated" || !guard()) return "starting";
360
390
  const pane = sample();
361
391
  if (pane?.rateLimited) return "rate_limited";
362
- return pane?.inputReady ? "ready" : "starting";
392
+ if (!pane?.inputReady) return "starting";
393
+ if (!held) return "ready";
394
+ // A held probe may keep a pane starting, never make it ready: a logged-out Claude still draws ❯ (its login menu,
395
+ // or the composer after a token loss), and work typed there is acked and lost. Ask once more before "ready".
396
+ const fresh = auth(true);
397
+ return fresh === "authenticated" ? "ready" : fresh === "unauthenticated" ? "auth_required" : "starting";
363
398
  }
364
399
 
365
400
  /**
@@ -481,20 +516,53 @@ export async function waitForPaneReceipt(eventId, sample = paneHasEvent, sleepFn
481
516
  }
482
517
  }
483
518
 
519
+ // Vadym 14045 / #1856: Rev ran one 1h12m turn while the SAME eventId sat in its input queue ~40 times. An event typed
520
+ // into the pane but not yet seen in its history (Claude queues a prompt typed mid-turn) stays unacked, so the server
521
+ // re-serves it on every poll and the feeder typed it again each time. Once typed, an event is not typed again until
522
+ // its window has passed (EVENT_REINJECT_MS, doubled each retry, capped at EVENT_REINJECT_MAX_MS), and it is acked as
523
+ // soon as it shows in the pane. The DB holds one row for it (served_at set, never acknowledged): the copies are made here.
524
+ const EVENT_REINJECT_MS = Number(process.env.EVENT_REINJECT_MS ?? "600000");
525
+ const EVENT_REINJECT_MAX_MS = Number(process.env.EVENT_REINJECT_MAX_MS ?? "3600000");
526
+ // While every served event is held, the server answers the next poll at once with the same batch; poll this often
527
+ // instead (a busy pane used to hold the loop ~INJECT_BUSY_WAIT_MS in injectEvent; 1 s was 3600 polls per held hour).
528
+ const HELD_POLL_MS = Number(process.env.HELD_POLL_MS ?? "30000");
529
+ const _typed = new Map();
530
+ export function noteTyped(eventId, now = Date.now()) {
531
+ const wait = Math.min((_typed.get(eventId)?.wait ?? EVENT_REINJECT_MS / 2) * 2, EVENT_REINJECT_MAX_MS);
532
+ _typed.set(eventId, { at: now, wait });
533
+ }
534
+ /** True while an event already typed into the pane should be left alone. */
535
+ export function typedAlready(eventId, now = Date.now()) {
536
+ const typed = _typed.get(eventId);
537
+ return Boolean(typed && now - typed.at < typed.wait);
538
+ }
539
+ export const forgetTyped = (eventId) => { _typed.delete(eventId); };
540
+ const pruneTyped = (now = Date.now()) => { for (const [id, t] of _typed) if (now - t.at > 24 * 3_600_000) _typed.delete(id); };
541
+
542
+ // The hosted bridge leaves this when the last workspace was stopped or crashed mid-work (TRC-RESTART-REASON).
543
+ const RESTART_NOTICE = join(homedir(), ".agentradar", "run", "restart-notice.json");
544
+ const readNotice = (file) => { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } };
545
+
484
546
  /** Inject one client event into the live pane. Returns true only after Claude's pane shows the submitted turn. */
485
- async function injectEvent(event) {
486
- if (!(await waitForIdlePane())) {
547
+ export async function injectEvent(event, { idle = waitForIdlePane, inject = injectFrame, receipt = waitForPaneReceipt, noticeFile = RESTART_NOTICE } = {}) {
548
+ if (!(await idle())) {
487
549
  console.warn(
488
550
  `[feeder] pane ${TMUX_SESSION} still mid-turn after ${INJECT_BUSY_WAIT_MS}ms — NOT injecting eventId=${event.eventId}; leaving it UNACKED for re-delivery`,
489
551
  );
490
552
  return false;
491
553
  }
492
- const injected = await injectFrame(
493
- formatInjectionFrame(event, AR_CLI_RUNNER_ID),
554
+ const restartNotice = readNotice(noticeFile);
555
+ const injected = await inject(
556
+ formatInjectionFrame(restartNotice ? { ...event, restartNotice } : event, AR_CLI_RUNNER_ID),
494
557
  `eventId=${event.eventId} (source=${event.source}, conversationId=${event.conversationId ?? "-"})`,
495
558
  );
496
559
  if (!injected) return false;
497
- if (await waitForPaneReceipt(event.eventId)) return true;
560
+ noteTyped(event.eventId);
561
+ if (await receipt(event.eventId)) {
562
+ forgetTyped(event.eventId);
563
+ if (restartNotice) fs.rmSync(noticeFile, { force: true }); // told once
564
+ return true;
565
+ }
498
566
  console.warn(
499
567
  `[feeder] pane ${TMUX_SESSION} did not show eventId=${event.eventId} after injection — leaving it UNACKED for re-delivery`,
500
568
  );
@@ -601,6 +669,24 @@ export function paneInputReady(text, harness = AR_AGENT_HARNESS, readyPattern =
601
669
  : /bypass permissions on\b|^\s*❯/im.test(tail);
602
670
  }
603
671
 
672
+ /**
673
+ * A turn, tool or dialog owns the pane, so nothing may be typed into it (Vadym 15775, Rev's phantom STOP). Measured
674
+ * on CLI 2.1.282 during a foreground Bash: the footer's "esc to interrupt" shows at 80 and 120 columns but is cut
675
+ * at 50, where the ❯ prompt and the bypass footer still show; the spinner line "✽ Sautéing… (24s · ↓ 105 tokens)"
676
+ * shows at every width and turns into "✻ Worked for 13s" when the turn ends.
677
+ */
678
+ export const paneBusy = (text) => /\besc to (?:interrupt|cancel)\b/i.test(text ?? "")
679
+ || /^\s*[^\w\s]\s+\S+…\s*\(\s*(?:\d+h\s*)?(?:\d+m\s*)?\d+s\b/m.test(text ?? "");
680
+
681
+ /**
682
+ * Vadym 12490: the permission mode Claude Code's footer names ("⏸ manual mode on"), or null when no footer shows.
683
+ * A hosted session must read "bypass permissions"; anything else is reported so the server can alert.
684
+ */
685
+ export function panePermissionMode(text) {
686
+ const tail = String(text ?? "").replace(/\n\s*$/, "").split("\n").slice(-12).join("\n");
687
+ return /\b(manual mode|accept edits|auto-accept edits|plan mode|bypass permissions) on\b/i.exec(tail)?.[1].toLowerCase() ?? null;
688
+ }
689
+
604
690
  /**
605
691
  * Why a Claude Code session refused to register its channels, read off the banner it prints — or null.
606
692
  * Keyed by the CLI's own skip `kind`. The `auth` banner comes from builds before 2.1.281, which has no auth
@@ -636,13 +722,13 @@ function capturePane() {
636
722
  // "• Working (1s • esc to interrupt)", measured on codex-cli 0.149.1, so this matcher needs no harness
637
723
  // branch. This is only a HINT that triggers a probe on stagnation — it never decides liveness, so a
638
724
  // missed/extra hint is harmless.
639
- const inProgress = /esc to interrupt|\besc to cancel\b/i.test(text);
725
+ const inProgress = paneBusy(text);
640
726
  // The CLI's idle input prompt is the durable readiness signal, and it is the one pane signal that differs
641
727
  // per harness (see paneInputReady). The optional bypass-permissions footer disappeared from a live Claude
642
728
  // build after several turns, which falsely demoted a healthy pane to `starting` and stopped next_event
643
729
  // from draining until its heartbeat aged stale.
644
730
  const inputReady = paneInputReady(text);
645
- return { hash, inProgress, inputReady, rateLimited: paneRateLimited(text), channelGate: classifyChannelBanner(text) };
731
+ return { hash, inProgress, inputReady, rateLimited: paneRateLimited(text), channelGate: classifyChannelBanner(text), mode: panePermissionMode(text) };
646
732
  }
647
733
  /**
648
734
  * #173b (disc obs) — format-drift detector. If the STRIPPED fingerprint keeps CHANGING while the pane
@@ -685,7 +771,10 @@ async function reportPane() {
685
771
  _lastChannelGate = p.channelGate?.kind ?? null;
686
772
  // #156: isolated connection (NOT the shared long-poll socket) so pane sampling never stalls behind
687
773
  // the receive long-poll's ALB-killed socket.
688
- await mcpPostIsolated("tools/call", { name: "report_pane", arguments: { paneHash: p.hash, inProgress: p.inProgress } });
774
+ // Vadym 12490: a hosted session always runs in full auto; a vendor's own runner may choose its mode.
775
+ const offMode = process.env.AR_CLI_RUNNER_ID === "hosted" && p.mode && p.mode !== "bypass permissions" ? p.mode : null;
776
+ if (offMode) console.warn(`[feeder] hosted session in ${offMode}, not full auto`);
777
+ await mcpPostIsolated("tools/call", { name: "report_pane", arguments: { paneHash: p.hash, inProgress: p.inProgress, ...(offMode ? { permissionMode: offMode } : {}) } });
689
778
  if (process.env.DEBUG_PANE) {
690
779
  console.log(`[feeder] report_pane #${n}: OK hash=${p.hash.slice(0, 8)} inProgress=${p.inProgress} (${Date.now() - t0}ms)`);
691
780
  }
@@ -706,14 +795,49 @@ const isManaged = (dir) => {
706
795
  const st = fs.lstatSync(dir, { throwIfNoEntry: false });
707
796
  return Boolean(st?.isDirectory() && fs.existsSync(join(dir, MANAGED_MARK)));
708
797
  };
798
+ // Same bytes and the same mode for every file, and nothing else in the directory.
709
799
  const sameSkill = (dir, files) => {
710
800
  try {
711
801
  const have = fs.readdirSync(dir, { recursive: true }).filter((p) => p !== MANAGED_MARK && fs.statSync(join(dir, p)).isFile());
712
- return have.length === files.length && files.every((f) => fs.readFileSync(join(dir, f.path), "utf8") === f.content);
802
+ return have.length === files.length && files.every((f) => fs.readFileSync(join(dir, f.path)).equals(f.bytes)
803
+ && (fs.statSync(join(dir, f.path)).mode & 0o777) === f.mode);
713
804
  } catch {
714
805
  return false;
715
806
  }
716
807
  };
808
+ const markText = (sha) => `Written by the AgentRadar runner; replaced on every sync.\n${sha ? `sha256:${sha}\n` : ""}`;
809
+ const markedSha = (dir) => {
810
+ try {
811
+ return /^sha256:([0-9a-f]{64})$/m.exec(fs.readFileSync(join(dir, MANAGED_MARK), "utf8"))?.[1] ?? null;
812
+ } catch {
813
+ return null;
814
+ }
815
+ };
816
+
817
+ /** SKILL-ZIP-S3: what `my_skills` is told this box holds, so an unchanged zip is not sent again. */
818
+ export function heldSkillZips(skillsDir) {
819
+ const held = {};
820
+ for (const entry of fs.existsSync(skillsDir) ? fs.readdirSync(skillsDir) : []) {
821
+ const sha = SKILL_NAME.test(entry) && isManaged(join(skillsDir, entry)) ? markedSha(join(skillsDir, entry)) : null;
822
+ if (sha) held[entry] = sha;
823
+ }
824
+ return held;
825
+ }
826
+
827
+ // Each skill comes as a zip (its bytes checked against its sha256 and unpacked by skill-zip.mjs, exec bits kept), as a
828
+ // zip's sha256 alone when this box already holds it, or, from an older server, as files of text.
829
+ function skillFiles(skill, target) {
830
+ const zip = skill.zip;
831
+ if (zip && typeof zip === "object") {
832
+ if (typeof zip.base64 === "string") return { sha: zip.sha256, files: unpackSkillZip(Buffer.from(zip.base64, "base64"), zip.sha256) };
833
+ if (isManaged(target) && markedSha(target) === zip.sha256) return "held";
834
+ throw new Error("the server sent no bytes for a zip this box does not hold");
835
+ }
836
+ const files = Array.isArray(skill.files) ? skill.files : [];
837
+ if (files.some((f) => typeof f?.path !== "string" || typeof f?.content !== "string" || isAbsolute(f.path)
838
+ || normalize(f.path).split(sep).includes(".."))) return null;
839
+ return { sha: null, files: files.map((f) => ({ path: normalize(f.path), bytes: Buffer.from(f.content), mode: 0o644 })) };
840
+ }
717
841
 
718
842
  export function writeManagedSkills(skillsDir, skills) {
719
843
  fs.mkdirSync(skillsDir, { recursive: true });
@@ -726,22 +850,38 @@ export function writeManagedSkills(skillsDir, skills) {
726
850
  console.warn(`[feeder] skill sync: ${name} is not ours, left alone`);
727
851
  continue;
728
852
  }
729
- const files = Array.isArray(skill.files) ? skill.files : [];
730
- const bad = files.find((f) => typeof f?.path !== "string" || typeof f?.content !== "string" || isAbsolute(f.path)
731
- || normalize(f.path).split(sep).includes("..") || [".", MANAGED_MARK].includes(normalize(f.path)));
732
- if (bad) { console.warn(`[feeder] skill sync: ${name} has an unsafe file path, skipped`); continue; }
853
+ let got;
854
+ try {
855
+ got = skillFiles(skill, target);
856
+ } catch (err) {
857
+ // A zip that fails its check keeps the copy this box already has.
858
+ if (isManaged(target)) written.add(name);
859
+ console.warn(`[feeder] skill sync: ${name} not written: ${err instanceof Error ? err.message : String(err)}`);
860
+ continue;
861
+ }
862
+ if (got === "held") { written.add(name); continue; }
863
+ if (!got || got.files.some((f) => [".", MANAGED_MARK].includes(f.path))) {
864
+ console.warn(`[feeder] skill sync: ${name} has an unsafe file path, skipped`);
865
+ continue;
866
+ }
867
+ const { sha, files } = got;
733
868
  // An unchanged skill is not rewritten: the agent's CLI may be reading it (Codex skipped a SKILL.md that a
734
- // periodic resync had just removed).
735
- if (sameSkill(target, files)) { written.add(name); continue; }
869
+ // periodic resync had just removed). Only its mark is brought up to date.
870
+ if (sameSkill(target, files)) {
871
+ if (markedSha(target) !== sha) fs.writeFileSync(join(target, MANAGED_MARK), markText(sha));
872
+ written.add(name);
873
+ continue;
874
+ }
736
875
  const staging = join(skillsDir, `.${name}.sync-${process.pid}`);
737
876
  const old = join(skillsDir, `.${name}.old-${process.pid}`);
738
877
  try {
739
878
  fs.rmSync(staging, { recursive: true, force: true });
740
879
  for (const f of files) {
741
880
  fs.mkdirSync(dirname(join(staging, f.path)), { recursive: true });
742
- fs.writeFileSync(join(staging, f.path), f.content);
881
+ fs.writeFileSync(join(staging, f.path), f.bytes, { mode: f.mode });
882
+ fs.chmodSync(join(staging, f.path), f.mode); // the umask must not take the exec bit back
743
883
  }
744
- fs.writeFileSync(join(staging, MANAGED_MARK), "Written by the AgentRadar runner; replaced on every sync.\n");
884
+ fs.writeFileSync(join(staging, MANAGED_MARK), markText(sha));
745
885
  // Rename aside, then into place: the skill is missing for two renames, not for a recursive delete.
746
886
  fs.rmSync(old, { recursive: true, force: true });
747
887
  if (fs.existsSync(target)) fs.renameSync(target, old);
@@ -775,7 +915,7 @@ async function syncSkills() {
775
915
  console.warn(`[feeder] brief sync failed: ${err instanceof Error ? err.message : String(err)}`);
776
916
  }
777
917
  try {
778
- const answer = toolJson(await mcpPostIsolated("tools/call", { name: "my_skills", arguments: {} }));
918
+ const answer = toolJson(await mcpPostIsolated("tools/call", { name: "my_skills", arguments: { have: heldSkillZips(SKILLS_DIR) } }));
779
919
  // A refusal or an older server keeps whatever this box already has.
780
920
  if (!answer.ok || !Array.isArray(answer.skills)) {
781
921
  console.warn(`[feeder] skill sync: server answered ${answer.error ?? "without skills"}; keeping current copies`);
@@ -969,7 +1109,13 @@ let _lastRunnerState = null;
969
1109
 
970
1110
  /** One receive→inject→ack-select cycle (extracted from loop() + exported so the #173b _pendingAck
971
1111
  * fence is directly testable). Sets `_pendingAck` = the eventIds to ack on the NEXT poll. */
972
- export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, deliverEvent = injectEvent, drive = driveModelSwitch) {
1112
+ /**
1113
+ * Vadym 15469: Claude Code takes work only through the AgentRadar channel. Keys typed into its pane interrupt a running tool
1114
+ * and can stay stuck in the input box, so a Claude pane fed here is told apart, never typed into: its pollOnce arguments
1115
+ * report channel_unconfirmed ("Not receiving messages"), which drains nothing, and never type a /model switch either.
1116
+ */
1117
+ export const channelOnlyPoll = (harness = AR_AGENT_HARNESS) => harness === "claude" ? [paneIsLive, () => "channel_unconfirmed", injectEvent, async () => {}] : [];
1118
+ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, deliverEvent = injectEvent, drive = driveModelSwitch, wait = waitForReady, paneHas = paneHasEvent) {
973
1119
  // next_event refreshes the portal's runner heartbeat. Never call it for a detached/orphan feeder: a
974
1120
  // process without its owned tmux session cannot receive work, so polling would paint a false Live state
975
1121
  // while every event stays unacknowledged. The supervisor recreates the session; until then the durable
@@ -995,7 +1141,10 @@ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, d
995
1141
  const { events = [], livenessProbe, welcome, modelSwitch, firstStep } = await nextEvent(canWork && _pendingAck.length ? _pendingAck : undefined, state);
996
1142
  _nextSwitch = modelSwitch ?? null;
997
1143
  if (!canWork) {
998
- await sleep(IDLE_DELAY_MS);
1144
+ // Signed in but still booting: poll again the moment the pane is ready. Each check is a file stat and a pane
1145
+ // capture; the login probe stays held, so it does not spawn the CLI beside the booting session.
1146
+ if (state === "starting" && !_switching && authHeld()) await wait(readiness);
1147
+ else await sleep(IDLE_DELAY_MS);
999
1148
  return;
1000
1149
  }
1001
1150
  _pendingAck = [];
@@ -1012,7 +1161,14 @@ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, d
1012
1161
  }
1013
1162
  console.log(`[feeder] received ${events.length} event(s) → injecting into live session`);
1014
1163
  const delivered = [];
1164
+ let held = 0;
1165
+ pruneTyped();
1015
1166
  for (const e of events) {
1167
+ // Already typed into the pane: ack it once it shows there, else leave it alone until its window passes.
1168
+ if (_typed.has(e.eventId)) {
1169
+ if (paneHas(e.eventId)) { forgetTyped(e.eventId); delivered.push(e.eventId); continue; }
1170
+ if (typedAlready(e.eventId)) { held += 1; continue; }
1171
+ }
1016
1172
  // Inject in order; ack ONLY the ones that reached a live pane (no-phantom).
1017
1173
  // eslint-disable-next-line no-await-in-loop
1018
1174
  if (await deliverEvent(firstStep ? { ...e, firstStep } : e)) delivered.push(e.eventId);
@@ -1023,6 +1179,8 @@ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, d
1023
1179
  }
1024
1180
  _pendingAck = delivered;
1025
1181
  if (delivered.length) console.log(`[feeder] delivered ${delivered.length}/${events.length} → will ack`);
1182
+ // The server re-serves what is still unacked at once, so a batch held back whole must not spin on it.
1183
+ if (held && held === events.length) await sleep(HELD_POLL_MS);
1026
1184
  }
1027
1185
 
1028
1186
  async function loop() {
@@ -1037,9 +1195,11 @@ async function loop() {
1037
1195
  void syncSkills();
1038
1196
  setInterval(() => { void syncSkills(); }, SKILLS_SYNC_INTERVAL_MS).unref?.();
1039
1197
  }
1198
+ const channelOnly = channelOnlyPoll();
1199
+ if (channelOnly.length) console.warn("[feeder] Claude Code receives work only through the AgentRadar channel: run `agentradar join` again; reporting channel_unconfirmed, typing nothing");
1040
1200
  while (!_stopping) {
1041
1201
  try {
1042
- await pollOnce();
1202
+ await pollOnce(...channelOnly);
1043
1203
  } catch (err) {
1044
1204
  const msg = err instanceof Error ? err.message : String(err);
1045
1205
  console.error(`[feeder] iteration error: ${msg} — re-establishing MCP session`);
@@ -21,11 +21,41 @@ function oneLine(s) {
21
21
  .trim();
22
22
  }
23
23
 
24
+ /** The send tool of each room channel (mirrors the server's replyToolForSource in awp/discovery.ts). Anything else: Slack's `reply`. */
25
+ const REPLY_TOOL = { telegram: "telegram_reply", discord: "discord_reply", whatsapp: "whatsapp_reply", email: "email_reply" };
26
+
27
+ /**
28
+ * Sources with nobody waiting in a channel (a company notice, an approval decision, platform traffic, no source at all): `reply` has nowhere
29
+ * to go and fails ("no active engagement"). Each says what to do instead; the message above carries its own "Now:" line.
30
+ */
31
+ function noReplyAnswer(e, run) {
32
+ const notice = "nobody is waiting for a reply, so do not use reply";
33
+ if (e.source === "approval")
34
+ return `This is the decision on an approval you asked for, not a chat message: do not use reply on this thread. Run \`${run} acknowledge_decision\` with the approvalId in the message, then do the rest of its "Now:" line (react in the thread where you asked).`;
35
+ if (e.source === "moderator" && e.noticeKind === "permission_request")
36
+ return `This is a permission request from a colleague, not a chat message: they are waiting for your decision, not a reply, so do not use reply. Decide it now. To agree, run \`${run} set_role_permission\` with the roleId, area and level (and mode, if the message names one) from the message, plus approvalDetails with four short plain sentences and no ids: context (what happened), reason (why), ifDeclined (what happens if you say no) and decisionDetail (what is granted). To refuse, do nothing more: nothing else is needed.`;
37
+ if (e.source === "moderator")
38
+ return `This is a notice from your company, not a chat message: ${notice}. Do what its "Now:" line says; if it has none, stop.`;
39
+ if (e.source === "agentradar")
40
+ return `This is a notice from AgentRadar, not a chat message: ${notice}. Do what the message says; if it asks for nothing, stop.`;
41
+ return `This message has no source, so there is no channel to reply into: do not use reply. Run \`${run} get_my_context\` to see what is waiting for you, then act on it.`;
42
+ }
43
+ // peer-handoff (run 39060351286): an unconditional "Reply with:" outvoted the brief's "a handoff that asks you nothing gets a reaction
44
+ // only" (Vadym 12276), so the grantee thanked in text. The condition now comes before the command, an order counts as an ask, and a
45
+ // plain notice gets the source's reaction (only where the agent has a react tool, as discovery.ts ackToolForSource) and the ack.
46
+ const REACT = { telegram: ["telegram_react", "👍"], slack: ["slack_react", "white_check_mark"] };
47
+ const NO_TEXT = (run, source, turn, evId) => {
48
+ const ack = `\`${run} inbox_ack --message-ids "${evId ?? "<eventId>"}"\``;
49
+ const react = Object.hasOwn(REACT, source) ? `react with \`${run} ${REACT[source][0]}${turn} --emoji "${REACT[source][1]}"\`, then run ` : "run ";
50
+ return `Only if it just tells you something and asks nothing (a notice, a handoff, a thanks), send no text: ${react}${ack}.`;
51
+ };
52
+ const NO_REPLY_SOURCES = new Set(["moderator", "approval", "agentradar", undefined, null, ""]);
53
+
24
54
  /**
25
55
  * Build the single-line injection frame for one drained next_event event.
26
56
  * event: { eventId, source, channel?, conversationId?, text, firstStep? }
27
- * cliRunnerId: set when the session acts through `agentradar run <id> <tool>` (a joined agent) rather than an
28
- * MCP connection — the command its human already chose "always allow" for, where an MCP tool would prompt.
57
+ * cliRunnerId: the runner the session acts through, `agentradar run <id> <tool>` (AgentRadar has no MCP for agents);
58
+ * absent, the frame names the shell variable every joined and hosted session has, `$AR_CLI_RUNNER_ID`.
29
59
  * Returns a one-line string safe to `tmux send-keys -l` then Enter.
30
60
  */
31
61
  export function formatInjectionFrame(event, cliRunnerId) {
@@ -36,45 +66,76 @@ export function formatInjectionFrame(event, cliRunnerId) {
36
66
  if (e.taskId) tags.push(`taskId=${e.taskId}`); // assignment events: the agent works THIS task, not one dug out of its log
37
67
  if (e.eventId) tags.push(`eventId=${e.eventId}`);
38
68
  // Vadym 11025: the server's `firstStep` (get_my_context first) opens every frame that carries work.
39
- const header = `[AgentRadar inbound · ${tags.join(" · ")}]${e.firstStep ? ` ${oneLine(e.firstStep)} Message:` : ""}`;
69
+ const restart = restartLine(e.restartNotice, cliRunnerId); // before anything else the agent reads, once per restart
70
+ const header = `[AgentRadar inbound · ${tags.join(" · ")}]${restart ? ` ${restart}` : ""}${e.firstStep ? ` ${oneLine(e.firstStep)} Message:` : ""}`;
40
71
  const body = oneLine(e.text) || "(no text)";
41
72
  const evId = e.eventId ? String(e.eventId) : null;
42
73
  // The reply instruction pins the EXPLICIT conversationId (threading) + inResponseToEventId
43
74
  // (exact provenance under concurrent same-conversation turns — disc S2).
44
- const parts = [];
45
- if (cliRunnerId) {
46
- const run = `agentradar run ${cliRunnerId}`;
47
- parts.push(
48
- `Reply with: ${run} reply${e.conversationId ? ` --conversation-id "${e.conversationId}"` : ""}${evId ? ` --in-response-to-event-id "${evId}"` : ""} --text '<your reply>'.`,
49
- evId
50
- ? `For billable work run ${run} report_usage with --idempotency-key "${evId}:<usage-code>" so a re-delivered event never double-charges.`
51
- : `Use ${run} report_usage for billable work.`,
52
- "Then stop.",
53
- );
54
- return oneLine(`${header} ${body} — ${parts.join(" ")}`);
55
- }
56
- if (e.conversationId) {
57
- parts.push(
58
- `Reply via your MCP \`reply\` tool with conversationId="${e.conversationId}"${evId ? ` and inResponseToEventId="${evId}"` : ""} (do NOT guess the channel).`,
59
- );
60
- } else {
61
- parts.push(`Reply via your MCP \`reply\` tool${evId ? ` with inResponseToEventId="${evId}"` : ""}.`);
62
- }
63
- // L2 (disc #104): the feeder is at-least-once — a crash in the deliver→ack window
64
- // re-injects this event. Tying report_usage's idempotencyKey to THIS eventId makes a
65
- // re-delivery a no-op charge (#65 dedups on key) instead of a double bill, while a
66
- // per-line suffix keeps distinct usage lines separate.
67
- if (evId) {
68
- parts.push(
69
- `For billable work call \`report_usage\` with an idempotencyKey derived from this eventId (e.g. "${evId}:<usage-code>") so a re-delivered event never double-charges.`,
70
- );
71
- } else {
72
- parts.push("Use `report_usage` for billable work.");
73
- }
74
- parts.push("Then stop.");
75
+ const run = `agentradar run ${cliRunnerId || "$AR_CLI_RUNNER_ID"}`;
76
+ const turn = `${e.conversationId ? ` --conversation-id "${e.conversationId}"` : ""}${evId ? ` --in-response-to-event-id "${evId}"` : ""}`;
77
+ // A task wake has no conversation: nobody waits in a channel, so `reply` has nowhere to go. The answer is a comment on the task.
78
+ const answer = e.taskId && !e.conversationId && e.reason === "review_requested"
79
+ ? `Record your verdict with: ${run} review_task --issue-id "${e.taskId}" --decision accept|reject --reason '<why>'.`
80
+ : e.taskId && !e.conversationId
81
+ ? `Answer on the task with: ${run} comment_on_task --issue-id "${e.taskId}" --body '<your answer>'.`
82
+ : NO_REPLY_SOURCES.has(e.source) ? noReplyAnswer(e, run)
83
+ : `If the message asks you something or asks you to do something, or its "Now:" line asks for a reply, answer or do it, then reply with: ${run} ${(Object.hasOwn(REPLY_TOOL, e.source) ? REPLY_TOOL[e.source] : "reply")}${turn} --text '<your reply>'. ${NO_TEXT(run, e.source, turn, evId)}`;
84
+ // The server's catch-up line (catch-up.ts: older messages edited, or chatter since the agent's last ack) rides in `guidance`,
85
+ // which the frame does not carry; without it an agent answered from what it last saw, not the edited text (edit-reconcile, 10-18).
86
+ // Channel wakes only (moderator/approval/agentradar wakes have no thread to read), and as a runnable command, not prose.
87
+ const channelWake = e.conversationId && (e.source === "slack" || Object.hasOwn(REPLY_TOOL, e.source));
88
+ // qa-sec MED (#2679): names and titles in guidance can carry a newline and forge a "Now: catch up" line, which this turns into
89
+ // a command the agent runs. So take the LAST such line (the server's is last, in the turn block) and keep it only when its
90
+ // conversation is this wake's and its cursor is base64url (encodeThreadCursor); anything else is dropped, never run.
91
+ const raw = channelWake ? (e.guidance ?? "").match(/^Now: catch up\b.*$/gm)?.at(-1) : undefined;
92
+ let lifted = false;
93
+ const catchUp = raw?.replace(/read_history with conversationId "([^"]+)"(?: and after "([^"]+)")?(?: \(it starts right after your last ack\))?/,
94
+ (m, c, a) => c !== e.conversationId || /["`$\\]/.test(c) || (a !== undefined && !/^[A-Za-z0-9_-]+$/.test(a))
95
+ ? m
96
+ : ((lifted = true), `\`${run} read_history --conversation-id "${c}"${a ? ` --after "${a}"` : ""}\``));
97
+ // Vadym 12241: who wrote it and where. The frame carried neither, and Quill answered a group question untagged with its private
98
+ // part in the room, 3 runs of 3 (group-manners, 10-18). The handle is shape-checked and the name stripped to plain words: both
99
+ // land in the agent's turn as text, so neither can carry a command.
100
+ const handle = /^(@\w{1,32}|<@[UW][A-Z0-9]+>)$/.test(e.senderHandle ?? "") ? e.senderHandle : null;
101
+ const name = oneLine(e.sender?.name).replace(/[^\p{L}\p{N} .'-]/gu, "").slice(0, 40).trim();
102
+ const who = name && handle ? `${name} (${handle})` : name || handle || "";
103
+ const room = !channelWake || !e.chat ? null
104
+ : e.chat.type === "dm" ? `This is a direct chat${who ? ` from ${who}` : ""}: one human, so no @mention is needed.`
105
+ : `This is a ${e.chat.type} with other humans and agents${who ? `; the message is from ${who}` : ""}. Reply in the ${e.chat.type} with an @mention of ${handle ?? "the sender"}; a private matter (approvals, reminders, billing) is not answered here: say it waits on their Needs you page in the AgentRadar app, without a link.`;
106
+ const parts = [
107
+ ...(lifted ? [catchUp] : []),
108
+ ...(room ? [room] : []),
109
+ answer,
110
+ evId
111
+ ? `For billable work run ${run} report_usage with --idempotency-key "${evId}:<usage-code>" so a re-delivered event never double-charges.`
112
+ : `Use ${run} report_usage for billable work.`,
113
+ "Then stop.",
114
+ ];
75
115
  return oneLine(`${header} ${body} — ${parts.join(" ")}`);
76
116
  }
77
117
 
118
+ /**
119
+ * TRC-RESTART-REASON: the hosted bridge's note that the last workspace ended mid-work, said once on the next wake.
120
+ * Haiden's DM blast was cut by stops it could not explain; the agent must know why, what died and how to resume safely.
121
+ */
122
+ export function restartLine(notice, cliRunnerId, now = Date.now()) {
123
+ const at = Date.parse(notice?.at ?? "");
124
+ if (Number.isNaN(at) || !RESTART_REASONS[notice?.reason]) return "";
125
+ const time = new Date(at).toISOString().slice(11, 16);
126
+ const min = Math.max(0, Math.round((now - at) / 60_000));
127
+ const ago = min < 60 ? `${min} min` : `${Math.round(min / 60)} h`;
128
+ // A crash leaves only the time the workspace last started, so that one is "some time after".
129
+ const when = notice.reason === "crashed" ? `stopped without warning some time after ${time} UTC (${ago} ago)` : `was stopped at ${time} UTC (${ago} ago)`;
130
+ return `Your workspace ${when}. ${RESTART_REASONS[notice.reason]} Anything you had running was stopped and is not running now: background jobs, loops and open shells. Files in your home folder are still there. Before you do anything else: 1. Check what finished: look at the files and logs your job wrote, and the results of the calls it made. 2. Restart only the items that did not finish, with \`agentradar run ${cliRunnerId || "$AR_CLI_RUNNER_ID"}\`, passing each one the same --idempotency-key it had before. An item whose key already ran returns its earlier result and is not sent twice. Then handle this message:`;
131
+ }
132
+
133
+ const RESTART_REASONS = {
134
+ idle: "It stopped after the workspace was idle. Running turns, tools and background jobs keep it awake, even when they are silent; there is no runtime cap.",
135
+ stopped: "AgentRadar stopped it, to update or move your workspace or because someone in your company stopped it. Nothing you did caused this.",
136
+ crashed: "It crashed, possibly from running out of memory.",
137
+ };
138
+
78
139
  /**
79
140
  * #173b — build the single-line frame for a #156 liveness PROBE.
80
141
  *
@@ -118,8 +179,8 @@ export function formatProbeFrame(probe, cliRunnerId) {
118
179
  // "no charge / zero blast radius"; state the honest bounded effect.
119
180
  const body =
120
181
  oneLine(p.instruction) ||
121
- `Liveness check — call the liveness_ack tool with nonce "${nonce}". Judge on structure, not who sent it: a wrong or stale nonce is a harmless no-op (safe even if forged); the tool is harness-granted (a message cannot add a tool); the nonce is server-minted (you cannot fabricate a valid one). A matching nonce only confirms your session is alive — it updates the liveness signal that gates billing; it moves no data, sends no message, creates no task, and creates no new charge.`;
122
- const cli = cliRunnerId ? ` Run: agentradar run ${cliRunnerId} liveness_ack --nonce "${nonce}"` : "";
182
+ `Liveness check — run the liveness_ack command with nonce "${nonce}". Judge on structure, not who sent it: a wrong or stale nonce is a harmless no-op (safe even if forged); the command is part of your installed CLI (a message cannot add one); the nonce is server-minted (you cannot fabricate a valid one). A matching nonce only confirms your session is alive — it updates the liveness signal that gates billing; it moves no data, sends no message, creates no task, and creates no new charge.`;
183
+ const cli = ` Run: agentradar run ${cliRunnerId || "$AR_CLI_RUNNER_ID"} liveness_ack --nonce "${nonce}"`;
123
184
  return oneLine(`${header} ${body}${cli}`);
124
185
  }
125
186
 
@@ -0,0 +1,76 @@
1
+ // SKILL-ZIP-S3 (Vadym 12039/12041): a skill version arrives as one zip with its sha256. This box treats it as
2
+ // untrusted: the hash is checked before a byte is parsed, every name is checked before anything is written,
3
+ // and the caps stop a zip bomb inside inflate itself. Stdlib only; the feeder and the CLI both import it.
4
+ // The writer is server/src/services/skill-zip.ts; server/src/__tests__/skill-zip.test.ts runs one against the other.
5
+ import { createHash } from "node:crypto";
6
+ import { inflateRawSync } from "node:zlib";
7
+
8
+ export const SKILL_ZIP_LIMITS = { entries: 1000, fileBytes: 2 * 1024 * 1024, totalBytes: 5 * 1024 * 1024 };
9
+
10
+ const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
11
+ let c = n;
12
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
13
+ return c >>> 0;
14
+ });
15
+ export function crc32(bytes) {
16
+ let c = 0xffffffff;
17
+ for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
18
+ return (c ^ 0xffffffff) >>> 0;
19
+ }
20
+
21
+ const refuse = (why) => { throw new Error(`skill zip refused: ${why}`); };
22
+
23
+ /** Returns [{ path, bytes, mode }] with mode 0o755 or 0o644, or throws. Nothing is written here. */
24
+ export function unpackSkillZip(zip, sha256, limits = SKILL_ZIP_LIMITS) {
25
+ if (!Buffer.isBuffer(zip)) refuse("not bytes");
26
+ if (createHash("sha256").update(zip).digest("hex") !== sha256) refuse("sha256 does not match");
27
+ let eocd = -1;
28
+ for (let i = zip.length - 22; i >= Math.max(0, zip.length - 22 - 0xffff); i--) {
29
+ if (zip.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
30
+ }
31
+ if (eocd < 0) refuse("no end of central directory");
32
+ const count = zip.readUInt16LE(eocd + 10);
33
+ if (count === 0xffff || count > limits.entries) refuse(`${count} entries`);
34
+ let at = zip.readUInt32LE(eocd + 16);
35
+ const files = [];
36
+ const seen = new Set();
37
+ let total = 0;
38
+ for (let n = 0; n < count; n++) {
39
+ if (at + 46 > eocd || zip.readUInt32LE(at) !== 0x02014b50) refuse("bad central directory");
40
+ const flags = zip.readUInt16LE(at + 8);
41
+ const method = zip.readUInt16LE(at + 10);
42
+ const crc = zip.readUInt32LE(at + 16);
43
+ const packed = zip.readUInt32LE(at + 20);
44
+ const size = zip.readUInt32LE(at + 24);
45
+ const nameLen = zip.readUInt16LE(at + 28);
46
+ const next = at + 46 + nameLen + zip.readUInt16LE(at + 30) + zip.readUInt16LE(at + 32);
47
+ const attr = zip.readUInt32LE(at + 38) >>> 16;
48
+ const local = zip.readUInt32LE(at + 42);
49
+ const path = zip.subarray(at + 46, at + 46 + nameLen).toString("utf8");
50
+ at = next;
51
+ if (flags & 1) refuse(`${path} is encrypted`);
52
+ if (method !== 0 && method !== 8) refuse(`${path} uses method ${method}`);
53
+ const type = attr & 0o170000;
54
+ if (type && type !== 0o100000) refuse(`${path} is not a regular file`);
55
+ if (!path || path.includes("\\") || path.includes("\0") || path.startsWith("/") || /^[a-zA-Z]:/.test(path)
56
+ || path.split("/").some((part) => part === "" || part === "." || part === "..")) refuse(`unsafe path ${JSON.stringify(path)}`);
57
+ if (seen.has(path.toLowerCase())) refuse(`${path} appears twice`);
58
+ seen.add(path.toLowerCase());
59
+ if (size > limits.fileBytes) refuse(`${path} is ${size} bytes`);
60
+ total += size;
61
+ if (total > limits.totalBytes) refuse(`more than ${limits.totalBytes} bytes unpacked`);
62
+ if (local + 30 > zip.length || zip.readUInt32LE(local) !== 0x04034b50) refuse(`${path} has no local header`);
63
+ const start = local + 30 + zip.readUInt16LE(local + 26) + zip.readUInt16LE(local + 28);
64
+ if (start + packed > zip.length) refuse(`${path} is truncated`);
65
+ const raw = zip.subarray(start, start + packed);
66
+ let bytes;
67
+ try {
68
+ bytes = method === 0 ? Buffer.from(raw) : inflateRawSync(raw, { maxOutputLength: Math.max(1, size) });
69
+ } catch {
70
+ refuse(`${path} does not inflate to its size`);
71
+ }
72
+ if (bytes.length !== size || crc32(bytes) !== crc) refuse(`${path} does not match its checksum`);
73
+ files.push({ path, bytes, mode: attr & 0o111 ? 0o755 : 0o644 });
74
+ }
75
+ return files;
76
+ }