agentradar-cli 0.3.6 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-guard/hooks/hooks.json +7 -7
- package/dist/agent-guard/skills/agentradar-discipline/SKILL.md +6 -0
- package/dist/agentradar-vendor/SKILL.md +1 -1
- package/dist/channel.js +39 -15
- package/dist/index.js +60 -104
- package/dist/setup.js +47 -34
- package/dist/skill-install.js +6 -4
- package/dist/tool-run.js +196 -157
- package/dist/vendor-runner/feeder.mjs +67 -8
- package/dist/vendor-runner/session-frame.mjs +86 -2
- package/dist/vendor-runner/supervisor/supervise-agent.sh +81 -15
- package/dist/vendor-runner/terminal-bridge.mjs +5 -0
- package/package.json +1 -2
- package/dist/commands.js +0 -74
|
@@ -516,20 +516,53 @@ export async function waitForPaneReceipt(eventId, sample = paneHasEvent, sleepFn
|
|
|
516
516
|
}
|
|
517
517
|
}
|
|
518
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
|
+
|
|
519
546
|
/** Inject one client event into the live pane. Returns true only after Claude's pane shows the submitted turn. */
|
|
520
|
-
async function injectEvent(event) {
|
|
521
|
-
if (!(await
|
|
547
|
+
export async function injectEvent(event, { idle = waitForIdlePane, inject = injectFrame, receipt = waitForPaneReceipt, noticeFile = RESTART_NOTICE } = {}) {
|
|
548
|
+
if (!(await idle())) {
|
|
522
549
|
console.warn(
|
|
523
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`,
|
|
524
551
|
);
|
|
525
552
|
return false;
|
|
526
553
|
}
|
|
527
|
-
const
|
|
528
|
-
|
|
554
|
+
const restartNotice = readNotice(noticeFile);
|
|
555
|
+
const injected = await inject(
|
|
556
|
+
formatInjectionFrame(restartNotice ? { ...event, restartNotice } : event, AR_CLI_RUNNER_ID),
|
|
529
557
|
`eventId=${event.eventId} (source=${event.source}, conversationId=${event.conversationId ?? "-"})`,
|
|
530
558
|
);
|
|
531
559
|
if (!injected) return false;
|
|
532
|
-
|
|
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
|
+
}
|
|
533
566
|
console.warn(
|
|
534
567
|
`[feeder] pane ${TMUX_SESSION} did not show eventId=${event.eventId} after injection — leaving it UNACKED for re-delivery`,
|
|
535
568
|
);
|
|
@@ -636,6 +669,15 @@ export function paneInputReady(text, harness = AR_AGENT_HARNESS, readyPattern =
|
|
|
636
669
|
: /bypass permissions on\b|^\s*❯/im.test(tail);
|
|
637
670
|
}
|
|
638
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
|
+
|
|
639
681
|
/**
|
|
640
682
|
* Vadym 12490: the permission mode Claude Code's footer names ("⏸ manual mode on"), or null when no footer shows.
|
|
641
683
|
* A hosted session must read "bypass permissions"; anything else is reported so the server can alert.
|
|
@@ -680,7 +722,7 @@ function capturePane() {
|
|
|
680
722
|
// "• Working (1s • esc to interrupt)", measured on codex-cli 0.149.1, so this matcher needs no harness
|
|
681
723
|
// branch. This is only a HINT that triggers a probe on stagnation — it never decides liveness, so a
|
|
682
724
|
// missed/extra hint is harmless.
|
|
683
|
-
const inProgress =
|
|
725
|
+
const inProgress = paneBusy(text);
|
|
684
726
|
// The CLI's idle input prompt is the durable readiness signal, and it is the one pane signal that differs
|
|
685
727
|
// per harness (see paneInputReady). The optional bypass-permissions footer disappeared from a live Claude
|
|
686
728
|
// build after several turns, which falsely demoted a healthy pane to `starting` and stopped next_event
|
|
@@ -1067,7 +1109,13 @@ let _lastRunnerState = null;
|
|
|
1067
1109
|
|
|
1068
1110
|
/** One receive→inject→ack-select cycle (extracted from loop() + exported so the #173b _pendingAck
|
|
1069
1111
|
* fence is directly testable). Sets `_pendingAck` = the eventIds to ack on the NEXT poll. */
|
|
1070
|
-
|
|
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) {
|
|
1071
1119
|
// next_event refreshes the portal's runner heartbeat. Never call it for a detached/orphan feeder: a
|
|
1072
1120
|
// process without its owned tmux session cannot receive work, so polling would paint a false Live state
|
|
1073
1121
|
// while every event stays unacknowledged. The supervisor recreates the session; until then the durable
|
|
@@ -1113,7 +1161,14 @@ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, d
|
|
|
1113
1161
|
}
|
|
1114
1162
|
console.log(`[feeder] received ${events.length} event(s) → injecting into live session`);
|
|
1115
1163
|
const delivered = [];
|
|
1164
|
+
let held = 0;
|
|
1165
|
+
pruneTyped();
|
|
1116
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
|
+
}
|
|
1117
1172
|
// Inject in order; ack ONLY the ones that reached a live pane (no-phantom).
|
|
1118
1173
|
// eslint-disable-next-line no-await-in-loop
|
|
1119
1174
|
if (await deliverEvent(firstStep ? { ...e, firstStep } : e)) delivered.push(e.eventId);
|
|
@@ -1124,6 +1179,8 @@ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, d
|
|
|
1124
1179
|
}
|
|
1125
1180
|
_pendingAck = delivered;
|
|
1126
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);
|
|
1127
1184
|
}
|
|
1128
1185
|
|
|
1129
1186
|
async function loop() {
|
|
@@ -1138,9 +1195,11 @@ async function loop() {
|
|
|
1138
1195
|
void syncSkills();
|
|
1139
1196
|
setInterval(() => { void syncSkills(); }, SKILLS_SYNC_INTERVAL_MS).unref?.();
|
|
1140
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");
|
|
1141
1200
|
while (!_stopping) {
|
|
1142
1201
|
try {
|
|
1143
|
-
await pollOnce();
|
|
1202
|
+
await pollOnce(...channelOnly);
|
|
1144
1203
|
} catch (err) {
|
|
1145
1204
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1146
1205
|
console.error(`[feeder] iteration error: ${msg} — re-establishing MCP session`);
|
|
@@ -21,6 +21,36 @@ 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? }
|
|
@@ -36,14 +66,47 @@ 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
|
|
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
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.`;
|
|
45
106
|
const parts = [
|
|
46
|
-
|
|
107
|
+
...(lifted ? [catchUp] : []),
|
|
108
|
+
...(room ? [room] : []),
|
|
109
|
+
answer,
|
|
47
110
|
evId
|
|
48
111
|
? `For billable work run ${run} report_usage with --idempotency-key "${evId}:<usage-code>" so a re-delivered event never double-charges.`
|
|
49
112
|
: `Use ${run} report_usage for billable work.`,
|
|
@@ -52,6 +115,27 @@ export function formatInjectionFrame(event, cliRunnerId) {
|
|
|
52
115
|
return oneLine(`${header} ${body} — ${parts.join(" ")}`);
|
|
53
116
|
}
|
|
54
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
|
+
|
|
55
139
|
/**
|
|
56
140
|
* #173b — build the single-line frame for a #156 liveness PROBE.
|
|
57
141
|
*
|
|
@@ -48,6 +48,7 @@ set -a
|
|
|
48
48
|
# shellcheck source=/dev/null
|
|
49
49
|
. "${ENV_FILE}"
|
|
50
50
|
set +a
|
|
51
|
+
[ "${2:-}" = "--dry-run" ] && AR_DRY_RUN=1
|
|
51
52
|
|
|
52
53
|
# --- 2. Validate + defaults ---------------------------------------------------
|
|
53
54
|
: "${AGENT_NAME:?AGENT_NAME required in env}"
|
|
@@ -66,6 +67,11 @@ CLAUDE_BIN="${CLAUDE_BIN:-claude}"
|
|
|
66
67
|
TMUX_BIN="${TMUX_BIN:-tmux}"
|
|
67
68
|
CLAUDE_EXTRA_FLAGS="${CLAUDE_EXTRA_FLAGS:-}" # e.g. "--chrome" (Spielberg/Sam); empty (Liza)
|
|
68
69
|
PRE_LAUNCH_HOOK="${PRE_LAUNCH_HOOK:-}" # optional per-agent script run once before claude
|
|
70
|
+
PRE_LAUNCH_HOOK_REQUIRED="${PRE_LAUNCH_HOOK_REQUIRED:-0}" # 1: a failing or missing hook aborts the launch; the loop retries (e.g. wait for the gateway)
|
|
71
|
+
AR_LAUNCH_ARGS_FILE="${AR_LAUNCH_ARGS_FILE:-}" # extra launch args, one VERBATIM arg per line (`@file:<path>` = that file's whole content as one arg)
|
|
72
|
+
AR_PROMPT_WATCH_SEC="${AR_PROMPT_WATCH_SEC:-180}" # how long to answer startup prompts (dev channels, Chrome intro, resume summary)
|
|
73
|
+
AR_READY_PORTS="${AR_READY_PORTS:-}" # e.g. "8769 8770": none up after the watch = deaf session, kill the pane so it is recreated
|
|
74
|
+
AR_DRY_RUN="${AR_DRY_RUN:-0}" # 1 (or a 2nd argument --dry-run): print the exact argv and env, start nothing
|
|
69
75
|
BROWSER_APP="${BROWSER_APP:-Google Chrome}" # #150: app to ensure-up for --chrome agents (macOS)
|
|
70
76
|
BROWSER_READY_MAX_SEC="${BROWSER_READY_MAX_SEC:-20}" # #150: bounded wait for the browser proc on cold boot
|
|
71
77
|
BROWSER_READY_SETTLE_SEC="${BROWSER_READY_SETTLE_SEC:-3}" # #150: settle for extension/native-host after cold launch
|
|
@@ -149,6 +155,9 @@ else
|
|
|
149
155
|
# SC2086: CLAUDE_EXTRA_FLAGS is intentionally UNQUOTED so "--chrome" (etc.) word-splits into flags.
|
|
150
156
|
# shellcheck disable=SC2086
|
|
151
157
|
_launch_flags=( ${CLAUDE_EXTRA_FLAGS} --strict-mcp-config --mcp-config="${MCP_CONFIG}" )
|
|
158
|
+
# AR_MCP_STRICT=0 (written by `agentradar join`): AgentRadar joins the agent's own MCPs and channels instead of
|
|
159
|
+
# replacing them (Vadym 13444). The default stays strict: hosted and vendor-fleet agents still get only $MCP_CONFIG.
|
|
160
|
+
[ "${AR_MCP_STRICT:-1}" = 0 ] && _launch_flags=( ${CLAUDE_EXTRA_FLAGS} --mcp-config="${MCP_CONFIG}" )
|
|
152
161
|
# `agentradar join` enables the agent-guard plugin for this session only, through a settings file.
|
|
153
162
|
[ -n "${CLAUDE_SETTINGS:-}" ] && _launch_flags+=( --settings "${CLAUDE_SETTINGS}" )
|
|
154
163
|
# R764 + Vadym 10342: the AgentRadar channel (the stdio MCP `agentradar` in $MCP_CONFIG) pushes work in; no feeder types.
|
|
@@ -161,6 +170,24 @@ else
|
|
|
161
170
|
fi
|
|
162
171
|
AGENT_PID_PATTERN="${AGENT_PID_PATTERN:-${_agent_pid_pattern_default}}"
|
|
163
172
|
|
|
173
|
+
# The launcher for a session that already has its own launch command (the secretary: model pin, plugin dirs, channels,
|
|
174
|
+
# --append-system-prompt): its flags go here verbatim, after ours. CLAUDE_EXTRA_FLAGS word-splits and cannot carry them.
|
|
175
|
+
AR_ARGS_CHROME=""
|
|
176
|
+
if [ -n "${AR_LAUNCH_ARGS_FILE}" ]; then
|
|
177
|
+
[ -f "${AR_LAUNCH_ARGS_FILE}" ] || die "AR_LAUNCH_ARGS_FILE not found: ${AR_LAUNCH_ARGS_FILE}"
|
|
178
|
+
while IFS= read -r _a || [ -n "${_a}" ]; do
|
|
179
|
+
case "${_a}" in
|
|
180
|
+
''|'#'*) continue ;;
|
|
181
|
+
'@file:'*) [ -f "${_a#@file:}" ] || die "AR_LAUNCH_ARGS_FILE names a missing file: ${_a#@file:}"; _a="$(cat "${_a#@file:}")" ;;
|
|
182
|
+
esac
|
|
183
|
+
[ "${_a}" = "--chrome" ] && AR_ARGS_CHROME="--chrome"
|
|
184
|
+
_launch_flags+=( "${_a}" )
|
|
185
|
+
done < "${AR_LAUNCH_ARGS_FILE}"
|
|
186
|
+
fi
|
|
187
|
+
if [ "${PRE_LAUNCH_HOOK_REQUIRED}" = 1 ] && [ ! -x "${PRE_LAUNCH_HOOK}" ]; then
|
|
188
|
+
die "PRE_LAUNCH_HOOK_REQUIRED=1 but PRE_LAUNCH_HOOK is not an executable file: '${PRE_LAUNCH_HOOK}'"
|
|
189
|
+
fi
|
|
190
|
+
|
|
164
191
|
# Optional per-agent TOOL credentials — a vendor brain's OWN MCP-tool API keys (e.g. an SEO agent's
|
|
165
192
|
# WEBFLOW_TOKEN / PERPLEXITY_API_KEY). These are ADDED to the scrubbed launch env so the agent's own
|
|
166
193
|
# tool servers (declared in its --mcp-config) get their creds, WITHOUT widening the daemon's
|
|
@@ -228,6 +255,24 @@ command -v "${AGENT_BIN}" >/dev/null 2>&1 || die "${AR_AGENT_HARNESS} CLI not fo
|
|
|
228
255
|
[ -f "${PANE_LIVE_SH}" ] || die "pane liveness check not found: ${PANE_LIVE_SH}"
|
|
229
256
|
[ -d "${AR_BASE}" ] || die "AR_BASE dir not found: ${AR_BASE}"
|
|
230
257
|
|
|
258
|
+
# --- 2a-bis. Dry run: the exact launch, printed, nothing started or written. Secret values show only their length.
|
|
259
|
+
# Same argv/env as the launch below: `env -i` allowlist + claude cfg + tool-env entries, then the resume args and flags.
|
|
260
|
+
if [ "${AR_DRY_RUN}" = 1 ]; then
|
|
261
|
+
# redact by (case-insensitive) name or by value shape: a secret in a name we did not foresee still shows only its length
|
|
262
|
+
_show() { local _n; _n=$(printf %s "$1" | tr a-z A-Z); case "${_n}" in *TOKEN*|*KEY*|*SECRET*|*BEARER*|*PASS*|*AUTH*|*COOKIE*|*CRED*|PAT|*_PAT|PAT_*|*_PAT_*) printf 'ENV %s=<redacted:%s chars>\n' "$1" "${#2}"; return ;; esac
|
|
263
|
+
case "$2" in *://*:*@*|*[Bb]earer\ *) printf 'ENV %s=<redacted:%s chars>\n' "$1" "${#2}" ;; *) printf 'ENV %s=%s\n' "$1" "$2" ;; esac; }
|
|
264
|
+
_showarg() { case "$1" in *[Bb]earer*|*://*:*@*|*[Tt]oken=*|*[Ss]ecret=*|*[Kk]ey=*) printf 'ARGV <redacted:%s chars>\n' "${#1}" ;; *) printf 'ARGV %q\n' "$1" ;; esac; }
|
|
265
|
+
printf 'DRY_RUN session=%s cwd=%s harness=%s feed=%s resume=%s fresh=%s\n' "${TMUX_SESSION}" "${AR_BASE}" "${AR_AGENT_HARNESS}" "${AR_FEED}" "${AR_RESUME_FLAGS}" "${AR_FRESH_FLAGS}"
|
|
266
|
+
for _k in HOME PATH USER LOGNAME SHELL LANG LC_ALL TMPDIR NODE_OPTIONS CODEX_HOME; do _show "${_k}" "${!_k:-}"; done
|
|
267
|
+
_show TERM "${AR_AGENT_TERM:-tmux-256color}"; _show COLORTERM "${AR_AGENT_COLORTERM:-truecolor}"; _show LC_CTYPE "${AR_AGENT_LC_CTYPE:-en_US.UTF-8}"
|
|
268
|
+
_show DISABLE_AUTOUPDATER 1; _show CLAUDE_CODE_GB_DISK_CACHE_WHEN_TELEMETRY_OFF 1
|
|
269
|
+
for _kv in ${_claude_cfg[@]+"${_claude_cfg[@]}"} ${TOOL_ENV_ARGS[@]+"${TOOL_ENV_ARGS[@]}"}; do _show "${_kv%%=*}" "${_kv#*=}"; done
|
|
270
|
+
printf 'ARGV %q\n' "${AGENT_BIN}"
|
|
271
|
+
# shellcheck disable=SC2086 # the resume args word-split in the pane wrapper too
|
|
272
|
+
for _a in ${AR_RESUME_FLAGS} ${_launch_flags[@]+"${_launch_flags[@]}"}; do _showarg "${_a}"; done
|
|
273
|
+
exit 0
|
|
274
|
+
fi
|
|
275
|
+
|
|
231
276
|
# --- 2b. Credential hygiene (#152): secrets to 0600 files, OUT of argv+env ------
|
|
232
277
|
# The AWP bearer must not linger in any long-lived process's ARGV (`ps aux`) or ENVIRONMENT
|
|
233
278
|
# (`ps eww`), where a co-resident process on the box could read it. Stage it into a per-agent 0600
|
|
@@ -253,7 +298,7 @@ if [ "${#TOOL_ENV_ARGS[@]}" -gt 0 ]; then
|
|
|
253
298
|
TOOL_ENV_ARGS=() # #166: no longer passed inline on the env -i line
|
|
254
299
|
fi
|
|
255
300
|
|
|
256
|
-
session_exists() { "${TMUX_BIN}" has-session -t "
|
|
301
|
+
session_exists() { "${TMUX_BIN}" has-session -t "=${TMUX_SESSION}" 2>/dev/null; }
|
|
257
302
|
pane_alive() {
|
|
258
303
|
TMUX_BIN="${TMUX_BIN}" TMUX_SESSION="${TMUX_SESSION}" AGENT_PID_PATTERN="${AGENT_PID_PATTERN}" \
|
|
259
304
|
/bin/sh "${PANE_LIVE_SH}"
|
|
@@ -266,7 +311,7 @@ pane_alive() {
|
|
|
266
311
|
# Overridable via BROWSER_APP (default "Google Chrome"). macOS-only; documented no-op elsewhere (on Linux,
|
|
267
312
|
# launch the browser in your runner env / at login). Belt-and-suspenders alongside a Login-Items entry.
|
|
268
313
|
ensure_browser() {
|
|
269
|
-
case " ${CLAUDE_EXTRA_FLAGS} " in
|
|
314
|
+
case " ${CLAUDE_EXTRA_FLAGS} ${AR_ARGS_CHROME} " in
|
|
270
315
|
*" --chrome "*) : ;; # --chrome as a WHOLE space-bounded token (won't false-match e.g. --chromeless)
|
|
271
316
|
*) return 0 ;; # not a --chrome agent → nothing to do
|
|
272
317
|
esac
|
|
@@ -302,7 +347,7 @@ ensure_browser() {
|
|
|
302
347
|
# True when one identical `API Error: 400` line (digits normalised: the message index moves) fills the pane
|
|
303
348
|
# scrollback AR_API400_LOOP_MAX times. ponytail: only the last 200 lines are read, so a very slow loop could slip by.
|
|
304
349
|
api_error_loop() {
|
|
305
|
-
"${TMUX_BIN}" capture-pane -p -J -t "
|
|
350
|
+
"${TMUX_BIN}" capture-pane -p -J -t "=${TMUX_SESSION}:" -S -200 2>/dev/null \
|
|
306
351
|
| grep -o 'API Error: 400 .*' | sed -E 's/[0-9]+/N/g' | sort | uniq -c | sort -rn \
|
|
307
352
|
| awk -v m="${AR_API400_LOOP_MAX}" 'NR==1{ok=($1>=m)} END{exit !ok}'
|
|
308
353
|
}
|
|
@@ -321,7 +366,7 @@ ensure_agent() {
|
|
|
321
366
|
fi
|
|
322
367
|
if session_exists && ! pane_alive; then
|
|
323
368
|
log "session '${TMUX_SESSION}' exists but claude pane is dead — recreating"
|
|
324
|
-
"${TMUX_BIN}" kill-session -t "
|
|
369
|
+
"${TMUX_BIN}" kill-session -t "=${TMUX_SESSION}" 2>/dev/null || true
|
|
325
370
|
fi
|
|
326
371
|
[ -n "${AR_GUARD_MARKER}" ] && rm -f "${AR_GUARD_MARKER}" # this launch must prove the guard afresh
|
|
327
372
|
# Retire-legacy step 1 (Vadym 11054): Codex starts only with AgentRadar's guard. Its SessionStart hook does not
|
|
@@ -347,7 +392,10 @@ ensure_agent() {
|
|
|
347
392
|
ensure_browser # #150: bring up the browser first for --chrome agents (idempotent, no-op otherwise)
|
|
348
393
|
if [ -n "${PRE_LAUNCH_HOOK}" ] && [ -x "${PRE_LAUNCH_HOOK}" ]; then
|
|
349
394
|
log "running pre-launch hook: ${PRE_LAUNCH_HOOK}"
|
|
350
|
-
"${PRE_LAUNCH_HOOK}"
|
|
395
|
+
if ! "${PRE_LAUNCH_HOOK}"; then
|
|
396
|
+
[ "${PRE_LAUNCH_HOOK_REQUIRED}" = 1 ] && { log "pre-launch hook failed and is required — not launching"; return 1; }
|
|
397
|
+
log "WARN pre-launch hook exited non-zero (continuing)"
|
|
398
|
+
fi
|
|
351
399
|
fi
|
|
352
400
|
# SCRUBBED launch: env -i with a strict allowlist so the agent inherits nothing from this daemon
|
|
353
401
|
# (AWP_BEARER, AWP_MCP_URL, etc. never reach the pane). CONNECTOR ISOLATION is enforced by
|
|
@@ -428,19 +476,37 @@ ensure_agent() {
|
|
|
428
476
|
return 1
|
|
429
477
|
}
|
|
430
478
|
|
|
431
|
-
# A pushed session starts on Claude Code's "Loading development channels" warning
|
|
432
|
-
#
|
|
433
|
-
#
|
|
479
|
+
# A pushed session starts on Claude Code's "Loading development channels" warning ("I am using this for local
|
|
480
|
+
# development" / "Exit"), once per development channel, and nobody sits at an unattended pane to confirm it. A launcher
|
|
481
|
+
# that carries its own launch args (AR_LAUNCH_ARGS_FILE) names those channels itself, so it answers every one of them,
|
|
482
|
+
# however late, plus the Chrome intro and the "Resume from summary" modal, for AR_PROMPT_WATCH_SEC (same strings and
|
|
483
|
+
# keys as the secretary's watchdog). Then, if AR_READY_PORTS names channel ports and none is listening, the session is
|
|
484
|
+
# deaf: kill the pane so the monitor loop recreates it.
|
|
434
485
|
accept_dev_channels() {
|
|
435
|
-
[ "${AR_FEED}" = "push" ] || return 0
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
486
|
+
{ [ "${AR_FEED}" = "push" ] || [ -n "${AR_LAUNCH_ARGS_FILE}" ]; } || return 0
|
|
487
|
+
local _pane _now _t0 _last=0 _chrome=0 _resume=0 _p _up=0 _presses=0
|
|
488
|
+
_t0=$(date +%s)
|
|
489
|
+
while _now=$(date +%s); [ $(( _now - _t0 )) -lt "${AR_PROMPT_WATCH_SEC}" ]; do
|
|
490
|
+
_pane="$("${TMUX_BIN}" capture-pane -p -t "=${TMUX_SESSION}:" 2>/dev/null || true)"
|
|
491
|
+
if [ "${_presses}" -lt 6 ] && [ $(( _now - _last )) -ge 2 ] && { grep -q "I am using this for local development" <<< "${_pane}" || grep -q "Loading development channels" <<< "${_pane}"; }; then
|
|
492
|
+
"${TMUX_BIN}" send-keys -t "=${TMUX_SESSION}:" 1 Enter
|
|
493
|
+
_last=${_now}; _presses=$(( _presses + 1 )) # cap: text the agent itself prints must not type into its conversation forever
|
|
494
|
+
log "confirmed Claude Code's development-channels warning for a launch channel"
|
|
495
|
+
fi
|
|
496
|
+
if [ "${_chrome}" = 0 ] && grep -q "Claude in Chrome (Beta)" <<< "${_pane}"; then
|
|
497
|
+
"${TMUX_BIN}" send-keys -t "=${TMUX_SESSION}:" Enter; _chrome=1; log "dismissed the Claude-in-Chrome intro"
|
|
498
|
+
fi
|
|
499
|
+
if [ "${_resume}" = 0 ] && grep -q "Resume from summary" <<< "${_pane}"; then
|
|
500
|
+
"${TMUX_BIN}" send-keys -t "=${TMUX_SESSION}:" Enter; _resume=1; log "took the default of the resume-from-summary modal"
|
|
441
501
|
fi
|
|
442
502
|
sleep 1
|
|
443
503
|
done
|
|
504
|
+
[ -n "${AR_READY_PORTS}" ] || return 0
|
|
505
|
+
for _p in ${AR_READY_PORTS}; do (exec 3<>"/dev/tcp/127.0.0.1/${_p}") 2>/dev/null && _up=1; done
|
|
506
|
+
if [ "${_up}" = 0 ]; then
|
|
507
|
+
log "WARNING: no channel port of '${AR_READY_PORTS}' is up after ${AR_PROMPT_WATCH_SEC}s: killing the pane so it is recreated"
|
|
508
|
+
"${TMUX_BIN}" kill-session -t "=${TMUX_SESSION}" 2>/dev/null || true # "=": exact match, never a prefix (join-req-1 killed join-req-12)
|
|
509
|
+
fi
|
|
444
510
|
}
|
|
445
511
|
|
|
446
512
|
# --- 4. Run: ensure agent, run feeder in foreground, monitor both -------------
|
|
@@ -525,7 +591,7 @@ while true; do
|
|
|
525
591
|
elif [ "${AR_ATTACH:-}" != "1" ] && [ $(( $(date +%s) - ${_LAST_HEAL:-0} )) -ge "${AR_API400_COOLDOWN_SEC}" ] && api_error_loop; then
|
|
526
592
|
log "agent pane is looping on the same API 400 — replacing it with a FRESH session"
|
|
527
593
|
_LAST_HEAL=$(date +%s); _FORCE_FRESH=1; _heal=1
|
|
528
|
-
"${TMUX_BIN}" kill-session -t "
|
|
594
|
+
"${TMUX_BIN}" kill-session -t "=${TMUX_SESSION}" 2>/dev/null || true
|
|
529
595
|
fi
|
|
530
596
|
if [ "${_heal}" = 1 ]; then
|
|
531
597
|
if ensure_agent; then
|
|
@@ -351,6 +351,11 @@ class TerminalBridge {
|
|
|
351
351
|
case "detach":
|
|
352
352
|
this.stopPipe();
|
|
353
353
|
break;
|
|
354
|
+
case "clear":
|
|
355
|
+
// An engagement ended or changed: drop the scrollback so the next client's snapshot (capture-pane -S)
|
|
356
|
+
// cannot replay the previous one's. The visible screen of the live session is not touched.
|
|
357
|
+
tmux(["clear-history", "-t", TMUX_SESSION]);
|
|
358
|
+
break;
|
|
354
359
|
case "input":
|
|
355
360
|
if (typeof obj.data === "string") tmux(["send-keys", "-t", TMUX_SESSION, "-l", obj.data]);
|
|
356
361
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentradar-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=20"
|
|
6
6
|
},
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
"dist/sync.js",
|
|
20
20
|
"dist/vault-import.js",
|
|
21
21
|
"dist/update.js",
|
|
22
|
-
"dist/commands.js",
|
|
23
22
|
"dist/vault-clone.js",
|
|
24
23
|
"dist/secrets.js",
|
|
25
24
|
"dist/admin.js",
|
package/dist/commands.js
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Vadym 9970 (CLI-FIRST): every agent-facing MCP tool is also a CLI command, `agentradar run <runner-id> <noun> <verb>`,
|
|
3
|
-
* in AgentRadar terms. The tree is derived from the server's own tools/list at call time, so it cannot fall behind
|
|
4
|
-
* the MCP; this file only says how a tool name reads as a noun and a verb.
|
|
5
|
-
*/
|
|
6
|
-
/** Nouns a tool name is grouped under, most specific first. A tool takes the first one its name contains. */
|
|
7
|
-
const NOUNS = [
|
|
8
|
-
"inbox", "thread", "message", "task", "metric", "quota", "vault", "memory", "skill", "escalation", "incident",
|
|
9
|
-
"karma", "wallet", "spend", "budget", "usage", "browser", "email", "telegram", "slack", "discord", "whatsapp",
|
|
10
|
-
"github", "model", "outcome", "role", "org_chart", "org", "project", "integration", "channel", "peer",
|
|
11
|
-
"subordinate", "secret", "service", "onboarding", "intervention", "marketplace", "hire", "egress", "feedback",
|
|
12
|
-
"contact", "placement", "mcp", "tool", "join_link", "handoff", "selftest", "readiness", "pane", "brief",
|
|
13
|
-
"teammate", "approval", "agent",
|
|
14
|
-
];
|
|
15
|
-
const PLURAL = {
|
|
16
|
-
messages: "message", tasks: "task", metrics: "metric", vaults: "vault", skills: "skill", escalations: "escalation",
|
|
17
|
-
threads: "thread", channels: "channel", peers: "peer", subordinates: "subordinate", integrations: "integration",
|
|
18
|
-
secrets: "secret", agents: "agent", interventions: "intervention", events: "event", retrospectives: "retrospective",
|
|
19
|
-
};
|
|
20
|
-
/** Where the rule above would put a tool somewhere an agent would not look for it. */
|
|
21
|
-
const OVERRIDES = {
|
|
22
|
-
next_event: ["inbox", "next"],
|
|
23
|
-
mark_messages_read: ["inbox", "mark-read"],
|
|
24
|
-
search_history: ["thread", "history"],
|
|
25
|
-
reply: ["thread", "reply"],
|
|
26
|
-
read_history: ["thread", "read"],
|
|
27
|
-
mark_read: ["thread", "mark-read"],
|
|
28
|
-
expect_reply: ["thread", "expect-reply"],
|
|
29
|
-
clear_reply_allowance: ["thread", "clear-reply-allowance"],
|
|
30
|
-
request_reply_clearance: ["thread", "request-reply-clearance"],
|
|
31
|
-
github_thread: ["github", "thread"],
|
|
32
|
-
post_escalation_discussion_message: ["escalation", "post-message"],
|
|
33
|
-
read_escalation_discussion: ["escalation", "read-discussion"],
|
|
34
|
-
get_self: ["agent", "self"],
|
|
35
|
-
get_my_context: ["agent", "context"],
|
|
36
|
-
liveness_ack: ["agent", "liveness-ack"],
|
|
37
|
-
remember: ["memory", "remember"],
|
|
38
|
-
recall: ["memory", "recall"],
|
|
39
|
-
my_skills: ["skill", "mine"],
|
|
40
|
-
list_my_threads: ["thread", "list"],
|
|
41
|
-
list_my_metrics: ["metric", "list"],
|
|
42
|
-
read_my_usage: ["usage", "read"],
|
|
43
|
-
// #496 (APPROVAL-ASK): the comment thread on an approval, and the decision it acknowledges.
|
|
44
|
-
approval_comments: ["approval", "comments"],
|
|
45
|
-
approval_comment: ["approval", "comment"],
|
|
46
|
-
acknowledge_decision: ["approval", "acknowledge"],
|
|
47
|
-
};
|
|
48
|
-
/** One-release aliases: still on the MCP, never given a command of their own. */
|
|
49
|
-
export const DEPRECATED_TOOLS = new Set(["acknowledge_messages"]);
|
|
50
|
-
/** `<noun> <verb>` for a tool name. */
|
|
51
|
-
export function commandFor(tool) {
|
|
52
|
-
const fixed = OVERRIDES[tool];
|
|
53
|
-
if (fixed)
|
|
54
|
-
return fixed;
|
|
55
|
-
// Plurals fold only to FIND the noun (list_tasks is a task command); the verb keeps the tool's own words, so
|
|
56
|
-
// slack_list_channels reads "list-channels" as slack_list_users reads "list-users".
|
|
57
|
-
const raw = tool.split("_");
|
|
58
|
-
const words = raw.map((word) => PLURAL[word] ?? word);
|
|
59
|
-
for (const noun of NOUNS) {
|
|
60
|
-
const parts = noun.split("_");
|
|
61
|
-
for (let i = 0; i + parts.length <= words.length; i++) {
|
|
62
|
-
if (parts.every((part, k) => words[i + k] === part)) {
|
|
63
|
-
const rest = [...raw.slice(0, i), ...raw.slice(i + parts.length)];
|
|
64
|
-
return [noun.replace(/_/g, "-"), rest.join("-") || "get"];
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return ["agent", raw.join("-")];
|
|
69
|
-
}
|
|
70
|
-
/** The tool a `<noun> <verb>` names among the server's tools, or undefined. */
|
|
71
|
-
export function toolFor(noun, verb, tools) {
|
|
72
|
-
return tools.find((tool) => !DEPRECATED_TOOLS.has(tool) && commandFor(tool).join(" ") === `${noun} ${verb}`);
|
|
73
|
-
}
|
|
74
|
-
//# sourceMappingURL=commands.js.map
|