agentradar-cli 0.3.5 → 0.3.6
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/admin.js +39 -0
- package/dist/agent-guard/hooks/hooks.json +3 -3
- package/dist/agent-guard/scripts/compaction-report.mjs +76 -0
- package/dist/agentradar-vendor/SKILL.md +11 -9
- package/dist/channel.js +21 -20
- package/dist/index.js +11 -3
- package/dist/runner.js +16 -11
- package/dist/secrets.js +3 -1
- package/dist/setup.js +77 -24
- package/dist/skill-install.js +8 -74
- package/dist/tool-run.js +4 -25
- package/dist/update.js +27 -2
- package/dist/vendor-runner/feeder.mjs +117 -16
- package/dist/vendor-runner/session-frame.mjs +12 -35
- package/dist/vendor-runner/skill-zip.mjs +76 -0
- package/package.json +3 -2
package/dist/setup.js
CHANGED
|
@@ -8,6 +8,7 @@ import { homedir } from "node:os";
|
|
|
8
8
|
import * as tls from "node:tls";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
10
|
import { syncJoinedSkills } from "./skill-install.js";
|
|
11
|
+
import { cliHeader, noteCliUpdate } from "./update.js";
|
|
11
12
|
const DEFAULT_API_URL = "https://vendor.agentradar.com/api";
|
|
12
13
|
/** The service's CLI build (the CLI itself and the agent-guard plugin): `installCli` keeps it current, never the npx cache. */
|
|
13
14
|
const cliDist = (home) => join(home, ".agentradar", "cli", "current", "dist");
|
|
@@ -28,11 +29,13 @@ const SUPERVISOR_HARNESS = {
|
|
|
28
29
|
generic: "generic",
|
|
29
30
|
};
|
|
30
31
|
async function postJson(fetchImpl, url, body) {
|
|
31
|
-
|
|
32
|
+
const response = await fetchImpl(url, {
|
|
32
33
|
method: "POST",
|
|
33
|
-
headers: { "content-type": "application/json", accept: "application/json", "cache-control": "no-store" },
|
|
34
|
+
headers: { ...(await cliHeader()), "content-type": "application/json", accept: "application/json", "cache-control": "no-store" },
|
|
34
35
|
body: JSON.stringify(body),
|
|
35
36
|
});
|
|
37
|
+
await noteCliUpdate(response.headers);
|
|
38
|
+
return response;
|
|
36
39
|
}
|
|
37
40
|
/**
|
|
38
41
|
* The server's own words for a refusal, when it sent any.
|
|
@@ -117,7 +120,7 @@ export function trustedMcpEndpoint(value) {
|
|
|
117
120
|
endpoint.hostname.endsWith(".localhost"));
|
|
118
121
|
return endpoint.protocol === "https:" || loopbackHttp ? endpoint : null;
|
|
119
122
|
}
|
|
120
|
-
/**
|
|
123
|
+
/** AgentRadar is CLI-only: a project entry for `agentradar` is only ever removed (never written), and an agent acts through `agentradar run`. */
|
|
121
124
|
async function installProjectMcp(configPath, cwd, installation, trust,
|
|
122
125
|
/** Vadym 10342: a joined Claude Code agent's AgentRadar channel (`agentradar channel <runner-id>`, stdio). */
|
|
123
126
|
channel) {
|
|
@@ -153,14 +156,6 @@ channel) {
|
|
|
153
156
|
if (channel) {
|
|
154
157
|
mcpServers.agentradar = channel;
|
|
155
158
|
}
|
|
156
|
-
else if (installation && endpoint) {
|
|
157
|
-
mcpServers.agentradar = {
|
|
158
|
-
type: "http",
|
|
159
|
-
url: endpoint.toString(),
|
|
160
|
-
headers: { Authorization: `Bearer ${installation.bearer}` },
|
|
161
|
-
timeout: 1_800_000,
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
159
|
else {
|
|
165
160
|
delete mcpServers.agentradar;
|
|
166
161
|
}
|
|
@@ -178,6 +173,68 @@ channel) {
|
|
|
178
173
|
await chmod(configPath, 0o600);
|
|
179
174
|
return projectPath;
|
|
180
175
|
}
|
|
176
|
+
/** Drop the `[mcp_servers.agentradar]` table (and its sub-tables) when it is the old HTTP one (has a `url`). Null = nothing to drop. */
|
|
177
|
+
export function stripCodexAgentradar(toml) {
|
|
178
|
+
const lines = toml.split("\n");
|
|
179
|
+
const out = [];
|
|
180
|
+
let dropped = false;
|
|
181
|
+
for (let i = 0; i < lines.length; i++) {
|
|
182
|
+
if (!/^\s*\[\s*mcp_servers\s*\.\s*["']?agentradar["']?\s*[\].]/.test(lines[i])) {
|
|
183
|
+
out.push(lines[i]);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
let end = i + 1;
|
|
187
|
+
while (end < lines.length && !/^\s*\[/.test(lines[end]))
|
|
188
|
+
end++;
|
|
189
|
+
// A following [mcp_servers.agentradar.http_headers] belongs to the same entry.
|
|
190
|
+
while (end < lines.length && /^\s*\[\s*mcp_servers\s*\.\s*["']?agentradar["']?\s*\./.test(lines[end])) {
|
|
191
|
+
end++;
|
|
192
|
+
while (end < lines.length && !/^\s*\[/.test(lines[end]))
|
|
193
|
+
end++;
|
|
194
|
+
}
|
|
195
|
+
if (/^\s*url\s*=/m.test(lines.slice(i, end).join("\n"))) {
|
|
196
|
+
dropped = true;
|
|
197
|
+
i = end - 1;
|
|
198
|
+
}
|
|
199
|
+
else
|
|
200
|
+
out.push(lines[i]);
|
|
201
|
+
}
|
|
202
|
+
return dropped ? out.join("\n") : null;
|
|
203
|
+
}
|
|
204
|
+
/** Upgrade and sync: remove the old AgentRadar HTTP MCP entry an older `agentradar setup` or join wrote: Claude's project entry, the project .mcp.json and ~/.codex/config.toml. Silent unless it removes one. */
|
|
205
|
+
export async function dropStaleMcp(cwd, home, log = console.log, env = process.env) {
|
|
206
|
+
const said = (where) => log(`Removed the old AgentRadar MCP entry from ${where}: AgentRadar is CLI-only now, use agentradar run <runner-id> <tool>.`);
|
|
207
|
+
const configPath = claudeConfigPath(home, env);
|
|
208
|
+
try {
|
|
209
|
+
const projectPath = await realpath(cwd);
|
|
210
|
+
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
211
|
+
if (config.projects?.[projectPath]?.mcpServers?.agentradar?.type === "http") {
|
|
212
|
+
await installProjectMcp(configPath, cwd, null, false);
|
|
213
|
+
said(configPath);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch { /* no Claude config, or not a project it knows: nothing stale to remove */ }
|
|
217
|
+
const mcpJson = join(cwd, ".mcp.json");
|
|
218
|
+
try {
|
|
219
|
+
const parsed = JSON.parse(await readFile(mcpJson, "utf8"));
|
|
220
|
+
const entry = parsed.mcpServers?.agentradar;
|
|
221
|
+
if (entry && (entry.type === "http" || entry.url)) {
|
|
222
|
+
delete parsed.mcpServers.agentradar;
|
|
223
|
+
await writeFile(mcpJson, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
224
|
+
said(mcpJson);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch { /* no .mcp.json, or not JSON we can edit: leave it */ }
|
|
228
|
+
const codexToml = join(env.CODEX_HOME?.trim() || join(home, ".codex"), "config.toml");
|
|
229
|
+
try {
|
|
230
|
+
const stripped = stripCodexAgentradar(await readFile(codexToml, "utf8"));
|
|
231
|
+
if (stripped !== null) {
|
|
232
|
+
await writeFile(codexToml, stripped);
|
|
233
|
+
said(codexToml);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch { /* no Codex config: nothing stale to remove */ }
|
|
237
|
+
}
|
|
181
238
|
export function shellValue(value) {
|
|
182
239
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
183
240
|
}
|
|
@@ -392,7 +449,7 @@ async function installRunner(home, projectPath, approved, service, feed) {
|
|
|
392
449
|
const envPath = join(agentDir, `${approved.runnerId}.env`);
|
|
393
450
|
const joined = [];
|
|
394
451
|
if (service) {
|
|
395
|
-
joined.push(`
|
|
452
|
+
joined.push(`AR_AGENTRADAR_BIN=${shellValue(join(cliDist(home), "index.js"))}`);
|
|
396
453
|
let toolKeys = [];
|
|
397
454
|
if (harness === "codex") {
|
|
398
455
|
const { codexHome, keys } = await codexServiceHome(home, approved.runnerId, service.env);
|
|
@@ -425,6 +482,8 @@ async function installRunner(home, projectPath, approved, service, feed) {
|
|
|
425
482
|
await writePrivateFile(settings, `${JSON.stringify({
|
|
426
483
|
extraKnownMarketplaces: { agentradar: { source: { source: "directory", path: join(cliDist(home), "agent-guard") } } },
|
|
427
484
|
enabledPlugins: { "agent-guard@agentradar": true },
|
|
485
|
+
// Vadym 12201: AskUserQuestion blocks the turn on a pick nobody at the pane makes, stalling every channel.
|
|
486
|
+
permissions: { deny: ["AskUserQuestion"] },
|
|
428
487
|
// Nobody sits at the pane to accept bypass mode's warning screen; the operator's consent was given at approval.
|
|
429
488
|
...(service.bypass ? { skipDangerousModePermissionPrompt: true } : {}),
|
|
430
489
|
}, null, 2)}\n`);
|
|
@@ -450,21 +509,15 @@ async function installRunner(home, projectPath, approved, service, feed) {
|
|
|
450
509
|
// the service runs; the supervisor adds --dangerously-load-development-channels server:agentradar.
|
|
451
510
|
await writePrivateFile(mcpConfig, `${JSON.stringify(feed === "push"
|
|
452
511
|
? { mcpServers: { agentradar: { type: "stdio", command: process.execPath, args: [join(cliDist(home), "index.js"), "channel", approved.runnerId] } } }
|
|
453
|
-
:
|
|
454
|
-
mcpServers: {
|
|
455
|
-
agentradar: {
|
|
456
|
-
type: "http",
|
|
457
|
-
url: endpoint.toString(),
|
|
458
|
-
headers: { Authorization: `Bearer ${approved.installation.bearer}` },
|
|
459
|
-
},
|
|
460
|
-
},
|
|
461
|
-
}, null, 2)}\n`);
|
|
512
|
+
: { mcpServers: {} }, null, 2)}\n`);
|
|
462
513
|
await writePrivateFile(envPath, [
|
|
463
514
|
`AGENT_NAME=${shellValue(approved.runnerId)}`,
|
|
464
515
|
`AR_BASE=${shellValue(projectPath)}`,
|
|
465
516
|
`AWP_MCP_URL=${shellValue(endpoint.toString())}`,
|
|
466
517
|
`AWP_BEARER=${shellValue(approved.installation.bearer)}`,
|
|
467
518
|
`MCP_CONFIG=${shellValue(mcpConfig)}`,
|
|
519
|
+
// Every convention line in the brief and skills says `agentradar run $AR_CLI_RUNNER_ID <tool>`.
|
|
520
|
+
`AR_CLI_RUNNER_ID=${shellValue(approved.runnerId)}`,
|
|
468
521
|
// Written only when the vendor declared a runtime. An absent variable is the supervisor's own
|
|
469
522
|
// default, so writing `AR_AGENT_HARNESS=claude` unconditionally would change nothing except to make
|
|
470
523
|
// every existing env file look as though a choice had been made.
|
|
@@ -542,17 +595,17 @@ export async function setupVendorAgent(deps = {}) {
|
|
|
542
595
|
const runnerEnv = await installRunner(home, projectPath, approved);
|
|
543
596
|
// Print the path, not just "installed". D3 was a silent write to the wrong file under a
|
|
544
597
|
// relocated CLAUDE_CONFIG_DIR; naming the target turns that class of failure into a glance.
|
|
545
|
-
log(`AgentRadar
|
|
598
|
+
log(`AgentRadar runner set up for this project (no MCP entry; the agent acts through agentradar run) → ${configPath}`);
|
|
546
599
|
log(trust
|
|
547
600
|
? `--trust: pre-accepted the trust dialog for ${projectPath}. Claude Code will run this folder's existing hooks and settings without asking.`
|
|
548
|
-
: `Claude Code will ask you to trust ${projectPath} on first launch — approve it there. Not pre-accepted, because that would trust everything already in the folder, not just
|
|
601
|
+
: `Claude Code will ask you to trust ${projectPath} on first launch — approve it there. Not pre-accepted, because that would trust everything already in the folder, not just AgentRadar. Re-run with --trust for unattended onboarding.`);
|
|
549
602
|
log(`Runner installed → ${runnerEnv}`);
|
|
550
603
|
if ((approved.harness ?? "").trim().toLowerCase() === "codex") {
|
|
551
604
|
// Said BEFORE the vendor runs the runner, because the supervisor's refusal comes after they
|
|
552
605
|
// think setup succeeded. Codex reads its MCP servers from $CODEX_HOME/config.toml — there is no
|
|
553
606
|
// `--strict-mcp-config` to point at the file this setup just wrote — so the isolation boundary is
|
|
554
607
|
// a per-agent home the vendor supplies, and the supervisor fails closed without it.
|
|
555
|
-
log(`This agent runs on Codex. Set CODEX_HOME in ${runnerEnv} to a per-agent Codex home whose config.toml declares
|
|
608
|
+
log(`This agent runs on Codex. Set CODEX_HOME in ${runnerEnv} to a per-agent Codex home whose config.toml declares no MCP servers (the agent acts through the CLI); the runner refuses to start without it.`);
|
|
556
609
|
}
|
|
557
610
|
log(`Start it with: agentradar runner start ${approved.runnerId}`);
|
|
558
611
|
log("On macOS the runner uses your existing Claude Code Keychain-backed login. CLAUDE_CODE_OAUTH_TOKEN is ignored; ANTHROPIC_API_KEY is prohibited.");
|
package/dist/skill-install.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { chmod, lstat, mkdir, readFile,
|
|
1
|
+
import { chmod, lstat, mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { basename, dirname,
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { awpRequest, envValue } from "./tool-run.js";
|
|
5
5
|
const MANAGED_MARK = ".agentradar-managed";
|
|
6
|
-
const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,99}$/;
|
|
7
6
|
async function ensureDirectory(path) {
|
|
8
7
|
try {
|
|
9
8
|
const info = await lstat(path);
|
|
@@ -44,76 +43,10 @@ export async function installVendorSkill(deps = {}) {
|
|
|
44
43
|
await writeFile(join(skillDir, MANAGED_MARK), "Written by AgentRadar; replaced on every sync.\n");
|
|
45
44
|
(deps.log ?? console.log)("AgentRadar vendor skill installed for this project.");
|
|
46
45
|
}
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
* The feeder's `writeManagedSkills` (templates/vendor-runner/feeder.mjs), for the CLI: the server's answer is
|
|
50
|
-
* untrusted input to this box, so names and paths are checked, each skill is staged then renamed into place,
|
|
51
|
-
* a folder without the marker (the vendor's own) is never touched, and a managed skill no longer listed goes.
|
|
52
|
-
*/
|
|
53
|
-
const sameSkill = async (dir, files) => {
|
|
54
|
-
try {
|
|
55
|
-
const have = [];
|
|
56
|
-
for (const p of await readdir(dir, { recursive: true }))
|
|
57
|
-
if (p !== MANAGED_MARK && (await stat(join(dir, p))).isFile())
|
|
58
|
-
have.push(p);
|
|
59
|
-
for (const f of files)
|
|
60
|
-
if (await readFile(join(dir, f.path), "utf8") !== f.content)
|
|
61
|
-
return false;
|
|
62
|
-
return have.length === files.length;
|
|
63
|
-
}
|
|
64
|
-
catch {
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
};
|
|
46
|
+
const runnerSkills = () => import(new URL(import.meta.url.endsWith(".ts")
|
|
47
|
+
? "../../../templates/vendor-runner/feeder.mjs" : "./vendor-runner/feeder.mjs", import.meta.url).href);
|
|
68
48
|
export async function writeManagedSkills(skillsDir, skills) {
|
|
69
|
-
await
|
|
70
|
-
const written = new Set();
|
|
71
|
-
for (const skill of skills) {
|
|
72
|
-
const name = String(skill?.name ?? "");
|
|
73
|
-
const target = join(skillsDir, name);
|
|
74
|
-
if (!SKILL_NAME.test(name))
|
|
75
|
-
continue;
|
|
76
|
-
if (await lstat(target).then(() => true, () => false) && !(await isManaged(target)))
|
|
77
|
-
continue;
|
|
78
|
-
const files = (Array.isArray(skill.files) ? skill.files : []);
|
|
79
|
-
if (files.some((f) => typeof f?.path !== "string" || typeof f?.content !== "string" || isAbsolute(f.path)
|
|
80
|
-
|| normalize(f.path).split(sep).includes("..") || [".", MANAGED_MARK].includes(normalize(f.path))))
|
|
81
|
-
continue;
|
|
82
|
-
// An unchanged skill is not rewritten: the agent's CLI may be reading it.
|
|
83
|
-
if (await sameSkill(target, files)) {
|
|
84
|
-
written.add(name);
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
const staging = join(skillsDir, `.${name}.sync-${process.pid}`);
|
|
88
|
-
const old = join(skillsDir, `.${name}.old-${process.pid}`);
|
|
89
|
-
try {
|
|
90
|
-
await rm(staging, { recursive: true, force: true });
|
|
91
|
-
for (const f of files) {
|
|
92
|
-
await mkdir(dirname(join(staging, f.path)), { recursive: true });
|
|
93
|
-
await writeFile(join(staging, f.path), f.content);
|
|
94
|
-
}
|
|
95
|
-
await writeFile(join(staging, MANAGED_MARK), "Written by the AgentRadar runner; replaced on every sync.\n");
|
|
96
|
-
// Rename aside, then into place: the skill is missing for two renames, not for a recursive delete.
|
|
97
|
-
await rm(old, { recursive: true, force: true });
|
|
98
|
-
await rename(target, old).catch((err) => { if (err?.code !== "ENOENT")
|
|
99
|
-
throw err; });
|
|
100
|
-
await rename(staging, target);
|
|
101
|
-
await rm(old, { recursive: true, force: true });
|
|
102
|
-
written.add(name);
|
|
103
|
-
}
|
|
104
|
-
catch {
|
|
105
|
-
await rm(staging, { recursive: true, force: true });
|
|
106
|
-
if (!(await lstat(target).then(() => true, () => false)))
|
|
107
|
-
await rename(old, target).catch(() => { });
|
|
108
|
-
if (await isManaged(target))
|
|
109
|
-
written.add(name);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
for (const entry of await readdir(skillsDir)) {
|
|
113
|
-
if (!written.has(entry) && await isManaged(join(skillsDir, entry)))
|
|
114
|
-
await rm(join(skillsDir, entry), { recursive: true, force: true });
|
|
115
|
-
}
|
|
116
|
-
return [...written];
|
|
49
|
+
return (await runnerSkills()).writeManagedSkills(skillsDir, skills);
|
|
117
50
|
}
|
|
118
51
|
/**
|
|
119
52
|
* R650 — a joined agent's AgentRadar skills (`my_skills`: the bundled set plus the ones linked to it), written
|
|
@@ -160,11 +93,12 @@ export async function syncJoinedSkills(runnerId, deps) {
|
|
|
160
93
|
// Same file and mark as the runner's feeder, which keeps it fresh from then on.
|
|
161
94
|
const init = await awpRequest(runnerId, "initialize", {
|
|
162
95
|
protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "agentradar-cli", version: "1.0.0" },
|
|
163
|
-
}, { home: deps.home, fetch: deps.fetch, logError: deps.log });
|
|
96
|
+
}, { home: deps.home, fetch: deps.fetch, logError: deps.log, surface: "skill-sync" });
|
|
164
97
|
const briefFile = codex ? join(root, "AGENTS.md") : join(root, ".claude", "rules", "agentradar-brief.md");
|
|
165
98
|
if (root && init?.result?.instructions)
|
|
166
99
|
await writeBrief(briefFile, init.result.instructions);
|
|
167
|
-
const
|
|
100
|
+
const have = (await runnerSkills()).heldSkillZips(skillsDir);
|
|
101
|
+
const body = await awpRequest(runnerId, "tools/call", { name: "my_skills", arguments: { have } }, { home: deps.home, fetch: deps.fetch, logError: deps.log, surface: "skill-sync" });
|
|
168
102
|
const text = body?.result?.content?.find((c) => c.type === "text")?.text;
|
|
169
103
|
const answer = (text ? JSON.parse(text) : {});
|
|
170
104
|
if (!answer.ok || !Array.isArray(answer.skills))
|
package/dist/tool-run.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { commandFor, DEPRECATED_TOOLS, toolFor } from "./commands.js";
|
|
5
|
-
import { cliHeader,
|
|
5
|
+
import { cliHeader, noteCliUpdate, withCliUpdate } from "./update.js";
|
|
6
6
|
/**
|
|
7
7
|
* One `KEY='value'` line as `setup.ts` wrote it, single-quoted with `'` escaped as `'\''`.
|
|
8
8
|
* Nothing here is printed: the file holds the runner's bearer.
|
|
@@ -335,31 +335,9 @@ export async function callAwpTool(runnerId, name, toolArguments, deps) {
|
|
|
335
335
|
logError(said);
|
|
336
336
|
return 1;
|
|
337
337
|
}
|
|
338
|
-
log(said);
|
|
339
|
-
await cliVersionNotice(said, logError);
|
|
338
|
+
log(withCliUpdate(said));
|
|
340
339
|
return 0;
|
|
341
340
|
}
|
|
342
|
-
/**
|
|
343
|
-
* Vadym 9970: the server names the CLI it expects (`cli: { latest, min }`, on a successful get_my_context).
|
|
344
|
-
* When this one is behind, the agent reads it in its own output (stderr, so --json stdout stays parseable).
|
|
345
|
-
*/
|
|
346
|
-
async function cliVersionNotice(said, logError) {
|
|
347
|
-
let cli;
|
|
348
|
-
try {
|
|
349
|
-
cli = JSON.parse(said).cli;
|
|
350
|
-
}
|
|
351
|
-
catch {
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
if (!cli || typeof cli.latest !== "string")
|
|
355
|
-
return;
|
|
356
|
-
const mine = await runningVersion().catch(() => null);
|
|
357
|
-
if (!mine || !newer(cli.latest, mine))
|
|
358
|
-
return;
|
|
359
|
-
logError(typeof cli.min === "string" && newer(cli.min, mine)
|
|
360
|
-
? `agentradar ${mine} is below the minimum ${cli.min}: upgrade required. Run \`agentradar upgrade\`.`
|
|
361
|
-
: `agentradar ${cli.latest} is available (you run ${mine}). Run \`agentradar upgrade\`.`);
|
|
362
|
-
}
|
|
363
341
|
const RUNNER_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
364
342
|
/** One JSON-RPC request on the runner's AWP MCP endpoint with the runner's own key; null after logging why it failed. */
|
|
365
343
|
export async function awpRequest(runnerId, method, params, deps) {
|
|
@@ -407,7 +385,7 @@ async function postAwp(url, bearer, method, params, deps) {
|
|
|
407
385
|
const response = await (deps.fetch ?? fetch)(url, {
|
|
408
386
|
method: "POST",
|
|
409
387
|
headers: {
|
|
410
|
-
...(await cliHeader()),
|
|
388
|
+
...(await cliHeader(deps.surface)),
|
|
411
389
|
authorization: `Bearer ${bearer}`,
|
|
412
390
|
"content-type": "application/json",
|
|
413
391
|
// Both, because the endpoint answers JSON only when the client says it takes JSON.
|
|
@@ -420,6 +398,7 @@ async function postAwp(url, bearer, method, params, deps) {
|
|
|
420
398
|
params,
|
|
421
399
|
}),
|
|
422
400
|
});
|
|
401
|
+
await noteCliUpdate(response.headers, logError);
|
|
423
402
|
if (!response.ok) {
|
|
424
403
|
// 426: this CLI is below the server's minimum; its sentence says how to upgrade.
|
|
425
404
|
const said = response.status === 426 ? await response.json().then((body) => body.error?.message).catch(() => null) : null;
|
package/dist/update.js
CHANGED
|
@@ -16,8 +16,33 @@ export const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
|
16
16
|
const serviceCopy = (root) => basename(dirname(root)) === "cli" && basename(dirname(dirname(root))) === ".agentradar";
|
|
17
17
|
/** The version this process runs, whatever `current` points at now. */
|
|
18
18
|
export const runningVersion = async () => JSON.parse(await readFile(join(PACKAGE_ROOT, "package.json"), "utf8")).version;
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Sent as `x-agentradar-cli` on every MCP call, so the server can refuse a CLI below its minimum (self-release card 2),
|
|
21
|
+
* and as `x-agentradar-surface` so it can count which client a call came from (MCPR-1).
|
|
22
|
+
*/
|
|
23
|
+
export const cliHeader = async (surface = "cli") => ({ "x-agentradar-cli": await runningVersion().catch(() => ""), "x-agentradar-surface": surface });
|
|
24
|
+
let behind = null;
|
|
25
|
+
export const forgetCliUpdate = () => { behind = null; };
|
|
26
|
+
export async function noteCliUpdate(headers, logError = console.error) {
|
|
27
|
+
const latest = headers?.get("x-agentradar-cli-latest");
|
|
28
|
+
const current = latest && !behind ? await runningVersion().catch(() => "") : "";
|
|
29
|
+
if (!latest || !current || !newer(latest, current) || behind)
|
|
30
|
+
return;
|
|
31
|
+
behind = { current, latest, command: "agentradar upgrade" };
|
|
32
|
+
logError(`agentradar ${latest} is available (you run ${current}). Run \`agentradar upgrade\`.`);
|
|
33
|
+
}
|
|
34
|
+
/** A JSON-object output with `cliUpdate` added when this CLI is behind; anything else, or a current CLI, unchanged. */
|
|
35
|
+
export function withCliUpdate(text) {
|
|
36
|
+
if (!behind)
|
|
37
|
+
return text;
|
|
38
|
+
try {
|
|
39
|
+
const body = JSON.parse(text);
|
|
40
|
+
return body && typeof body === "object" && !Array.isArray(body) ? JSON.stringify({ ...body, cliUpdate: behind }, null, text.includes("\n") ? 2 : 0) : text;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return text;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
21
46
|
/** The version on disk: for a service's copy, the one `current` points at, which another runner may have moved. */
|
|
22
47
|
export const installedVersion = async (root = PACKAGE_ROOT) => JSON.parse(await readFile(join(serviceCopy(root) ? join(dirname(root), "current") : root, "package.json"), "utf8")).version;
|
|
23
48
|
export const newer = (a, b) => {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
/**
|
|
@@ -601,6 +636,15 @@ export function paneInputReady(text, harness = AR_AGENT_HARNESS, readyPattern =
|
|
|
601
636
|
: /bypass permissions on\b|^\s*❯/im.test(tail);
|
|
602
637
|
}
|
|
603
638
|
|
|
639
|
+
/**
|
|
640
|
+
* Vadym 12490: the permission mode Claude Code's footer names ("⏸ manual mode on"), or null when no footer shows.
|
|
641
|
+
* A hosted session must read "bypass permissions"; anything else is reported so the server can alert.
|
|
642
|
+
*/
|
|
643
|
+
export function panePermissionMode(text) {
|
|
644
|
+
const tail = String(text ?? "").replace(/\n\s*$/, "").split("\n").slice(-12).join("\n");
|
|
645
|
+
return /\b(manual mode|accept edits|auto-accept edits|plan mode|bypass permissions) on\b/i.exec(tail)?.[1].toLowerCase() ?? null;
|
|
646
|
+
}
|
|
647
|
+
|
|
604
648
|
/**
|
|
605
649
|
* Why a Claude Code session refused to register its channels, read off the banner it prints — or null.
|
|
606
650
|
* Keyed by the CLI's own skip `kind`. The `auth` banner comes from builds before 2.1.281, which has no auth
|
|
@@ -642,7 +686,7 @@ function capturePane() {
|
|
|
642
686
|
// build after several turns, which falsely demoted a healthy pane to `starting` and stopped next_event
|
|
643
687
|
// from draining until its heartbeat aged stale.
|
|
644
688
|
const inputReady = paneInputReady(text);
|
|
645
|
-
return { hash, inProgress, inputReady, rateLimited: paneRateLimited(text), channelGate: classifyChannelBanner(text) };
|
|
689
|
+
return { hash, inProgress, inputReady, rateLimited: paneRateLimited(text), channelGate: classifyChannelBanner(text), mode: panePermissionMode(text) };
|
|
646
690
|
}
|
|
647
691
|
/**
|
|
648
692
|
* #173b (disc obs) — format-drift detector. If the STRIPPED fingerprint keeps CHANGING while the pane
|
|
@@ -685,7 +729,10 @@ async function reportPane() {
|
|
|
685
729
|
_lastChannelGate = p.channelGate?.kind ?? null;
|
|
686
730
|
// #156: isolated connection (NOT the shared long-poll socket) so pane sampling never stalls behind
|
|
687
731
|
// the receive long-poll's ALB-killed socket.
|
|
688
|
-
|
|
732
|
+
// Vadym 12490: a hosted session always runs in full auto; a vendor's own runner may choose its mode.
|
|
733
|
+
const offMode = process.env.AR_CLI_RUNNER_ID === "hosted" && p.mode && p.mode !== "bypass permissions" ? p.mode : null;
|
|
734
|
+
if (offMode) console.warn(`[feeder] hosted session in ${offMode}, not full auto`);
|
|
735
|
+
await mcpPostIsolated("tools/call", { name: "report_pane", arguments: { paneHash: p.hash, inProgress: p.inProgress, ...(offMode ? { permissionMode: offMode } : {}) } });
|
|
689
736
|
if (process.env.DEBUG_PANE) {
|
|
690
737
|
console.log(`[feeder] report_pane #${n}: OK hash=${p.hash.slice(0, 8)} inProgress=${p.inProgress} (${Date.now() - t0}ms)`);
|
|
691
738
|
}
|
|
@@ -706,14 +753,49 @@ const isManaged = (dir) => {
|
|
|
706
753
|
const st = fs.lstatSync(dir, { throwIfNoEntry: false });
|
|
707
754
|
return Boolean(st?.isDirectory() && fs.existsSync(join(dir, MANAGED_MARK)));
|
|
708
755
|
};
|
|
756
|
+
// Same bytes and the same mode for every file, and nothing else in the directory.
|
|
709
757
|
const sameSkill = (dir, files) => {
|
|
710
758
|
try {
|
|
711
759
|
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)
|
|
760
|
+
return have.length === files.length && files.every((f) => fs.readFileSync(join(dir, f.path)).equals(f.bytes)
|
|
761
|
+
&& (fs.statSync(join(dir, f.path)).mode & 0o777) === f.mode);
|
|
713
762
|
} catch {
|
|
714
763
|
return false;
|
|
715
764
|
}
|
|
716
765
|
};
|
|
766
|
+
const markText = (sha) => `Written by the AgentRadar runner; replaced on every sync.\n${sha ? `sha256:${sha}\n` : ""}`;
|
|
767
|
+
const markedSha = (dir) => {
|
|
768
|
+
try {
|
|
769
|
+
return /^sha256:([0-9a-f]{64})$/m.exec(fs.readFileSync(join(dir, MANAGED_MARK), "utf8"))?.[1] ?? null;
|
|
770
|
+
} catch {
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
/** SKILL-ZIP-S3: what `my_skills` is told this box holds, so an unchanged zip is not sent again. */
|
|
776
|
+
export function heldSkillZips(skillsDir) {
|
|
777
|
+
const held = {};
|
|
778
|
+
for (const entry of fs.existsSync(skillsDir) ? fs.readdirSync(skillsDir) : []) {
|
|
779
|
+
const sha = SKILL_NAME.test(entry) && isManaged(join(skillsDir, entry)) ? markedSha(join(skillsDir, entry)) : null;
|
|
780
|
+
if (sha) held[entry] = sha;
|
|
781
|
+
}
|
|
782
|
+
return held;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Each skill comes as a zip (its bytes checked against its sha256 and unpacked by skill-zip.mjs, exec bits kept), as a
|
|
786
|
+
// zip's sha256 alone when this box already holds it, or, from an older server, as files of text.
|
|
787
|
+
function skillFiles(skill, target) {
|
|
788
|
+
const zip = skill.zip;
|
|
789
|
+
if (zip && typeof zip === "object") {
|
|
790
|
+
if (typeof zip.base64 === "string") return { sha: zip.sha256, files: unpackSkillZip(Buffer.from(zip.base64, "base64"), zip.sha256) };
|
|
791
|
+
if (isManaged(target) && markedSha(target) === zip.sha256) return "held";
|
|
792
|
+
throw new Error("the server sent no bytes for a zip this box does not hold");
|
|
793
|
+
}
|
|
794
|
+
const files = Array.isArray(skill.files) ? skill.files : [];
|
|
795
|
+
if (files.some((f) => typeof f?.path !== "string" || typeof f?.content !== "string" || isAbsolute(f.path)
|
|
796
|
+
|| normalize(f.path).split(sep).includes(".."))) return null;
|
|
797
|
+
return { sha: null, files: files.map((f) => ({ path: normalize(f.path), bytes: Buffer.from(f.content), mode: 0o644 })) };
|
|
798
|
+
}
|
|
717
799
|
|
|
718
800
|
export function writeManagedSkills(skillsDir, skills) {
|
|
719
801
|
fs.mkdirSync(skillsDir, { recursive: true });
|
|
@@ -726,22 +808,38 @@ export function writeManagedSkills(skillsDir, skills) {
|
|
|
726
808
|
console.warn(`[feeder] skill sync: ${name} is not ours, left alone`);
|
|
727
809
|
continue;
|
|
728
810
|
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
811
|
+
let got;
|
|
812
|
+
try {
|
|
813
|
+
got = skillFiles(skill, target);
|
|
814
|
+
} catch (err) {
|
|
815
|
+
// A zip that fails its check keeps the copy this box already has.
|
|
816
|
+
if (isManaged(target)) written.add(name);
|
|
817
|
+
console.warn(`[feeder] skill sync: ${name} not written: ${err instanceof Error ? err.message : String(err)}`);
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
if (got === "held") { written.add(name); continue; }
|
|
821
|
+
if (!got || got.files.some((f) => [".", MANAGED_MARK].includes(f.path))) {
|
|
822
|
+
console.warn(`[feeder] skill sync: ${name} has an unsafe file path, skipped`);
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
const { sha, files } = got;
|
|
733
826
|
// 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)) {
|
|
827
|
+
// periodic resync had just removed). Only its mark is brought up to date.
|
|
828
|
+
if (sameSkill(target, files)) {
|
|
829
|
+
if (markedSha(target) !== sha) fs.writeFileSync(join(target, MANAGED_MARK), markText(sha));
|
|
830
|
+
written.add(name);
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
736
833
|
const staging = join(skillsDir, `.${name}.sync-${process.pid}`);
|
|
737
834
|
const old = join(skillsDir, `.${name}.old-${process.pid}`);
|
|
738
835
|
try {
|
|
739
836
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
740
837
|
for (const f of files) {
|
|
741
838
|
fs.mkdirSync(dirname(join(staging, f.path)), { recursive: true });
|
|
742
|
-
fs.writeFileSync(join(staging, f.path), f.
|
|
839
|
+
fs.writeFileSync(join(staging, f.path), f.bytes, { mode: f.mode });
|
|
840
|
+
fs.chmodSync(join(staging, f.path), f.mode); // the umask must not take the exec bit back
|
|
743
841
|
}
|
|
744
|
-
fs.writeFileSync(join(staging, MANAGED_MARK),
|
|
842
|
+
fs.writeFileSync(join(staging, MANAGED_MARK), markText(sha));
|
|
745
843
|
// Rename aside, then into place: the skill is missing for two renames, not for a recursive delete.
|
|
746
844
|
fs.rmSync(old, { recursive: true, force: true });
|
|
747
845
|
if (fs.existsSync(target)) fs.renameSync(target, old);
|
|
@@ -775,7 +873,7 @@ async function syncSkills() {
|
|
|
775
873
|
console.warn(`[feeder] brief sync failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
776
874
|
}
|
|
777
875
|
try {
|
|
778
|
-
const answer = toolJson(await mcpPostIsolated("tools/call", { name: "my_skills", arguments: {} }));
|
|
876
|
+
const answer = toolJson(await mcpPostIsolated("tools/call", { name: "my_skills", arguments: { have: heldSkillZips(SKILLS_DIR) } }));
|
|
779
877
|
// A refusal or an older server keeps whatever this box already has.
|
|
780
878
|
if (!answer.ok || !Array.isArray(answer.skills)) {
|
|
781
879
|
console.warn(`[feeder] skill sync: server answered ${answer.error ?? "without skills"}; keeping current copies`);
|
|
@@ -969,7 +1067,7 @@ let _lastRunnerState = null;
|
|
|
969
1067
|
|
|
970
1068
|
/** One receive→inject→ack-select cycle (extracted from loop() + exported so the #173b _pendingAck
|
|
971
1069
|
* 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) {
|
|
1070
|
+
export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, deliverEvent = injectEvent, drive = driveModelSwitch, wait = waitForReady) {
|
|
973
1071
|
// next_event refreshes the portal's runner heartbeat. Never call it for a detached/orphan feeder: a
|
|
974
1072
|
// process without its owned tmux session cannot receive work, so polling would paint a false Live state
|
|
975
1073
|
// while every event stays unacknowledged. The supervisor recreates the session; until then the durable
|
|
@@ -995,7 +1093,10 @@ export async function pollOnce(paneLive = paneIsLive, readiness = runnerState, d
|
|
|
995
1093
|
const { events = [], livenessProbe, welcome, modelSwitch, firstStep } = await nextEvent(canWork && _pendingAck.length ? _pendingAck : undefined, state);
|
|
996
1094
|
_nextSwitch = modelSwitch ?? null;
|
|
997
1095
|
if (!canWork) {
|
|
998
|
-
|
|
1096
|
+
// Signed in but still booting: poll again the moment the pane is ready. Each check is a file stat and a pane
|
|
1097
|
+
// capture; the login probe stays held, so it does not spawn the CLI beside the booting session.
|
|
1098
|
+
if (state === "starting" && !_switching && authHeld()) await wait(readiness);
|
|
1099
|
+
else await sleep(IDLE_DELAY_MS);
|
|
999
1100
|
return;
|
|
1000
1101
|
}
|
|
1001
1102
|
_pendingAck = [];
|