relayrun 0.1.0
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/README.md +99 -0
- package/dist/agent/diff.js +75 -0
- package/dist/agent/mock.js +175 -0
- package/dist/agent/orient.js +51 -0
- package/dist/agent/run.js +129 -0
- package/dist/agent/summarize.js +151 -0
- package/dist/cli.js +192 -0
- package/dist/emit.js +1 -0
- package/dist/redact.js +12 -0
- package/dist/repo.js +224 -0
- package/dist/runner.js +201 -0
- package/dist/sessionKey.js +108 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# relayrun
|
|
2
|
+
|
|
3
|
+
Watch a Claude agent work on a real codebase — together, live, with exactly one
|
|
4
|
+
person holding the wheel.
|
|
5
|
+
|
|
6
|
+
`relayrun` runs the agent **on your own machine**, against your own repository, on
|
|
7
|
+
your own credentials. It connects out to a coordination server and prints a link.
|
|
8
|
+
Anyone who opens that link watches the same session stream in the same moment.
|
|
9
|
+
|
|
10
|
+
Part of [Relay](https://github.com/Avaya02/Relay). The agent engine is the
|
|
11
|
+
[Claude Agent SDK](https://docs.claude.com/en/api/agent-sdk/overview) — the same
|
|
12
|
+
one Claude Code runs.
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
cd your-project
|
|
18
|
+
npx relayrun
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That's it. You'll get:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
repo your-project (/Users/you/your-project)
|
|
25
|
+
agent claude code login
|
|
26
|
+
session 8M2zrrx9Ng
|
|
27
|
+
|
|
28
|
+
Share this link:
|
|
29
|
+
https://relay-web-green.vercel.app/session/8M2zrrx9Ng
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Send the link to anyone. They watch the agent work — every tool call, every
|
|
33
|
+
diff, every failure — as it happens.
|
|
34
|
+
|
|
35
|
+
## Why exactly one driver
|
|
36
|
+
|
|
37
|
+
Multiplayer plus an agent that writes files is a concurrency problem. Relay
|
|
38
|
+
avoids it rather than solving it: **only one participant can send instructions**,
|
|
39
|
+
and the lock is enforced on the server, not in the UI. Everyone else watches, and
|
|
40
|
+
can propose an instruction that the driver chooses to send or dismiss.
|
|
41
|
+
|
|
42
|
+
Control moves by explicit hand-over. A driver who disconnects frees the wheel
|
|
43
|
+
rather than wedging the session.
|
|
44
|
+
|
|
45
|
+
## Options
|
|
46
|
+
|
|
47
|
+
| Flag | |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `--repo <path>` | Repository to work in (default: current directory) |
|
|
50
|
+
| `--api-key <key>` | Bill runs to this key instead of your Claude Code login |
|
|
51
|
+
| `--mock` | Scripted offline agent — see below |
|
|
52
|
+
| `--session <id>` | Reattach to an existing session (needs `--token`) |
|
|
53
|
+
| `--token <tok>` | Runner token for `--session` |
|
|
54
|
+
| `--github-repo <o/n>` | Open a PR here on publish |
|
|
55
|
+
| `--github-token <tok>` | Token for `--github-repo` |
|
|
56
|
+
| `--server <url>` | Coordination server to use |
|
|
57
|
+
| `--web <url>` | Web app, for the printed link |
|
|
58
|
+
| `-h, --help` | Show usage |
|
|
59
|
+
|
|
60
|
+
`--mock` replays a fixed script and **ignores what you type**. It's free and
|
|
61
|
+
offline, and it exists for developing the UI — not for real answers.
|
|
62
|
+
|
|
63
|
+
## Billing
|
|
64
|
+
|
|
65
|
+
By default runs are billed to your existing Claude Code login. Pass `--api-key`
|
|
66
|
+
to bill an Anthropic API key instead; it's verified before the session opens, so
|
|
67
|
+
a bad key fails immediately rather than mid-run.
|
|
68
|
+
|
|
69
|
+
Either way, the credential stays on your machine. It is never sent to the
|
|
70
|
+
coordination server.
|
|
71
|
+
|
|
72
|
+
## What leaves your machine
|
|
73
|
+
|
|
74
|
+
The agent works in a **disposable clone**, not your live checkout, so an agent
|
|
75
|
+
mistake can't touch uncommitted work.
|
|
76
|
+
|
|
77
|
+
Sent to the server: the transcript viewers need — instructions, tool calls,
|
|
78
|
+
results, diffs, and status. Not sent: your credentials, your working tree, or
|
|
79
|
+
anything the agent didn't emit as part of a run.
|
|
80
|
+
|
|
81
|
+
The connection is outbound only. Nothing listens, so there are no ports to open.
|
|
82
|
+
|
|
83
|
+
## Reattaching
|
|
84
|
+
|
|
85
|
+
If the CLI stops, the session survives. Restart with the token it printed:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
relayrun --session 8M2zrrx9Ng --token <runner-token>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Requirements
|
|
92
|
+
|
|
93
|
+
- Node.js >= 20.9.0
|
|
94
|
+
- `git`, and a repository to run in
|
|
95
|
+
- A Claude Code login or an Anthropic API key (unless using `--mock`)
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { planItems } from "./summarize.js";
|
|
2
|
+
/**
|
|
3
|
+
* Line-level diff for the expandable detail on an edit row.
|
|
4
|
+
*
|
|
5
|
+
* Hand-written rather than pulled from a package: the inputs are one Edit's
|
|
6
|
+
* old/new strings, and this keeps a diff library out of the browser bundle —
|
|
7
|
+
* the ledger receives finished lines, not two blobs to diff client-side.
|
|
8
|
+
*/
|
|
9
|
+
export function lineDiff(oldStr, newStr) {
|
|
10
|
+
const a = oldStr.split("\n");
|
|
11
|
+
const b = newStr.split("\n");
|
|
12
|
+
// Guard against a pathological LCS table on a huge Write. Above this, show
|
|
13
|
+
// the change wholesale rather than hanging the run.
|
|
14
|
+
if (a.length * b.length > 40_000) {
|
|
15
|
+
return [
|
|
16
|
+
...a.map((text) => ({ op: "-", text })),
|
|
17
|
+
...b.map((text) => ({ op: "+", text })),
|
|
18
|
+
];
|
|
19
|
+
}
|
|
20
|
+
const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
21
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
22
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
23
|
+
dp[i][j] =
|
|
24
|
+
a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const out = [];
|
|
28
|
+
let i = 0;
|
|
29
|
+
let j = 0;
|
|
30
|
+
while (i < a.length && j < b.length) {
|
|
31
|
+
if (a[i] === b[j]) {
|
|
32
|
+
out.push({ op: " ", text: a[i] });
|
|
33
|
+
i++;
|
|
34
|
+
j++;
|
|
35
|
+
}
|
|
36
|
+
else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
37
|
+
out.push({ op: "-", text: a[i++] });
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
out.push({ op: "+", text: b[j++] });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
while (i < a.length)
|
|
44
|
+
out.push({ op: "-", text: a[i++] });
|
|
45
|
+
while (j < b.length)
|
|
46
|
+
out.push({ op: "+", text: b[j++] });
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
// What, if anything, is worth showing when a row is expanded. Only edits get a
|
|
50
|
+
// diff — a Read's contents are the agent's business, not the watcher's.
|
|
51
|
+
export function detailForCall(tool, input, path) {
|
|
52
|
+
if (tool === "Edit" &&
|
|
53
|
+
typeof input.old_string === "string" &&
|
|
54
|
+
typeof input.new_string === "string") {
|
|
55
|
+
return { type: "diff", path, lines: lineDiff(input.old_string, input.new_string) };
|
|
56
|
+
}
|
|
57
|
+
if (tool === "Write" && typeof input.content === "string") {
|
|
58
|
+
// A new file is all additions — that IS the diff, and it reads correctly in
|
|
59
|
+
// the same renderer.
|
|
60
|
+
return {
|
|
61
|
+
type: "diff",
|
|
62
|
+
path,
|
|
63
|
+
lines: input.content.split("\n").map((text) => ({ op: "+", text })),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (tool === "TodoWrite") {
|
|
67
|
+
// The whole checklist rides along on the event. The client takes the newest
|
|
68
|
+
// one as the session's current plan, which is what makes it survive replay
|
|
69
|
+
// and late joins with no separate plan message and no server-side state.
|
|
70
|
+
const todos = planItems(input.todos);
|
|
71
|
+
if (todos.length)
|
|
72
|
+
return { type: "plan", todos };
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { appendFile, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const STEP_DELAY_MS = 700;
|
|
4
|
+
const INITIAL_DELAY_MS = 400;
|
|
5
|
+
function textStep(text) {
|
|
6
|
+
return (emit) => emit.event("agent_text", { text });
|
|
7
|
+
}
|
|
8
|
+
function toolCallStep(id, tool, verb, target, detail) {
|
|
9
|
+
return (emit) => emit.event("tool_call", { id, tool, verb, target, detail });
|
|
10
|
+
}
|
|
11
|
+
function toolResultStep(id, tool, ok, summary, detail) {
|
|
12
|
+
return (emit) => emit.event("tool_result", { id, tool, ok, summary, detail });
|
|
13
|
+
}
|
|
14
|
+
// The mock has to exercise the plan strip too, for the same reason it has to
|
|
15
|
+
// actually write files: a surface that only appears under the real agent can
|
|
16
|
+
// only be tested by spending API quota.
|
|
17
|
+
function planStep(id, done, active, steps) {
|
|
18
|
+
const todos = steps.map((content, i) => ({
|
|
19
|
+
content,
|
|
20
|
+
status: i < done ? "completed" : i === active ? "in_progress" : "pending",
|
|
21
|
+
activeForm: content.replace(/^[A-Z]/, (c) => c.toLowerCase()),
|
|
22
|
+
}));
|
|
23
|
+
const activeItem = todos.find((t) => t.status === "in_progress");
|
|
24
|
+
const target = activeItem
|
|
25
|
+
? (activeItem.activeForm ?? activeItem.content)
|
|
26
|
+
: done === steps.length
|
|
27
|
+
? "all steps complete"
|
|
28
|
+
: `${steps.length} steps`;
|
|
29
|
+
return [
|
|
30
|
+
toolCallStep(id, "TodoWrite", "planned", target, { type: "plan", todos }),
|
|
31
|
+
toolResultStep(id, "TodoWrite", true, "plan updated"),
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
// Real agents answer "what's in here?" with a wall of markdown — the case that
|
|
35
|
+
// used to push the whole ledger off screen, so the mock has to produce one or
|
|
36
|
+
// the clamp is untestable offline.
|
|
37
|
+
const MOCK_LONG_REPLY = [
|
|
38
|
+
"Here's what changed, with the surrounding structure for context:",
|
|
39
|
+
"",
|
|
40
|
+
"**Root**",
|
|
41
|
+
"",
|
|
42
|
+
...["README.md", "RELAY_NOTES.md", "package.json", "pnpm-workspace.yaml", "tsconfig.json"].map((f) => `- \`${f}\``),
|
|
43
|
+
"",
|
|
44
|
+
"**src/**",
|
|
45
|
+
"",
|
|
46
|
+
...[
|
|
47
|
+
"App.tsx",
|
|
48
|
+
"main.tsx",
|
|
49
|
+
"index.css",
|
|
50
|
+
"components/Button.tsx",
|
|
51
|
+
"components/Panel.tsx",
|
|
52
|
+
"hooks/use-store.ts",
|
|
53
|
+
"hooks/use-theme.ts",
|
|
54
|
+
"lib/api.ts",
|
|
55
|
+
"lib/format.ts",
|
|
56
|
+
"lib/types.ts",
|
|
57
|
+
].map((f) => `- \`src/${f}\``),
|
|
58
|
+
"",
|
|
59
|
+
"**tests/**",
|
|
60
|
+
"",
|
|
61
|
+
...["App.test.tsx", "lib/format.test.ts", "setup.ts"].map((f) => `- \`tests/${f}\``),
|
|
62
|
+
"",
|
|
63
|
+
"The only file I added is `RELAY_NOTES.md`; everything else was already tracked.",
|
|
64
|
+
].join("\n");
|
|
65
|
+
const MOCK_PLAN = [
|
|
66
|
+
"Read the existing notes file",
|
|
67
|
+
"Write RELAY_NOTES.md",
|
|
68
|
+
"Run the test suite",
|
|
69
|
+
"Fix the failing assertion",
|
|
70
|
+
];
|
|
71
|
+
const MOCK_DIFF = {
|
|
72
|
+
type: "diff",
|
|
73
|
+
path: "RELAY_NOTES.md",
|
|
74
|
+
lines: [
|
|
75
|
+
{ op: "+", text: "# Relay notes" },
|
|
76
|
+
{ op: "+", text: "" },
|
|
77
|
+
{ op: "+", text: "Written by the mock agent." },
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
function buildScript(instruction) {
|
|
81
|
+
const n = Date.now().toString(36);
|
|
82
|
+
return [
|
|
83
|
+
textStep(`On it — looking into **"${instruction.trim() || "your request"}"**.`),
|
|
84
|
+
...planStep(`${n}-p1`, 0, 0, MOCK_PLAN),
|
|
85
|
+
toolCallStep(`${n}-1`, "Bash", "ran", "ls src/"),
|
|
86
|
+
toolResultStep(`${n}-1`, "Bash", true, "12 lines", {
|
|
87
|
+
type: "text",
|
|
88
|
+
text: "App.tsx\nmain.tsx\nindex.css\ncomponents/\nhooks/\nlib/",
|
|
89
|
+
}),
|
|
90
|
+
textStep("Found the right spot. Making the change now."),
|
|
91
|
+
...planStep(`${n}-p2`, 1, 1, MOCK_PLAN),
|
|
92
|
+
toolCallStep(`${n}-2`, "Write", "wrote", "RELAY_NOTES.md", MOCK_DIFF),
|
|
93
|
+
toolResultStep(`${n}-2`, "Write", true, "written"),
|
|
94
|
+
// One nested path, deliberately: a root-only mock can't exercise the
|
|
95
|
+
// workspace rail's directory/filename split.
|
|
96
|
+
toolCallStep(`${n}-2b`, "Write", "wrote", "docs/session-log.md", {
|
|
97
|
+
type: "diff",
|
|
98
|
+
path: "docs/session-log.md",
|
|
99
|
+
lines: [
|
|
100
|
+
{ op: "+", text: "# Session log" },
|
|
101
|
+
{ op: "+", text: "" },
|
|
102
|
+
{ op: "+", text: "Nested so the file list has a path to shorten." },
|
|
103
|
+
],
|
|
104
|
+
}),
|
|
105
|
+
toolResultStep(`${n}-2b`, "Write", true, "written"),
|
|
106
|
+
...planStep(`${n}-p3`, 2, 2, MOCK_PLAN),
|
|
107
|
+
toolCallStep(`${n}-3`, "Bash", "ran", "npm test"),
|
|
108
|
+
toolResultStep(`${n}-3`, "Bash", false, "1 failing — App renders a counter", {
|
|
109
|
+
type: "text",
|
|
110
|
+
text: "FAIL src/App.test.tsx\n ● App › renders a counter\n expected 1 to be 0",
|
|
111
|
+
}),
|
|
112
|
+
textStep("That test asserted the old markup. Fixing the assertion."),
|
|
113
|
+
...planStep(`${n}-p4`, 3, 3, MOCK_PLAN),
|
|
114
|
+
toolCallStep(`${n}-4`, "Bash", "ran", "npm test"),
|
|
115
|
+
toolResultStep(`${n}-4`, "Bash", true, "4 passed"),
|
|
116
|
+
...planStep(`${n}-p5`, 4, null, MOCK_PLAN),
|
|
117
|
+
textStep(MOCK_LONG_REPLY),
|
|
118
|
+
textStep("Done — tests pass."),
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The mock claims to edit files, so it should actually edit them. Otherwise the
|
|
123
|
+
* offline path can't exercise anything downstream of the working dir — the
|
|
124
|
+
* session diff and publish flow would only ever be testable by spending real
|
|
125
|
+
* API quota, which defeats the point of having a mock at all.
|
|
126
|
+
*/
|
|
127
|
+
async function applyMockEdits(dir) {
|
|
128
|
+
try {
|
|
129
|
+
await writeFile(path.join(dir, "RELAY_NOTES.md"), `# Relay notes\n\nWritten by the mock agent at ${new Date().toISOString()}.\n\n` +
|
|
130
|
+
`This file exists so the session-diff and publish flow can be exercised\n` +
|
|
131
|
+
`without spending API quota.\n`);
|
|
132
|
+
await mkdir(path.join(dir, "docs"), { recursive: true });
|
|
133
|
+
await writeFile(path.join(dir, "docs", "session-log.md"), `# Session log\n\nNested so the file list has a path to shorten.\n`);
|
|
134
|
+
await appendFile(path.join(dir, "README.md"), `\n<!-- touched by a Relay mock session -->\n`);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
console.error("mock agent: could not write to the working dir, continuing without file changes:", err instanceof Error ? err.message : err);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function sleep(ms, signal) {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
const timer = setTimeout(resolve, ms);
|
|
143
|
+
signal.addEventListener("abort", () => {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
resolve();
|
|
146
|
+
}, { once: true });
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
export async function runMockAgent(opts) {
|
|
150
|
+
const { emit, instruction, workingDir, signal } = opts;
|
|
151
|
+
const script = buildScript(instruction);
|
|
152
|
+
const startedAt = Date.now();
|
|
153
|
+
void applyMockEdits(workingDir);
|
|
154
|
+
await sleep(INITIAL_DELAY_MS, signal);
|
|
155
|
+
for (const step of script) {
|
|
156
|
+
if (signal.aborted) {
|
|
157
|
+
emit.status("idle");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
step(emit);
|
|
161
|
+
await sleep(STEP_DELAY_MS, signal);
|
|
162
|
+
}
|
|
163
|
+
if (signal.aborted) {
|
|
164
|
+
emit.status("idle");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// Same shape the real agent reports, so the capstone row renders identically
|
|
168
|
+
// offline. The cost is fabricated; nothing was spent.
|
|
169
|
+
emit.event("agent_done", {
|
|
170
|
+
steps: 4,
|
|
171
|
+
durationMs: Date.now() - startedAt,
|
|
172
|
+
costUsd: 0.0128,
|
|
173
|
+
});
|
|
174
|
+
emit.status("done");
|
|
175
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
// Directories that are never what someone means by "the project", and which
|
|
3
|
+
// would otherwise dominate a top-level listing.
|
|
4
|
+
const NOISE = new Set([
|
|
5
|
+
".git", "node_modules", "dist", "build", ".next", ".turbo",
|
|
6
|
+
".cache", "coverage", ".venv", "__pycache__", ".DS_Store",
|
|
7
|
+
]);
|
|
8
|
+
const MAX_ENTRIES = 40;
|
|
9
|
+
/**
|
|
10
|
+
* A short description of where the agent has landed.
|
|
11
|
+
*
|
|
12
|
+
* Every run was opening with two or three failed reads: the agent starts in a
|
|
13
|
+
* disposable clone under the OS temp directory, guesses at conventional paths
|
|
14
|
+
* (`server.ts`, `README.md`) from the repository *name*, misses, and only then
|
|
15
|
+
* runs `pwd && ls` to orient itself. Observed on every real session so far.
|
|
16
|
+
*
|
|
17
|
+
* Handing it the listing up front removes that entirely. The cost is a few
|
|
18
|
+
* hundred tokens on the first turn; the saving is two round-trips of latency
|
|
19
|
+
* and the impression, for anyone watching the ledger, that the agent is lost.
|
|
20
|
+
*/
|
|
21
|
+
export async function orientation(workingDir) {
|
|
22
|
+
let listing;
|
|
23
|
+
try {
|
|
24
|
+
const entries = await readdir(workingDir, { withFileTypes: true });
|
|
25
|
+
const visible = entries
|
|
26
|
+
.filter((e) => !NOISE.has(e.name) && !e.name.startsWith("."))
|
|
27
|
+
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
|
|
28
|
+
.sort();
|
|
29
|
+
listing =
|
|
30
|
+
visible.length === 0
|
|
31
|
+
? "(empty)"
|
|
32
|
+
: visible.slice(0, MAX_ENTRIES).join(" ") +
|
|
33
|
+
(visible.length > MAX_ENTRIES ? ` … +${visible.length - MAX_ENTRIES} more` : "");
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Orientation is a convenience, never a precondition — a run must not fail
|
|
37
|
+
// because the listing could not be read.
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
return [
|
|
41
|
+
`You are working in ${workingDir}.`,
|
|
42
|
+
"",
|
|
43
|
+
"This is a disposable clone of the user's repository, so the path is a",
|
|
44
|
+
"temporary one and paths from the project's own documentation will not",
|
|
45
|
+
"resolve from anywhere else. Everything you need is under this directory.",
|
|
46
|
+
"",
|
|
47
|
+
`Top level: ${listing}`,
|
|
48
|
+
"",
|
|
49
|
+
"Use these names directly rather than guessing at conventional paths.",
|
|
50
|
+
].join("\n");
|
|
51
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { redactKeys } from "../redact.js";
|
|
3
|
+
import { detailForCall } from "./diff.js";
|
|
4
|
+
import { resultText, summarizeToolCall, summarizeToolResult } from "./summarize.js";
|
|
5
|
+
import { orientation } from "./orient.js";
|
|
6
|
+
export async function runRealAgent(opts) {
|
|
7
|
+
const { emit, instruction, workingDir, signal, resume, apiKeyHelper } = opts;
|
|
8
|
+
const pending = new Map();
|
|
9
|
+
const model = process.env.RELAY_MODEL ?? "claude-haiku-4-5";
|
|
10
|
+
// The SDK wants an AbortController, but ownership of stopping a run belongs
|
|
11
|
+
// to the runner, which already holds one. Bridge the two.
|
|
12
|
+
const abort = new AbortController();
|
|
13
|
+
if (signal.aborted)
|
|
14
|
+
abort.abort();
|
|
15
|
+
else
|
|
16
|
+
signal.addEventListener("abort", () => abort.abort(), { once: true });
|
|
17
|
+
// Inline settings, so a run is unaffected by whatever the operator has in
|
|
18
|
+
// their own settings files, and the helper can't be overridden.
|
|
19
|
+
const keyOptions = apiKeyHelper
|
|
20
|
+
? { settings: { apiKeyHelper } }
|
|
21
|
+
: {};
|
|
22
|
+
try {
|
|
23
|
+
// Resolved before the run so the agent opens by acting rather than by
|
|
24
|
+
// working out where it is — see orient.ts.
|
|
25
|
+
const where = await orientation(workingDir);
|
|
26
|
+
const q = query({
|
|
27
|
+
prompt: instruction,
|
|
28
|
+
options: {
|
|
29
|
+
cwd: workingDir,
|
|
30
|
+
model,
|
|
31
|
+
...(where
|
|
32
|
+
? { systemPrompt: { type: "preset", preset: "claude_code", append: where } }
|
|
33
|
+
: {}),
|
|
34
|
+
abortController: abort,
|
|
35
|
+
// Headless: there is no human here to approve each tool call. The agent
|
|
36
|
+
// runs with the operator's own permissions on their own machine, which
|
|
37
|
+
// is the same trust boundary as running Claude Code directly.
|
|
38
|
+
permissionMode: "bypassPermissions",
|
|
39
|
+
allowDangerouslySkipPermissions: true,
|
|
40
|
+
...keyOptions,
|
|
41
|
+
...(resume ? { resume } : {}),
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
for await (const msg of q) {
|
|
45
|
+
// Guarded on an actual change: the SDK repeats session_id on nearly every
|
|
46
|
+
// message, and reporting it each time would fire a Postgres upsert per
|
|
47
|
+
// tool call instead of once per run.
|
|
48
|
+
if ("session_id" in msg && msg.session_id && msg.session_id !== opts.resume) {
|
|
49
|
+
emit.agentSession(msg.session_id);
|
|
50
|
+
}
|
|
51
|
+
if (msg.type === "assistant") {
|
|
52
|
+
for (const block of msg.message.content) {
|
|
53
|
+
if (block.type === "text") {
|
|
54
|
+
const text = block.text.trim();
|
|
55
|
+
if (text)
|
|
56
|
+
emit.event("agent_text", { text });
|
|
57
|
+
}
|
|
58
|
+
else if (block.type === "tool_use") {
|
|
59
|
+
const input = (block.input ?? {});
|
|
60
|
+
const { verb, target } = summarizeToolCall(block.name, input, workingDir);
|
|
61
|
+
pending.set(block.id, { tool: block.name, verb, target });
|
|
62
|
+
emit.event("tool_call", {
|
|
63
|
+
// `id` pairs this with its result so the ledger renders ONE row
|
|
64
|
+
// per action: it appears now, pending, and completes on arrival.
|
|
65
|
+
id: block.id,
|
|
66
|
+
tool: block.name,
|
|
67
|
+
verb,
|
|
68
|
+
target,
|
|
69
|
+
detail: detailForCall(block.name, input, target),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else if (msg.type === "user") {
|
|
75
|
+
const content = msg.message.content;
|
|
76
|
+
if (Array.isArray(content)) {
|
|
77
|
+
for (const block of content) {
|
|
78
|
+
if (block.type !== "tool_result")
|
|
79
|
+
continue;
|
|
80
|
+
const call = pending.get(block.tool_use_id);
|
|
81
|
+
pending.delete(block.tool_use_id);
|
|
82
|
+
const ok = block.is_error !== true;
|
|
83
|
+
const tool = call?.tool ?? "tool";
|
|
84
|
+
const full = resultText(block.content).trim();
|
|
85
|
+
emit.event("tool_result", {
|
|
86
|
+
id: block.tool_use_id,
|
|
87
|
+
tool,
|
|
88
|
+
ok,
|
|
89
|
+
summary: summarizeToolResult(tool, block.content, workingDir, ok),
|
|
90
|
+
// Bulk output goes behind the disclosure rather than being
|
|
91
|
+
// truncated away — the summary stays scannable, the detail stays
|
|
92
|
+
// available.
|
|
93
|
+
detail: full.split("\n").length > 1
|
|
94
|
+
? { type: "text", text: full.split(`${workingDir}/`).join("") }
|
|
95
|
+
: undefined,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else if (msg.type === "result") {
|
|
101
|
+
if (msg.subtype === "success") {
|
|
102
|
+
emit.event("agent_done", {
|
|
103
|
+
steps: msg.num_turns,
|
|
104
|
+
durationMs: msg.duration_ms,
|
|
105
|
+
costUsd: msg.total_cost_usd,
|
|
106
|
+
});
|
|
107
|
+
emit.status("done");
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
emit.event("agent_error", { message: `agent stopped: ${msg.subtype}` });
|
|
111
|
+
emit.status("error");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
// An abort is a deliberate stop, not a failure to report as one. Still
|
|
118
|
+
// clear "working", so a driver who reconnects later isn't left watching a
|
|
119
|
+
// status that will never resolve on its own.
|
|
120
|
+
if (abort.signal.aborted) {
|
|
121
|
+
emit.status("idle");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
emit.event("agent_error", {
|
|
125
|
+
message: redactKeys(err instanceof Error ? err.message : String(err)),
|
|
126
|
+
});
|
|
127
|
+
emit.status("error");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
/**
|
|
3
|
+
* TodoWrite's payload, defensively parsed. It arrives as untyped tool input,
|
|
4
|
+
* and a malformed one must degrade to "no plan" rather than throw inside the
|
|
5
|
+
* message loop and kill the run.
|
|
6
|
+
*/
|
|
7
|
+
export function planItems(raw) {
|
|
8
|
+
if (!Array.isArray(raw))
|
|
9
|
+
return [];
|
|
10
|
+
const out = [];
|
|
11
|
+
for (const item of raw) {
|
|
12
|
+
if (!item || typeof item !== "object")
|
|
13
|
+
continue;
|
|
14
|
+
const t = item;
|
|
15
|
+
if (typeof t.content !== "string")
|
|
16
|
+
continue;
|
|
17
|
+
const status = t.status === "in_progress" || t.status === "completed" ? t.status : "pending";
|
|
18
|
+
out.push({
|
|
19
|
+
content: t.content,
|
|
20
|
+
status,
|
|
21
|
+
activeForm: typeof t.activeForm === "string" ? t.activeForm : undefined,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Split into (verb, target) rather than one string so the ledger can align them
|
|
28
|
+
* as columns and the eye can scan down a column instead of parsing each line
|
|
29
|
+
* (DESIGN.md, ledger rule 3). Raw tool args must never be dumped into the UI —
|
|
30
|
+
* anything bulky goes in collapsed detail instead.
|
|
31
|
+
*/
|
|
32
|
+
export function summarizeToolCall(tool, input, workingDir) {
|
|
33
|
+
// Paths render relative to the repo root. An agent that guesses a path
|
|
34
|
+
// outside the working dir would otherwise produce a "../../../.." chain, so
|
|
35
|
+
// fall back to the bare filename in that case.
|
|
36
|
+
const rel = (p) => {
|
|
37
|
+
if (typeof p !== "string")
|
|
38
|
+
return "";
|
|
39
|
+
const relative = path.relative(workingDir, p);
|
|
40
|
+
if (!relative)
|
|
41
|
+
return path.basename(p);
|
|
42
|
+
return relative.startsWith("..") ? path.basename(p) : relative;
|
|
43
|
+
};
|
|
44
|
+
const clip = (s, n = 60) => {
|
|
45
|
+
const text = typeof s === "string" ? s.replace(/\s+/g, " ").trim() : "";
|
|
46
|
+
return text.length > n ? `${text.slice(0, n)}…` : text;
|
|
47
|
+
};
|
|
48
|
+
switch (tool) {
|
|
49
|
+
case "Bash":
|
|
50
|
+
return { verb: "ran", target: clip(input.command, 120) };
|
|
51
|
+
case "Read":
|
|
52
|
+
return { verb: "read", target: rel(input.file_path) };
|
|
53
|
+
case "Write":
|
|
54
|
+
return { verb: "wrote", target: rel(input.file_path) };
|
|
55
|
+
case "Edit":
|
|
56
|
+
return { verb: "edited", target: rel(input.file_path) };
|
|
57
|
+
case "NotebookEdit":
|
|
58
|
+
return { verb: "edited", target: rel(input.notebook_path) };
|
|
59
|
+
case "Glob":
|
|
60
|
+
return { verb: "globbed", target: clip(input.pattern, 60) };
|
|
61
|
+
case "Grep":
|
|
62
|
+
return { verb: "grepped", target: clip(input.pattern, 60) };
|
|
63
|
+
case "WebFetch":
|
|
64
|
+
return { verb: "fetched", target: clip(input.url, 70) };
|
|
65
|
+
case "WebSearch":
|
|
66
|
+
return { verb: "searched", target: clip(input.query, 60) };
|
|
67
|
+
case "Task":
|
|
68
|
+
return { verb: "delegated", target: clip(input.description, 70) };
|
|
69
|
+
case "TodoWrite": {
|
|
70
|
+
// "updated its plan" said nothing. Name the step it just started, so the
|
|
71
|
+
// ledger row carries the same information the plan strip does.
|
|
72
|
+
const todos = planItems(input.todos);
|
|
73
|
+
const active = todos.find((t) => t.status === "in_progress");
|
|
74
|
+
const done = todos.filter((t) => t.status === "completed").length;
|
|
75
|
+
if (active) {
|
|
76
|
+
return { verb: "planned", target: active.activeForm ?? active.content };
|
|
77
|
+
}
|
|
78
|
+
if (todos.length && done === todos.length) {
|
|
79
|
+
return { verb: "planned", target: "all steps complete" };
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
verb: "planned",
|
|
83
|
+
target: todos.length ? `${todos.length} steps` : "updated its plan",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
default:
|
|
87
|
+
return { verb: tool.toLowerCase(), target: "" };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export function resultText(content) {
|
|
91
|
+
return Array.isArray(content)
|
|
92
|
+
? content
|
|
93
|
+
.map((b) => (b && typeof b === "object" && "text" in b ? String(b.text) : ""))
|
|
94
|
+
.join(" ")
|
|
95
|
+
: typeof content === "string"
|
|
96
|
+
? content
|
|
97
|
+
: "";
|
|
98
|
+
}
|
|
99
|
+
// Phrasing the SDK writes for the model's benefit, not the watcher's. Left in,
|
|
100
|
+
// it surfaces as "File created successfully at: REPORT.md (file state is
|
|
101
|
+
// current in your…" — an internal aside truncated mid-sentence.
|
|
102
|
+
const SDK_ASIDES = [
|
|
103
|
+
/\s*\(file state is current in your context[^)]*\)/gi,
|
|
104
|
+
/\s*<system-reminder>[\s\S]*?<\/system-reminder>/gi,
|
|
105
|
+
];
|
|
106
|
+
/**
|
|
107
|
+
* Per-tool result summaries. The generic "N lines" fallback told a watcher
|
|
108
|
+
* nothing on 12 of 13 rows in a real run (RELAY_PRODUCTION_PLAN.md §A3).
|
|
109
|
+
*/
|
|
110
|
+
export function summarizeToolResult(tool, content, workingDir, ok) {
|
|
111
|
+
let text = resultText(content);
|
|
112
|
+
for (const aside of SDK_ASIDES)
|
|
113
|
+
text = text.replace(aside, "");
|
|
114
|
+
// Absolute paths are noise in the ledger and leak the host's directory
|
|
115
|
+
// layout — show them relative to the repo root instead.
|
|
116
|
+
const cleaned = text.split(`${workingDir}/`).join("").trim();
|
|
117
|
+
const oneLine = cleaned.replace(/\s+/g, " ").trim();
|
|
118
|
+
const lines = cleaned ? cleaned.split("\n") : [];
|
|
119
|
+
const clip = (s, n = 70) => (s.length > n ? `${s.slice(0, n)}…` : s);
|
|
120
|
+
// A failure's reason is the whole point of the row — never flatten it to a
|
|
121
|
+
// bare "failed" (DESIGN.md ledger rule 7).
|
|
122
|
+
if (!ok)
|
|
123
|
+
return oneLine ? clip(oneLine, 90) : "failed";
|
|
124
|
+
switch (tool) {
|
|
125
|
+
case "Read":
|
|
126
|
+
return lines.length ? `${lines.length} lines` : "empty";
|
|
127
|
+
case "Write":
|
|
128
|
+
return "written";
|
|
129
|
+
case "Edit":
|
|
130
|
+
case "NotebookEdit":
|
|
131
|
+
return "applied";
|
|
132
|
+
case "Bash": {
|
|
133
|
+
if (!oneLine)
|
|
134
|
+
return "no output";
|
|
135
|
+
// One-line output is usually the answer itself; multi-line is bulk the
|
|
136
|
+
// watcher can expand into.
|
|
137
|
+
return lines.length > 1 ? `${lines.length} lines` : clip(oneLine);
|
|
138
|
+
}
|
|
139
|
+
case "Glob":
|
|
140
|
+
case "Grep": {
|
|
141
|
+
const n = lines.filter((l) => l.trim()).length;
|
|
142
|
+
return n === 1 ? "1 match" : `${n} matches`;
|
|
143
|
+
}
|
|
144
|
+
case "TodoWrite":
|
|
145
|
+
return "plan updated";
|
|
146
|
+
default:
|
|
147
|
+
if (!oneLine)
|
|
148
|
+
return "done";
|
|
149
|
+
return lines.length > 1 ? `${lines.length} lines` : clip(oneLine);
|
|
150
|
+
}
|
|
151
|
+
}
|