agentradar-cli 0.2.4 → 0.2.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/agent-guard/scripts/guard.mjs +1 -0
- package/dist/agent-guard/skills/agentradar-discipline/SKILL.md +1 -0
- package/dist/agentradar-vendor/SKILL.md +6 -0
- package/dist/setup.js +5 -0
- package/dist/skill-install.js +97 -3
- package/dist/tool-run.js +1 -1
- package/dist/vendor-runner/feeder.mjs +17 -1
- package/dist/vendor-runner/terminal-bridge.mjs +87 -71
- package/package.json +1 -1
|
@@ -32,6 +32,7 @@ const RULES = [
|
|
|
32
32
|
"Answer every message in the channel it came from, and a human first: someone is waiting.",
|
|
33
33
|
`Acknowledge what you handled: ${call("acknowledge_messages")} --args '{"eventIds":[...]}' for messages, ${call("acknowledge_task")} --issueId <id> for a task you take on.`,
|
|
34
34
|
"Talk is not a task: reply conversationally, and create a task only for trackable, billable work.",
|
|
35
|
+
"Write so a 13-year-old would understand: short, plain words, one next step, no ids or tool names unless asked. Say it once.",
|
|
35
36
|
`Before you go idle, run ${call("agent_obligations")}: "owed" must be 0, and every in-progress task must be moving or waiting on someone who knows it.`,
|
|
36
37
|
];
|
|
37
38
|
const MUST_NOTS = [
|
|
@@ -24,6 +24,7 @@ The Stop hook runs the same check and will not let you stop while something is o
|
|
|
24
24
|
|
|
25
25
|
- Answer every message in the channel it came from. A human who wrote is waiting for you; answer them first.
|
|
26
26
|
- Talk is not a task. Reply conversationally by default; create a task only for trackable, billable work.
|
|
27
|
+
- Write to people so a 13-year-old would understand: a few short lines in plain words, ending with one next step (not a menu of options), no ids, tool names or internal terms unless asked. Say a thing once. Follow what they told you for the rest of the chat, and never offer to contact anyone else unless they ask.
|
|
27
28
|
- Never ask for, print or pass on a credential. AgentRadar holds the keys.
|
|
28
29
|
- When you ask another agent or a human something, call `expect_reply` on that thread with the time you need the answer by. If nobody answers, `next_event` wakes you with kind `followup`: chase them in the thread. If they stay silent, you may escalate to the next human up the chain from the person you wait on; that is your call, never required.
|
|
29
30
|
- Only a human accepts your work. Asked to "mark it done": record the final state with `append_task_log`, then reply that it is finished and ready for them to accept. Never call `decide_outcome` or `declare_outcome` on your own task.
|
|
@@ -20,8 +20,14 @@ You work on the vendor's computer and connect to AgentRadar through the `agentra
|
|
|
20
20
|
|
|
21
21
|
Call `get_my_context` on the AgentRadar MCP FIRST, before any other tool. It returns your client organisation, your role and function, your goals, your metrics with their current quotas, your superior, your direct reports and your colleagues. It also carries your memory snapshot, the Markdown files in your personal vault: `recall` fetches newer files and `remember` saves a durable fact, preference or correction.
|
|
22
22
|
|
|
23
|
+
In your first session, call `read_onboarding_obligations` once (reading them acknowledges the safety rules), then `read_readiness`.
|
|
24
|
+
|
|
23
25
|
Your primary objective is to reach your quotas and drive the organisation forward in your role. Report each metric you own with `report_metric`. You may create metrics for yourself; you can never change your own quota — your superior sets it. Talk only in shared spaces (channels and tasks). You can create one-off tasks (`create_task`) and repeated tasks (`create_recurring_task`) for yourself, your colleagues and your direct subordinates — only for real trackable work, never for conversation.
|
|
24
26
|
|
|
27
|
+
## Talking to people
|
|
28
|
+
|
|
29
|
+
Write to people so a 13-year-old would understand: a few short plain lines, then one next step, not a menu. No ids, tool names or internal terms unless asked (say "the owner", "your team", "company memory"; name a plan with its monthly price). Answer only what was asked, and say it once. Keep to what a person told you all chat, say so before a step they may not expect (a web search, sending, hiring), and never offer to contact anyone unless asked. Never talk about how messages reach you. Do the obvious thing for "undo that"; if unclear, offer short choices. Check your tools before saying what is scheduled or done; never answer from memory.
|
|
30
|
+
|
|
25
31
|
## Work loop
|
|
26
32
|
|
|
27
33
|
1. Use the AgentRadar MCP to receive the next event or assigned work.
|
package/dist/setup.js
CHANGED
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import * as tls from "node:tls";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
|
+
import { syncJoinedSkills } from "./skill-install.js";
|
|
10
11
|
const DEFAULT_API_URL = "https://vendor.agentradar.com/api";
|
|
11
12
|
/** The service's CLI build (the CLI itself and the agent-guard plugin): `installCli` keeps it current, never the npx cache. */
|
|
12
13
|
const cliDist = (home) => join(home, ".agentradar", "cli", "current", "dist");
|
|
@@ -604,6 +605,10 @@ export async function joinOrganisation(enrolmentUrl, deps = {}) {
|
|
|
604
605
|
const runnerEnv = await installRunner(home, projectPath, { status: "approved", agentId, runnerId, installation, harness }, { sessionId, env });
|
|
605
606
|
await saved?.();
|
|
606
607
|
log(`Runner installed → ${runnerEnv}`);
|
|
608
|
+
// Best effort: a failed first sync never stops the join; the service's feeder syncs them again.
|
|
609
|
+
await syncJoinedSkills(runnerId, { home, fetch: fetchImpl, log }).catch((error) => {
|
|
610
|
+
log(`Your AgentRadar skills are not installed yet (${String(error?.message ?? error).split("\n")[0]}); the runner installs them once it is running.`);
|
|
611
|
+
});
|
|
607
612
|
try {
|
|
608
613
|
await (deps.installService ?? ((id) => installAgentService(id, home, env)))(runnerId);
|
|
609
614
|
}
|
package/dist/skill-install.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { chmod, lstat, mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
1
|
+
import { chmod, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { dirname, isAbsolute, join, normalize, sep } from "node:path";
|
|
4
|
+
import { awpRequest, envValue } from "./tool-run.js";
|
|
5
|
+
const MANAGED_MARK = ".agentradar-managed";
|
|
6
|
+
const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,99}$/;
|
|
4
7
|
async function ensureDirectory(path) {
|
|
5
8
|
try {
|
|
6
9
|
const info = await lstat(path);
|
|
@@ -38,7 +41,98 @@ export async function installVendorSkill(deps = {}) {
|
|
|
38
41
|
});
|
|
39
42
|
}
|
|
40
43
|
// The runner keeps this skill current from AgentRadar; the marker says the folder is AgentRadar's to replace.
|
|
41
|
-
await writeFile(join(skillDir,
|
|
44
|
+
await writeFile(join(skillDir, MANAGED_MARK), "Written by AgentRadar; replaced on every sync.\n");
|
|
42
45
|
(deps.log ?? console.log)("AgentRadar vendor skill installed for this project.");
|
|
43
46
|
}
|
|
47
|
+
const isManaged = async (dir) => (await lstat(dir).then((info) => info.isDirectory(), () => false)) && (await lstat(join(dir, MANAGED_MARK)).then(() => true, () => false));
|
|
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
|
+
};
|
|
68
|
+
export async function writeManagedSkills(skillsDir, skills) {
|
|
69
|
+
await mkdir(skillsDir, { recursive: true });
|
|
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];
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* R650 — a joined agent's AgentRadar skills (`my_skills`: the bundled set plus the ones linked to it), written
|
|
120
|
+
* at join into the directory its CLI reads, the feeder's `skillsDirFor`: Claude `$AR_BASE/.claude/skills`,
|
|
121
|
+
* Codex `$CODEX_HOME/skills`. Done here because a session that acts only through `agentradar run` has no
|
|
122
|
+
* feeder; where the service runs, its feeder keeps them current.
|
|
123
|
+
*/
|
|
124
|
+
export async function syncJoinedSkills(runnerId, deps) {
|
|
125
|
+
const env = await readFile(join(deps.home, ".agentradar", "agents", `${runnerId}.env`), "utf8");
|
|
126
|
+
const root = envValue(env, "AR_AGENT_HARNESS") === "codex" ? envValue(env, "CODEX_HOME") : envValue(env, "AR_BASE");
|
|
127
|
+
if (!root)
|
|
128
|
+
return;
|
|
129
|
+
const skillsDir = envValue(env, "AR_AGENT_HARNESS") === "codex" ? join(root, "skills") : join(root, ".claude", "skills");
|
|
130
|
+
const body = await awpRequest(runnerId, "tools/call", { name: "my_skills", arguments: {} }, { home: deps.home, fetch: deps.fetch, logError: deps.log });
|
|
131
|
+
const text = body?.result?.content?.find((c) => c.type === "text")?.text;
|
|
132
|
+
const answer = (text ? JSON.parse(text) : {});
|
|
133
|
+
if (!answer.ok || !Array.isArray(answer.skills))
|
|
134
|
+
throw new Error("AgentRadar did not return your skills");
|
|
135
|
+
const written = await writeManagedSkills(skillsDir, answer.skills);
|
|
136
|
+
deps.log(`Installed ${written.length} AgentRadar skill(s) in ${skillsDir}.`);
|
|
137
|
+
}
|
|
44
138
|
//# sourceMappingURL=skill-install.js.map
|
package/dist/tool-run.js
CHANGED
|
@@ -203,7 +203,7 @@ export async function callAwpTool(runnerId, name, toolArguments, deps) {
|
|
|
203
203
|
}
|
|
204
204
|
const RUNNER_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
205
205
|
/** One JSON-RPC request on the runner's AWP MCP endpoint with the runner's own key; null after logging why it failed. */
|
|
206
|
-
async function awpRequest(runnerId, method, params, deps) {
|
|
206
|
+
export async function awpRequest(runnerId, method, params, deps) {
|
|
207
207
|
const logError = deps.logError ?? console.error;
|
|
208
208
|
if (!RUNNER_ID.test(runnerId)) {
|
|
209
209
|
logError("Invalid runner ID");
|
|
@@ -663,6 +663,14 @@ const isManaged = (dir) => {
|
|
|
663
663
|
const st = fs.lstatSync(dir, { throwIfNoEntry: false });
|
|
664
664
|
return Boolean(st?.isDirectory() && fs.existsSync(join(dir, MANAGED_MARK)));
|
|
665
665
|
};
|
|
666
|
+
const sameSkill = (dir, files) => {
|
|
667
|
+
try {
|
|
668
|
+
const have = fs.readdirSync(dir, { recursive: true }).filter((p) => p !== MANAGED_MARK && fs.statSync(join(dir, p)).isFile());
|
|
669
|
+
return have.length === files.length && files.every((f) => fs.readFileSync(join(dir, f.path), "utf8") === f.content);
|
|
670
|
+
} catch {
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
};
|
|
666
674
|
|
|
667
675
|
export function writeManagedSkills(skillsDir, skills) {
|
|
668
676
|
fs.mkdirSync(skillsDir, { recursive: true });
|
|
@@ -679,7 +687,11 @@ export function writeManagedSkills(skillsDir, skills) {
|
|
|
679
687
|
const bad = files.find((f) => typeof f?.path !== "string" || typeof f?.content !== "string" || isAbsolute(f.path)
|
|
680
688
|
|| normalize(f.path).split(sep).includes("..") || [".", MANAGED_MARK].includes(normalize(f.path)));
|
|
681
689
|
if (bad) { console.warn(`[feeder] skill sync: ${name} has an unsafe file path, skipped`); continue; }
|
|
690
|
+
// An unchanged skill is not rewritten: the agent's CLI may be reading it (Codex skipped a SKILL.md that a
|
|
691
|
+
// periodic resync had just removed).
|
|
692
|
+
if (sameSkill(target, files)) { written.add(name); continue; }
|
|
682
693
|
const staging = join(skillsDir, `.${name}.sync-${process.pid}`);
|
|
694
|
+
const old = join(skillsDir, `.${name}.old-${process.pid}`);
|
|
683
695
|
try {
|
|
684
696
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
685
697
|
for (const f of files) {
|
|
@@ -687,12 +699,16 @@ export function writeManagedSkills(skillsDir, skills) {
|
|
|
687
699
|
fs.writeFileSync(join(staging, f.path), f.content);
|
|
688
700
|
}
|
|
689
701
|
fs.writeFileSync(join(staging, MANAGED_MARK), "Written by the AgentRadar runner; replaced on every sync.\n");
|
|
690
|
-
|
|
702
|
+
// Rename aside, then into place: the skill is missing for two renames, not for a recursive delete.
|
|
703
|
+
fs.rmSync(old, { recursive: true, force: true });
|
|
704
|
+
if (fs.existsSync(target)) fs.renameSync(target, old);
|
|
691
705
|
fs.renameSync(staging, target);
|
|
706
|
+
fs.rmSync(old, { recursive: true, force: true });
|
|
692
707
|
written.add(name);
|
|
693
708
|
} catch (err) {
|
|
694
709
|
// One skill that cannot be written keeps its previous copy and does not stop the rest of the sync.
|
|
695
710
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
711
|
+
if (!fs.existsSync(target) && fs.existsSync(old)) fs.renameSync(old, target);
|
|
696
712
|
if (isManaged(target)) written.add(name);
|
|
697
713
|
console.warn(`[feeder] skill sync: ${name} not written: ${err instanceof Error ? err.message : String(err)}`);
|
|
698
714
|
}
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Opens a persistent OUTBOUND WebSocket to the AgentRadar server (the mini INITIATES; the server never
|
|
4
4
|
// connects inbound — phone-home preserved) and bridges the agent's tmux pane:
|
|
5
|
-
// - server {attach} →
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// -
|
|
5
|
+
// - server {attach} → attach a read-only tmux CONTROL-MODE client (`tmux -C attach -r`) → stream the pane's
|
|
6
|
+
// `%output` as {output} frames, after a scrollback snapshot (`capture-pane -S -2000`) so a
|
|
7
|
+
// late viewer can scroll back and lands on the current screen.
|
|
8
|
+
// - server {detach} → kill the control client (idle agents cost nothing).
|
|
9
|
+
// - pane GEOMETRY → {hello cols,rows} on connect + {geometry cols,rows} on each `%layout-change` (#9).
|
|
9
10
|
// The server has no other way to know the pane size, and without it the client
|
|
10
11
|
// watch-terminal renders every agent at a hardcoded 120×30.
|
|
11
12
|
// - server {input} → `tmux send-keys -l` (VENDOR keystrokes; the server already dropped client input).
|
|
@@ -26,22 +27,20 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
26
27
|
import fs from "node:fs";
|
|
27
28
|
import http from "node:http";
|
|
28
29
|
import https from "node:https";
|
|
29
|
-
import
|
|
30
|
-
import path from "node:path";
|
|
30
|
+
import { StringDecoder } from "node:string_decoder";
|
|
31
31
|
|
|
32
32
|
const AWP_MCP_ENDPOINT = process.env.AWP_MCP_URL ?? "https://staging.agentradar.com/api/mcp";
|
|
33
|
-
|
|
33
|
+
// Re-read on every connect: a hosted supervisor rewrites the file when it refreshes the run token.
|
|
34
|
+
export function readBearer() {
|
|
34
35
|
const f = process.env.AWP_BEARER_FILE;
|
|
35
|
-
if (f)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
36
|
+
if (!f) return process.env.AWP_BEARER ?? "";
|
|
37
|
+
try {
|
|
38
|
+
return fs.readFileSync(f, "utf8").trim();
|
|
39
|
+
} catch (e) {
|
|
40
|
+
console.error(`[bridge] AWP_BEARER_FILE unreadable (${f}): ${e.message}`);
|
|
41
|
+
return "";
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
})();
|
|
43
|
+
}
|
|
45
44
|
const TMUX_SESSION = process.env.TMUX_SESSION ?? "agent";
|
|
46
45
|
// Resolve the tmux binary robustly: a bare "tmux" only works if it's on PATH, but the bridge is often
|
|
47
46
|
// launched from a supervisor / LaunchAgent whose PATH excludes e.g. /opt/homebrew/bin — then every tmux
|
|
@@ -67,11 +66,7 @@ const RECONNECT_MAX_MS = Number(process.env.BRIDGE_RECONNECT_MAX_MS ?? "15000");
|
|
|
67
66
|
// force-closed it — i.e. a ~5 min live-terminal blackout on EVERY deploy. Watch for ping silence ourselves so
|
|
68
67
|
// recovery is ours, not the load balancer's.
|
|
69
68
|
const SERVER_PING_TIMEOUT_MS = Number(process.env.BRIDGE_PING_TIMEOUT_MS ?? "90000"); // 3× the 30s server ping
|
|
70
|
-
|
|
71
|
-
// only way to notice a pane change is to ask. The poll runs only between attach and detach, so an unwatched
|
|
72
|
-
// agent still costs nothing, and a frame is sent only when the size actually CHANGED — an unchanged read is
|
|
73
|
-
// one cheap tmux call and no traffic.
|
|
74
|
-
const GEOMETRY_POLL_MS = Number(process.env.BRIDGE_GEOMETRY_POLL_MS ?? "5000");
|
|
69
|
+
const SNAPSHOT_LINES = 2000;
|
|
75
70
|
|
|
76
71
|
/**
|
|
77
72
|
* #18: run `fn` at most once, however many times the returned function is called.
|
|
@@ -206,12 +201,37 @@ export function parsePaneGeometry(stdout) {
|
|
|
206
201
|
return { cols, rows };
|
|
207
202
|
}
|
|
208
203
|
|
|
204
|
+
/**
|
|
205
|
+
* One line of tmux control-mode output → `{ type: "output", pane, data: Buffer }`, `{ type: "layout" }`,
|
|
206
|
+
* `{ type: "exit" }`, or null for everything else. `%output` data has bytes below 0x20 and `\` octal-escaped
|
|
207
|
+
* (`\015`); other bytes, UTF-8 included, pass through raw, so the line is handled as bytes (latin1), never text.
|
|
208
|
+
*/
|
|
209
|
+
export function parseControlLine(line) {
|
|
210
|
+
const s = Buffer.isBuffer(line) ? line.toString("latin1") : line;
|
|
211
|
+
if (s.startsWith("%output ")) {
|
|
212
|
+
const sp = s.indexOf(" ", 8);
|
|
213
|
+
if (sp < 0) return null;
|
|
214
|
+
const data = s.slice(sp + 1).replace(/\\([0-7]{3})/g, (_, o) => String.fromCharCode(parseInt(o, 8)));
|
|
215
|
+
return { type: "output", pane: s.slice(8, sp), data: Buffer.from(data, "latin1") };
|
|
216
|
+
}
|
|
217
|
+
if (s.startsWith("%layout-change ")) return { type: "layout" };
|
|
218
|
+
if (s.startsWith("%exit")) return { type: "exit" };
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The scrollback snapshot as terminal text: history + visible screen, then the cursor put back where the pane
|
|
224
|
+
* has it, so live output that follows (relative cursor moves) lands in the right place.
|
|
225
|
+
*/
|
|
226
|
+
export function snapshotText(captured, cursor) {
|
|
227
|
+
const body = captured.replace(/\n$/, "");
|
|
228
|
+
return cursor ? `${body}\x1b[${cursor.y + 1};${cursor.x + 1}H` : body;
|
|
229
|
+
}
|
|
230
|
+
|
|
209
231
|
class TerminalBridge {
|
|
210
232
|
constructor() {
|
|
211
233
|
this.sock = null;
|
|
212
|
-
this.
|
|
213
|
-
this.fifoPath = null;
|
|
214
|
-
this.fifoStream = null;
|
|
234
|
+
this.control = null; // the read-only tmux control-mode client while a viewer is attached
|
|
215
235
|
this.recvBuf = Buffer.alloc(0);
|
|
216
236
|
this.backoff = RECONNECT_MIN_MS;
|
|
217
237
|
this.pingTimer = null;
|
|
@@ -219,7 +239,6 @@ class TerminalBridge {
|
|
|
219
239
|
// Reset to null on every (re)connect — a reconnect may land on a different server task with no memory
|
|
220
240
|
// of this session, so the new peer must be told even if the pane never moved.
|
|
221
241
|
this.geometry = null;
|
|
222
|
-
this.geometryTimer = null;
|
|
223
242
|
}
|
|
224
243
|
|
|
225
244
|
/**
|
|
@@ -275,57 +294,52 @@ class TerminalBridge {
|
|
|
275
294
|
this.send({ type: "geometry", cols: g.cols, rows: g.rows });
|
|
276
295
|
}
|
|
277
296
|
|
|
278
|
-
startGeometryPoll() {
|
|
279
|
-
if (this.geometryTimer) return;
|
|
280
|
-
this.geometryTimer = setInterval(() => this.reportGeometry(), GEOMETRY_POLL_MS);
|
|
281
|
-
if (typeof this.geometryTimer.unref === "function") this.geometryTimer.unref();
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
stopGeometryPoll() {
|
|
285
|
-
if (!this.geometryTimer) return;
|
|
286
|
-
clearInterval(this.geometryTimer);
|
|
287
|
-
this.geometryTimer = null;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
297
|
startPipe() {
|
|
291
|
-
if (this.
|
|
292
|
-
this.piping = true;
|
|
298
|
+
if (this.control) return;
|
|
293
299
|
// #9: a viewer just attached — publish the real pane size before the first bytes, so the client sizes
|
|
294
300
|
// its grid correctly rather than painting the replay into a fallback grid and re-flowing a moment later.
|
|
295
301
|
this.reportGeometry(true);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const cap = tmux(["capture-pane", "-
|
|
299
|
-
if (cap.status === 0 && cap.stdout)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
302
|
+
const pane = tmux(["display-message", "-p", "-t", TMUX_SESSION, "#{pane_id} #{cursor_x} #{cursor_y}"]);
|
|
303
|
+
const [paneId, cx, cy] = pane.status === 0 ? pane.stdout.trim().split(" ") : [];
|
|
304
|
+
const cap = tmux(["capture-pane", "-p", "-e", "-J", "-S", `-${SNAPSHOT_LINES}`, "-t", TMUX_SESSION]);
|
|
305
|
+
if (cap.status === 0 && cap.stdout) {
|
|
306
|
+
const cursor = cx !== undefined && cy !== undefined ? { x: Number(cx), y: Number(cy) } : null;
|
|
307
|
+
this.send({ type: "output", data: snapshotText(cap.stdout, cursor) });
|
|
308
|
+
}
|
|
309
|
+
// Read-only + ignore-size: the bridge can neither type into nor shrink the vendor's pane.
|
|
310
|
+
const child = spawn(TMUX_BIN, ["-C", "attach-session", "-r", "-t", TMUX_SESSION], {
|
|
311
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
312
|
+
});
|
|
313
|
+
this.control = child;
|
|
314
|
+
const decoder = new StringDecoder("utf8"); // a UTF-8 character can straddle two %output lines
|
|
315
|
+
let buf = Buffer.alloc(0);
|
|
316
|
+
child.stdout.on("data", (chunk) => {
|
|
317
|
+
buf = Buffer.concat([buf, chunk]);
|
|
318
|
+
let nl;
|
|
319
|
+
while ((nl = buf.indexOf(0x0a)) >= 0) {
|
|
320
|
+
const msg = parseControlLine(buf.subarray(0, nl));
|
|
321
|
+
buf = buf.subarray(nl + 1);
|
|
322
|
+
if (msg?.type === "output" && (!paneId || msg.pane === paneId)) {
|
|
323
|
+
const text = decoder.write(msg.data);
|
|
324
|
+
if (text) this.send({ type: "output", data: text });
|
|
325
|
+
} else if (msg?.type === "layout") this.reportGeometry();
|
|
326
|
+
else if (msg?.type === "exit") this.stopPipe();
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
child.on("error", () => {});
|
|
330
|
+
child.on("exit", () => {
|
|
331
|
+
if (this.control === child) this.control = null;
|
|
332
|
+
});
|
|
307
333
|
}
|
|
308
334
|
|
|
309
335
|
stopPipe() {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
this.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
} catch {
|
|
318
|
-
/* ignore */
|
|
319
|
-
}
|
|
320
|
-
this.fifoStream = null;
|
|
321
|
-
}
|
|
322
|
-
if (this.fifoPath) {
|
|
323
|
-
try {
|
|
324
|
-
fs.unlinkSync(this.fifoPath);
|
|
325
|
-
} catch {
|
|
326
|
-
/* ignore */
|
|
327
|
-
}
|
|
328
|
-
this.fifoPath = null;
|
|
336
|
+
const child = this.control;
|
|
337
|
+
if (!child) return;
|
|
338
|
+
this.control = null;
|
|
339
|
+
try {
|
|
340
|
+
child.kill();
|
|
341
|
+
} catch {
|
|
342
|
+
/* already gone */
|
|
329
343
|
}
|
|
330
344
|
}
|
|
331
345
|
|
|
@@ -355,6 +369,8 @@ class TerminalBridge {
|
|
|
355
369
|
}
|
|
356
370
|
|
|
357
371
|
connect() {
|
|
372
|
+
const bearer = readBearer();
|
|
373
|
+
if (!bearer) return this.scheduleReconnect();
|
|
358
374
|
const url = feederWsUrl();
|
|
359
375
|
const key = randomBytes(16).toString("base64");
|
|
360
376
|
const lib = url.protocol === "wss:" ? https : http;
|
|
@@ -369,7 +385,7 @@ class TerminalBridge {
|
|
|
369
385
|
"Sec-WebSocket-Version": "13",
|
|
370
386
|
"Sec-WebSocket-Key": key,
|
|
371
387
|
// Auth rides the subprotocol (never a query param); NEVER logged.
|
|
372
|
-
"Sec-WebSocket-Protocol": `agentradar-feeder, ${
|
|
388
|
+
"Sec-WebSocket-Protocol": `agentradar-feeder, ${bearer}`,
|
|
373
389
|
},
|
|
374
390
|
});
|
|
375
391
|
req.on("upgrade", (res, socket) => {
|
|
@@ -459,7 +475,7 @@ class TerminalBridge {
|
|
|
459
475
|
|
|
460
476
|
// Only auto-run when executed directly (not when imported by the unit test).
|
|
461
477
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
462
|
-
if (!
|
|
478
|
+
if (!readBearer()) {
|
|
463
479
|
console.error("[bridge] FATAL: no AWP bearer (set AWP_BEARER_FILE or AWP_BEARER)");
|
|
464
480
|
process.exit(1);
|
|
465
481
|
}
|