agent-relay-runner 0.10.21 → 0.10.22
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/package.json +1 -1
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/hooks/relay-status.sh +14 -0
- package/plugins/claude/hooks/stop.sh +1 -0
- package/plugins/claude/skills/reply/SKILL.md +3 -1
- package/plugins/codex/skills/reply/SKILL.md +8 -5
- package/src/adapters/claude-delivery.ts +31 -7
- package/src/adapters/claude-transcript.ts +76 -0
- package/src/adapters/claude.ts +91 -20
- package/src/control-server.ts +23 -0
- package/src/profile-home.ts +63 -7
- package/src/runner.ts +105 -1
- package/src/session-scratch.ts +169 -0
package/package.json
CHANGED
|
@@ -35,6 +35,20 @@ relay_post_timeline_status() {
|
|
|
35
35
|
relay_post_status "$1" "${2:-provider-turn}" "" "" "" "" "${3:-}" "$4"
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
relay_post_session_turn() {
|
|
39
|
+
# Phase 1 live-session lane: hand the transcript path to the runner so it can
|
|
40
|
+
# capture the assistant turn for the dashboard chat. Synchronous on purpose —
|
|
41
|
+
# the captured turn clears the reply obligation before the stop decision check
|
|
42
|
+
# below, so the agent is not nagged to /reply (and does not re-emit).
|
|
43
|
+
local transcript_path="${1:-}"
|
|
44
|
+
local port="${AGENT_RELAY_RUNNER_PORT:-}"
|
|
45
|
+
[ -z "$port" ] && return 0
|
|
46
|
+
[ -z "$transcript_path" ] && return 0
|
|
47
|
+
curl -fsS -X POST "http://127.0.0.1:${port}/session-turn" \
|
|
48
|
+
-H 'Content-Type: application/json' \
|
|
49
|
+
-d "{\"transcriptPath\":\"$(relay_json_escape "$transcript_path")\"}" >/dev/null 2>&1 || true
|
|
50
|
+
}
|
|
51
|
+
|
|
38
52
|
relay_pending_reply_stop_decision() {
|
|
39
53
|
local port="${AGENT_RELAY_RUNNER_PORT:-}"
|
|
40
54
|
[ -z "$port" ] && return 0
|
|
@@ -5,6 +5,7 @@ source "${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/
|
|
|
5
5
|
payload="$(cat || true)"
|
|
6
6
|
stop_hook_active="$(relay_json_bool_field stop_hook_active "$payload")"
|
|
7
7
|
if [ "$stop_hook_active" != "true" ]; then
|
|
8
|
+
relay_post_session_turn "$(relay_json_string_field transcript_path "$payload")"
|
|
8
9
|
stop_decision="$(relay_pending_reply_stop_decision)"
|
|
9
10
|
if [ -n "$stop_decision" ]; then
|
|
10
11
|
printf '%s\n' "$stop_decision"
|
|
@@ -20,10 +20,12 @@ Examples:
|
|
|
20
20
|
```bash
|
|
21
21
|
agent-relay /reply 206 "Sounds good, I'll take a look"
|
|
22
22
|
agent-relay /reply 42 "Done — the fix is in commit abc123"
|
|
23
|
-
agent-relay /reply 42 --stdin <
|
|
23
|
+
agent-relay /reply 42 --stdin < .agent-relay/sessions/$AGENT_RELAY_ID/tmp/reply.md
|
|
24
24
|
agent-relay /reply 42 --body-file response.md
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
Use `--stdin` or `--body-file` for long replies so shell quoting does not mangle the body. Oversized replies are automatically uploaded as an Agent Relay artifact and sent as an attached concise reply.
|
|
28
28
|
|
|
29
|
+
The relay creates this per-session scratch dir for you (it is git-ignored locally), so staging there never pollutes the working tree and never collides with other agents. No cleanup needed — the dir is reaped when your session ends.
|
|
30
|
+
|
|
29
31
|
Do not send a separate Relay follow-up that only confirms the reply was sent. The CLI output is enough local confirmation.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: reply
|
|
3
|
-
description: Reply to an Agent Relay message by ID. Auto-routes to the sender and inherits channel context
|
|
3
|
+
description: Reply to an Agent Relay message by ID. Auto-routes to the sender and inherits channel context — no target needed. Use when the user invokes /reply or asks to reply to a specific relay message, especially with stdin/file for long replies.
|
|
4
4
|
argument-hint: "<messageId> <message|--stdin|--body-file PATH>"
|
|
5
|
+
allowed-tools: [Bash]
|
|
5
6
|
---
|
|
6
7
|
|
|
7
8
|
# Agent Relay Reply
|
|
@@ -12,17 +13,19 @@ Run:
|
|
|
12
13
|
agent-relay /reply $ARGUMENTS
|
|
13
14
|
```
|
|
14
15
|
|
|
15
|
-
The server auto-routes the reply to the original sender and inherits the channel if applicable. No target or channel ID
|
|
16
|
+
The server auto-routes the reply to the original sender and inherits the channel (Telegram, Slack, etc.) if applicable. No target or channel ID needed.
|
|
16
17
|
|
|
17
18
|
Examples:
|
|
18
19
|
|
|
19
20
|
```bash
|
|
20
21
|
agent-relay /reply 206 "Sounds good, I'll take a look"
|
|
21
|
-
agent-relay /reply 42 "Done
|
|
22
|
-
agent-relay /reply 42 --stdin <
|
|
22
|
+
agent-relay /reply 42 "Done — the fix is in commit abc123"
|
|
23
|
+
agent-relay /reply 42 --stdin < .agent-relay/sessions/$AGENT_RELAY_ID/tmp/reply.md
|
|
23
24
|
agent-relay /reply 42 --body-file response.md
|
|
24
25
|
```
|
|
25
26
|
|
|
26
27
|
Use `--stdin` or `--body-file` for long replies so shell quoting does not mangle the body. Oversized replies are automatically uploaded as an Agent Relay artifact and sent as an attached concise reply.
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
The relay creates this per-session scratch dir for you (it is git-ignored locally), so staging there never pollutes the working tree and never collides with other agents. No cleanup needed — the dir is reaped when your session ends.
|
|
30
|
+
|
|
31
|
+
Do not send a separate Relay follow-up that only confirms the reply was sent. The CLI output is enough local confirmation.
|
|
@@ -7,6 +7,27 @@ const REMINDER_EVERY_DELIVERIES = 5;
|
|
|
7
7
|
interface ClaudeDeliveryTextOptions {
|
|
8
8
|
deliveryCount: number;
|
|
9
9
|
readOnly?: boolean;
|
|
10
|
+
// false = isolated profile with no relay plugin/CLI surface. The agent cannot /reply or
|
|
11
|
+
// /claim, so strip the reply-reminder block and the server-baked claim-task instruction —
|
|
12
|
+
// it's just confusing noise in a clean-room run. Defaults to true (relay-aware agents).
|
|
13
|
+
relaySurface?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Server-baked trailing line from taskMessageBody() in src/db.ts. Meaningless to an isolated
|
|
17
|
+
// agent (sole claimant, no relay CLI), so drop it from delivered text when there's no surface.
|
|
18
|
+
const TASK_CLAIM_INSTRUCTION = "Claim this task before working it, then update task status when finished.";
|
|
19
|
+
|
|
20
|
+
function stripRelayScaffolding(body: string): string {
|
|
21
|
+
const lines = body.split("\n");
|
|
22
|
+
const popTrailingBlanks = () => {
|
|
23
|
+
while (lines.length && (lines.at(-1) ?? "").trim() === "") lines.pop();
|
|
24
|
+
};
|
|
25
|
+
popTrailingBlanks();
|
|
26
|
+
if (lines.at(-1) === TASK_CLAIM_INSTRUCTION) {
|
|
27
|
+
lines.pop();
|
|
28
|
+
popTrailingBlanks();
|
|
29
|
+
}
|
|
30
|
+
return lines.join("\n");
|
|
10
31
|
}
|
|
11
32
|
|
|
12
33
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -47,23 +68,24 @@ function latestReplyableMessage(messages: Message[]): Message | undefined {
|
|
|
47
68
|
.at(-1);
|
|
48
69
|
}
|
|
49
70
|
|
|
50
|
-
function formatMessage(message: Message): string {
|
|
71
|
+
function formatMessage(message: Message, relaySurface: boolean): string {
|
|
51
72
|
const subject = message.subject ? `Subject: ${message.subject}\n` : "";
|
|
52
73
|
const isMemoryContext = isMemoryInjection(message);
|
|
53
74
|
const canFetchMessage = isPersistedRelayMessage(message);
|
|
54
|
-
const
|
|
75
|
+
const rawBody = relaySurface ? message.body : stripRelayScaffolding(message.body);
|
|
76
|
+
const shouldPreview = canFetchMessage && rawBody.length > PROVIDER_MESSAGE_BODY_PREVIEW_CHARS;
|
|
55
77
|
const preview = shouldPreview
|
|
56
78
|
? {
|
|
57
|
-
body:
|
|
79
|
+
body: rawBody.slice(0, PROVIDER_MESSAGE_BODY_PREVIEW_CHARS),
|
|
58
80
|
truncated: true,
|
|
59
81
|
}
|
|
60
82
|
: {
|
|
61
|
-
body:
|
|
83
|
+
body: rawBody,
|
|
62
84
|
truncated: false,
|
|
63
85
|
};
|
|
64
86
|
const truncationGuidance = preview.truncated
|
|
65
87
|
? [
|
|
66
|
-
`[truncated: showing first ${PROVIDER_MESSAGE_BODY_PREVIEW_CHARS} of ${
|
|
88
|
+
`[truncated: showing first ${PROVIDER_MESSAGE_BODY_PREVIEW_CHARS} of ${rawBody.length} chars]`,
|
|
67
89
|
`Read full: agent-relay get-message ${message.id}`,
|
|
68
90
|
`Body only: agent-relay get-message ${message.id} --body`,
|
|
69
91
|
].join("\n")
|
|
@@ -99,9 +121,11 @@ function replyReminder(message: Message, readOnly: boolean): string {
|
|
|
99
121
|
}
|
|
100
122
|
|
|
101
123
|
export function claudeProviderMessageText(messages: Message[], options: ClaudeDeliveryTextOptions): string {
|
|
102
|
-
const
|
|
124
|
+
const relaySurface = options.relaySurface !== false;
|
|
125
|
+
const sections = messages.map((message) => formatMessage(message, relaySurface));
|
|
103
126
|
const replyable = latestReplyableMessage(messages);
|
|
104
|
-
|
|
127
|
+
// Isolated agents have no way to reply through Relay — never append the reminder.
|
|
128
|
+
if (relaySurface && replyable && shouldShowReplyReminder(options.deliveryCount)) {
|
|
105
129
|
sections.push(replyReminder(replyable, options.readOnly === true));
|
|
106
130
|
}
|
|
107
131
|
return sections.join("\n\n");
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Phase 1 live-session lane: extract the user-visible assistant turn from a
|
|
2
|
+
// Claude Code transcript JSONL so it can be surfaced in the dashboard chat
|
|
3
|
+
// without the agent re-emitting it via /reply (zero agent tokens).
|
|
4
|
+
//
|
|
5
|
+
// Transcript shape (one JSON object per line):
|
|
6
|
+
// { type: "user", message: { content: "..." | [{type:"text"|"tool_result", ...}] } }
|
|
7
|
+
// { type: "assistant", message: { content: [{type:"thinking"|"text"|"tool_use", ...}], stop_reason } }
|
|
8
|
+
//
|
|
9
|
+
// The "current turn" is everything after the last *real* user prompt (a user
|
|
10
|
+
// entry carrying text, not just tool_result blocks). We collect the assistant
|
|
11
|
+
// `text` blocks from that turn — thinking and tool_use are dropped.
|
|
12
|
+
|
|
13
|
+
interface TranscriptBlock {
|
|
14
|
+
type?: string;
|
|
15
|
+
text?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface TranscriptMessage {
|
|
19
|
+
role?: string;
|
|
20
|
+
content?: string | TranscriptBlock[];
|
|
21
|
+
stop_reason?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface TranscriptEntry {
|
|
25
|
+
type?: string;
|
|
26
|
+
message?: TranscriptMessage;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function blocks(message: TranscriptMessage | undefined): TranscriptBlock[] {
|
|
30
|
+
if (!message || !Array.isArray(message.content)) return [];
|
|
31
|
+
return message.content.filter((b): b is TranscriptBlock => Boolean(b) && typeof b === "object");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isRealUserPrompt(entry: TranscriptEntry): boolean {
|
|
35
|
+
if (entry.type !== "user") return false;
|
|
36
|
+
const content = entry.message?.content;
|
|
37
|
+
if (typeof content === "string") return content.trim().length > 0;
|
|
38
|
+
// An array user entry is a real prompt only if it carries a text block;
|
|
39
|
+
// pure tool_result entries are mid-turn plumbing, not a new prompt.
|
|
40
|
+
return blocks(entry.message).some((b) => b.type === "text" && Boolean(b.text?.trim()));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function assistantText(entry: TranscriptEntry): string {
|
|
44
|
+
if (entry.type !== "assistant") return "";
|
|
45
|
+
return blocks(entry.message)
|
|
46
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
47
|
+
.map((b) => b.text!.trim())
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.join("\n\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Returns the concatenated assistant `text` for the most recent turn (since the
|
|
54
|
+
* last real user prompt), or "" if there is nothing user-visible to surface.
|
|
55
|
+
*/
|
|
56
|
+
export function extractLastAssistantTurn(jsonl: string): string {
|
|
57
|
+
const lines = jsonl.split("\n");
|
|
58
|
+
let collected: string[] = [];
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
const trimmed = line.trim();
|
|
61
|
+
if (!trimmed) continue;
|
|
62
|
+
let entry: TranscriptEntry;
|
|
63
|
+
try {
|
|
64
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
65
|
+
} catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (isRealUserPrompt(entry)) {
|
|
69
|
+
collected = [];
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const text = assistantText(entry);
|
|
73
|
+
if (text) collected.push(text);
|
|
74
|
+
}
|
|
75
|
+
return collected.join("\n\n").trim();
|
|
76
|
+
}
|
package/src/adapters/claude.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import type { Message } from "agent-relay-sdk";
|
|
5
5
|
import { profileAllowsRelayFeature, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderStatusUpdate, type RunnerSpawnConfig, type SemanticStatus, type SpawnArgs } from "../adapter";
|
|
6
6
|
import { prepareClaudeProfileHome, profileUsesHostProviderGlobals } from "../profile-home";
|
|
7
|
+
import { claudeProviderMessageText } from "./claude-delivery";
|
|
7
8
|
|
|
8
9
|
export class ClaudeAdapter implements ProviderAdapter {
|
|
9
10
|
readonly provider = "claude";
|
|
@@ -58,10 +59,35 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
58
59
|
return { method: "tmux-inject", command: "/clear" };
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
async deliver(
|
|
62
|
-
const monitor =
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
async deliver(process: ManagedProcess, messages: Message[]): Promise<void> {
|
|
63
|
+
const monitor = process.meta?.monitor as { deliver?(messages: Message[]): Promise<number[]> } | undefined;
|
|
64
|
+
// A monitor object always exists for headless claude (it proxies to the runner
|
|
65
|
+
// control server), so its presence says nothing about whether a monitor process
|
|
66
|
+
// is actually connected. `monitorless` is set at spawn when the profile disables
|
|
67
|
+
// the relay plugin — then no monitor will ever connect, so we must not route
|
|
68
|
+
// through it (it would throw "no Claude monitor connected" forever); we inject via
|
|
69
|
+
// tmux instead. For monitored profiles we keep the monitor path (and its retry on
|
|
70
|
+
// transient startup races) to avoid double-delivering against a late monitor.
|
|
71
|
+
const monitorless = process.meta?.monitorless === true;
|
|
72
|
+
if (!monitorless && monitor?.deliver) {
|
|
73
|
+
await monitor.deliver(messages);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Inject straight into the interactive tmux session via send-keys — the same
|
|
77
|
+
// transport deliverInitialPrompt uses. Keeps the agent on interactive Claude (the
|
|
78
|
+
// user's Max quota), never `claude -p`/API, and lets isolated automation agents
|
|
79
|
+
// receive their task (a claimable message) and any mid-session messages (e.g. the
|
|
80
|
+
// budget "wrap up" warning) that a launch --prompt can't carry.
|
|
81
|
+
const session = process.meta?.tmuxSession as string | undefined;
|
|
82
|
+
const socket = process.meta?.tmuxSocket as string | undefined;
|
|
83
|
+
if (!session || !tmuxHasSession(session, socket)) {
|
|
84
|
+
throw new Error("Claude monitor delivery is unavailable and no tmux session for fallback");
|
|
85
|
+
}
|
|
86
|
+
await waitForClaudeInputReady(session, CLAUDE_TMUX_READY_TIMEOUT_MS, socket);
|
|
87
|
+
// Monitorless = isolated profile with no relay plugin/CLI surface. Strip relay
|
|
88
|
+
// interaction scaffolding (reply reminder, claim-task instruction) — the agent has
|
|
89
|
+
// no way to /reply or /claim, so it's just confusing noise in a clean-room run.
|
|
90
|
+
await submitTextToTmux(session, claudeProviderMessageText(messages, { deliveryCount: 1, relaySurface: !monitorless }), socket);
|
|
65
91
|
}
|
|
66
92
|
|
|
67
93
|
async deliverInitialPrompt(process: ManagedProcess, prompt: string): Promise<void> {
|
|
@@ -78,9 +104,13 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
78
104
|
const defaultArgs = profileUsesHostProviderGlobals(config) ? providerConfig.defaultArgs : [];
|
|
79
105
|
const pluginDirs = profileAllowsRelayFeature(config, "plugins") ? [...new Set([pluginRoot, ...providerConfig.pluginDirs])] : [];
|
|
80
106
|
const isClaudeRig = /claude-rig/.test(providerConfig.command);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
107
|
+
// Isolated profiles run their own CLAUDE_CONFIG_DIR and must never route through
|
|
108
|
+
// claude-rig — claude-rig resolves host rig config (and a bare `claude-rig` with no
|
|
109
|
+
// rig key just errors out). Only host-base profiles may use claude-rig.
|
|
110
|
+
const usesClaudeRig = isClaudeRig && profileUsesHostProviderGlobals(config);
|
|
111
|
+
const bypassRigDefaults = config.headless && usesClaudeRig && config.approvalMode !== "open";
|
|
112
|
+
const rigPrefix = usesClaudeRig && !bypassRigDefaults && config.rig ? ["launch", config.rig] : [];
|
|
113
|
+
const command = !usesClaudeRig || bypassRigDefaults || (!config.rig && !findClaudeRigRC(config.cwd))
|
|
84
114
|
? "claude"
|
|
85
115
|
: providerConfig.command;
|
|
86
116
|
const providerArgs = config.headless
|
|
@@ -102,13 +132,22 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
102
132
|
env: {
|
|
103
133
|
...config.env,
|
|
104
134
|
...(profileHome ? { CLAUDE_CONFIG_DIR: profileHome.path } : {}),
|
|
135
|
+
// Plain `claude` (isolated profiles) runs its own CLAUDE_CONFIG_DIR and never
|
|
136
|
+
// routes through claude-rig, so it never inherits the host's long-lived
|
|
137
|
+
// CLAUDE_CODE_OAUTH_TOKEN. Without it, claude falls back to the (often stale)
|
|
138
|
+
// file-based .credentials.json and bails to /login (401). claude-rig launches
|
|
139
|
+
// inject their own token, so only thread it for the plain-claude path, and
|
|
140
|
+
// never override an explicit per-launch value.
|
|
141
|
+
...(command === "claude" && process.env.CLAUDE_CODE_OAUTH_TOKEN && !config.env?.CLAUDE_CODE_OAUTH_TOKEN
|
|
142
|
+
? { CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN }
|
|
143
|
+
: {}),
|
|
105
144
|
AGENT_RELAY_PROVIDER: "claude",
|
|
106
145
|
...(config.effort ? { CLAUDE_CODE_EFFORT_LEVEL: config.effort } : {}),
|
|
107
146
|
},
|
|
108
147
|
};
|
|
109
148
|
}
|
|
110
149
|
|
|
111
|
-
buildTmuxArgs(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): { sessionName: string; socketName: string; args: string[] } {
|
|
150
|
+
buildTmuxArgs(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): { sessionName: string; socketName: string; args: string[]; launcherScript?: string } {
|
|
112
151
|
const sessionName = config.tmuxSession || tmuxSessionName(config.providerConfig.headless.tmuxPrefix, config.instanceId, config.label);
|
|
113
152
|
const socketName = tmuxSocketName(sessionName);
|
|
114
153
|
const shellCmd = [spawnArgs.command, ...spawnArgs.args].map(shellQuote).join(" ");
|
|
@@ -121,8 +160,18 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
121
160
|
}
|
|
122
161
|
}
|
|
123
162
|
|
|
124
|
-
|
|
125
|
-
|
|
163
|
+
// tmux new-session has a hard limit on command length. When the inline shell
|
|
164
|
+
// command is too long (e.g. fork with conversation history in --append-system-prompt),
|
|
165
|
+
// externalize it to a launcher script that tmux executes instead.
|
|
166
|
+
const MAX_TMUX_CMD_LEN = 2000;
|
|
167
|
+
let launcherScript: string | undefined;
|
|
168
|
+
if (shellCmd.length > MAX_TMUX_CMD_LEN) {
|
|
169
|
+
launcherScript = writeLauncherScript(sessionName, shellCmd);
|
|
170
|
+
tmuxArgs.push("-c", spawnArgs.cwd, `bash ${shellQuote(launcherScript)}`);
|
|
171
|
+
} else {
|
|
172
|
+
tmuxArgs.push("-c", spawnArgs.cwd, shellCmd);
|
|
173
|
+
}
|
|
174
|
+
return { sessionName, socketName, args: tmuxArgs, launcherScript };
|
|
126
175
|
}
|
|
127
176
|
|
|
128
177
|
private async spawnHeadless(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): Promise<ManagedProcess> {
|
|
@@ -158,6 +207,9 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
158
207
|
process: undefined,
|
|
159
208
|
meta: {
|
|
160
209
|
monitor: config.monitor,
|
|
210
|
+
// When the profile disables the relay plugin, the bundled monitor never
|
|
211
|
+
// loads, so no monitor will ever connect — deliver() must use tmux injection.
|
|
212
|
+
monitorless: !profileAllowsRelayFeature(config, "plugins"),
|
|
161
213
|
tmuxSession: sessionName,
|
|
162
214
|
tmuxSocket: socketName,
|
|
163
215
|
},
|
|
@@ -294,13 +346,18 @@ function captureTmuxPane(sessionName: string, socketName?: string): string {
|
|
|
294
346
|
}
|
|
295
347
|
|
|
296
348
|
export function claudePaneLooksReady(text: string): boolean {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
349
|
+
// Claude's startup banner ("Claude Code" / "Welcome back") scrolls off the pane once the
|
|
350
|
+
// conversation fills it, so a mid-session delivery (e.g. the budget warning, minutes into
|
|
351
|
+
// a run) must detect readiness from the PERSISTENT input-box footer that Claude always
|
|
352
|
+
// renders at the prompt. Requiring the banner made every delivery after the first screen
|
|
353
|
+
// of output time out as "input not ready". Any one of these strong, Claude-specific
|
|
354
|
+
// markers means the TUI is up and the input box exists.
|
|
355
|
+
return text.includes("bypass permissions") // footer in --dangerously-skip-permissions mode
|
|
356
|
+
|| text.includes("shift+tab to cycle") // mode-cycle footer hint (persistent)
|
|
357
|
+
|| text.includes("? for shortcuts") // default footer hint (persistent)
|
|
358
|
+
|| text.includes("/effort")
|
|
359
|
+
|| text.includes("Welcome back")
|
|
360
|
+
|| text.includes("Claude Code");
|
|
304
361
|
}
|
|
305
362
|
|
|
306
363
|
async function waitForClaudeInputReady(sessionName: string, timeoutMs = CLAUDE_TMUX_READY_TIMEOUT_MS, socketName?: string): Promise<void> {
|
|
@@ -349,6 +406,16 @@ async function submitTextToTmux(sessionName: string, text: string, socketName?:
|
|
|
349
406
|
}
|
|
350
407
|
}
|
|
351
408
|
|
|
409
|
+
const LAUNCHER_DIR = join(tmpdir(), "agent-relay-launchers");
|
|
410
|
+
|
|
411
|
+
function writeLauncherScript(sessionName: string, shellCmd: string): string {
|
|
412
|
+
mkdirSync(LAUNCHER_DIR, { recursive: true });
|
|
413
|
+
const sanitized = sessionName.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
414
|
+
const scriptPath = join(LAUNCHER_DIR, `${sanitized}.sh`);
|
|
415
|
+
writeFileSync(scriptPath, `#!/usr/bin/env bash\nexec ${shellCmd}\n`, { mode: 0o755 });
|
|
416
|
+
return scriptPath;
|
|
417
|
+
}
|
|
418
|
+
|
|
352
419
|
export function tmuxEnvKeys(env: Record<string, string>, providerEnv: Record<string, string>): string[] {
|
|
353
420
|
const keys = new Set<string>();
|
|
354
421
|
for (const key of Object.keys(env)) {
|
|
@@ -372,10 +439,14 @@ export function findClaudeRigRC(cwd: string): string | null {
|
|
|
372
439
|
const home = homedir();
|
|
373
440
|
let dir = resolve(cwd);
|
|
374
441
|
while (true) {
|
|
442
|
+
// Stop at $HOME without inspecting it: ~/.claude-rig is the claude-rig tool's
|
|
443
|
+
// own config home, not a per-project rc marker. Matching it would make every
|
|
444
|
+
// project under $HOME look like a claude-rig project.
|
|
445
|
+
if (dir === home) return null;
|
|
375
446
|
const rc = join(dir, ".claude-rig");
|
|
376
447
|
if (existsSync(rc)) return rc;
|
|
377
448
|
const parent = resolve(dir, "..");
|
|
378
|
-
if (parent === dir
|
|
449
|
+
if (parent === dir) return null;
|
|
379
450
|
dir = parent;
|
|
380
451
|
}
|
|
381
452
|
}
|
package/src/control-server.ts
CHANGED
|
@@ -20,6 +20,10 @@ interface ControlServerOptions {
|
|
|
20
20
|
onStatus(status: ProviderStatusEvent): void;
|
|
21
21
|
onTerminalAttachSpec?(): Promise<TerminalAttachSpec>;
|
|
22
22
|
onReplyObligations?(): Promise<ReplyObligation[]>;
|
|
23
|
+
// Phase 1 live-session lane: a provider Stop hook hands over its transcript
|
|
24
|
+
// path so the runner can capture the assistant turn and surface it in the
|
|
25
|
+
// dashboard chat without the agent re-emitting it via /reply.
|
|
26
|
+
onSessionTurn?(input: { transcriptPath: string }): Promise<void>;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
export function startControlServer(options: ControlServerOptions): ControlServer {
|
|
@@ -59,6 +63,9 @@ export function startControlServer(options: ControlServerOptions): ControlServer
|
|
|
59
63
|
if (url.pathname === "/permissions/request" && req.method === "POST") {
|
|
60
64
|
return handlePermissionRequest(req, options, pendingPermissionRequests);
|
|
61
65
|
}
|
|
66
|
+
if (url.pathname === "/session-turn" && req.method === "POST") {
|
|
67
|
+
return handleSessionTurn(req, options);
|
|
68
|
+
}
|
|
62
69
|
if (url.pathname === "/monitor") {
|
|
63
70
|
const upgraded = srv.upgrade(req, { data: { kind: "monitor" } });
|
|
64
71
|
return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
|
|
@@ -241,6 +248,22 @@ function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput,
|
|
|
241
248
|
};
|
|
242
249
|
}
|
|
243
250
|
|
|
251
|
+
async function handleSessionTurn(req: Request, options: ControlServerOptions): Promise<Response> {
|
|
252
|
+
if (!options.onSessionTurn) return Response.json({ ok: false, reason: "session capture unavailable" });
|
|
253
|
+
const body = await req.json().catch(() => null);
|
|
254
|
+
const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : "";
|
|
255
|
+
if (!transcriptPath) return Response.json({ ok: false, reason: "transcriptPath required" }, { status: 400 });
|
|
256
|
+
// Awaited on purpose: the Stop hook posts this synchronously before its
|
|
257
|
+
// reply-obligation check, so the captured turn must be persisted (and the
|
|
258
|
+
// obligation cleared) before this response returns.
|
|
259
|
+
try {
|
|
260
|
+
await options.onSessionTurn({ transcriptPath });
|
|
261
|
+
return Response.json({ ok: true });
|
|
262
|
+
} catch (error) {
|
|
263
|
+
return Response.json({ ok: false, reason: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
244
267
|
async function handleStatus(req: Request, options: ControlServerOptions): Promise<Response> {
|
|
245
268
|
const body = await req.json().catch(() => null) as Partial<ProviderStatusEvent> | null;
|
|
246
269
|
const status = body?.status;
|
package/src/profile-home.ts
CHANGED
|
@@ -17,14 +17,35 @@ function profileRequiresIsolatedHome(config: RunnerSpawnConfig): boolean {
|
|
|
17
17
|
return Boolean(config.agentProfile && config.agentProfile.base !== "host");
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
// First-run bootstrap (provider layer)
|
|
21
|
+
// -------------------------------------
|
|
22
|
+
// Isolated profile homes are keyed by instanceId, so every spawn gets a brand
|
|
23
|
+
// new CLAUDE_CONFIG_DIR / CODEX_HOME — to the provider it always looks like the
|
|
24
|
+
// first time it has ever run, even in a repo we've used before. Without
|
|
25
|
+
// bootstrapping, the CLI stalls on first-run gates (theme/onboarding picker,
|
|
26
|
+
// "do you trust this folder?") and the agent never starts; the runner then sees
|
|
27
|
+
// repeated ~2s exits and gives up. bootstrap<Provider>FirstRun makes a fresh
|
|
28
|
+
// home launch-ready non-interactively, guaranteeing three things:
|
|
29
|
+
// 1. host auth is linked in,
|
|
30
|
+
// 2. first-run / onboarding flags are pre-accepted (where the provider has them),
|
|
31
|
+
// 3. the cwd workspace is trusted.
|
|
32
|
+
// Workspace trust is independent of repoInstructions: "ignore" means don't load
|
|
33
|
+
// the repo's CLAUDE.md / AGENTS.md as behavioral instructions, NOT "don't
|
|
34
|
+
// operate here" — an isolated agent must still read/edit/execute in its own cwd.
|
|
35
|
+
// (claude-rig / host-base launches dodge all of this via onboarded host config +
|
|
36
|
+
// --dangerously-skip-permissions; only isolated homes need the bootstrap.)
|
|
37
|
+
|
|
20
38
|
export function prepareCodexProfileHome(config: RunnerSpawnConfig): ProviderHome | undefined {
|
|
21
39
|
if (!profileRequiresIsolatedHome(config)) return undefined;
|
|
22
40
|
const target = providerHomePath("codex", config);
|
|
23
41
|
mkdirSync(target, { recursive: true });
|
|
24
|
-
|
|
42
|
+
return { path: target, authLinked: bootstrapCodexFirstRun(target, config) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function bootstrapCodexFirstRun(codexHome: string, config: RunnerSpawnConfig): string[] {
|
|
46
|
+
trustWorkspaceForCodex(codexHome, config);
|
|
25
47
|
const sourceHome = process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
26
|
-
|
|
27
|
-
return { path: target, authLinked };
|
|
48
|
+
return linkExistingAuthItems(sourceHome, codexHome, ["auth.json", "installation_id"]);
|
|
28
49
|
}
|
|
29
50
|
|
|
30
51
|
export function prepareClaudeProfileHome(config: RunnerSpawnConfig): ProviderHome | undefined {
|
|
@@ -35,9 +56,44 @@ export function prepareClaudeProfileHome(config: RunnerSpawnConfig): ProviderHom
|
|
|
35
56
|
// surface. An isolated-research profile (relay.context disabled) must not get
|
|
36
57
|
// agent-relay communication instructions written into its config home.
|
|
37
58
|
if (profileAllowsRelayFeature(config, "context")) writeClaudeRelayManual(target);
|
|
59
|
+
return { path: target, authLinked: bootstrapClaudeFirstRun(target, config) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function bootstrapClaudeFirstRun(claudeHome: string, config: RunnerSpawnConfig): string[] {
|
|
63
|
+
seedClaudeConfigIfMissing(claudeHome, config);
|
|
38
64
|
const sourceHome = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
39
|
-
|
|
40
|
-
|
|
65
|
+
return linkExistingAuthItems(sourceHome, claudeHome, [".credentials.json", "statsig"]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function seedClaudeConfigIfMissing(claudeHome: string, config: RunnerSpawnConfig): void {
|
|
69
|
+
const path = join(claudeHome, ".claude.json");
|
|
70
|
+
if (existsSync(path)) return;
|
|
71
|
+
const host = readHostClaudeConfig();
|
|
72
|
+
const seed: Record<string, unknown> = {
|
|
73
|
+
hasCompletedOnboarding: true,
|
|
74
|
+
theme: typeof host?.theme === "string" ? host.theme : "dark",
|
|
75
|
+
};
|
|
76
|
+
if (typeof host?.lastOnboardingVersion === "string") seed.lastOnboardingVersion = host.lastOnboardingVersion;
|
|
77
|
+
// Open approval = headless launch adds --dangerously-skip-permissions, which has
|
|
78
|
+
// its OWN first-run gate ("Bypass Permissions mode" accept/exit). Pre-accept it,
|
|
79
|
+
// or the agent stalls there exactly like the onboarding/trust gates.
|
|
80
|
+
if (config.approvalMode === "open") seed.bypassPermissionsModeAccepted = true;
|
|
81
|
+
if (config.cwd) {
|
|
82
|
+
seed.projects = {
|
|
83
|
+
[resolve(config.cwd)]: { hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
writeFileSync(path, JSON.stringify(seed, null, 2), { mode: 0o600 });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readHostClaudeConfig(): Record<string, unknown> | undefined {
|
|
90
|
+
try {
|
|
91
|
+
const hostPath = join(homedir(), ".claude.json");
|
|
92
|
+
if (!existsSync(hostPath)) return undefined;
|
|
93
|
+
return JSON.parse(readFileSync(hostPath, "utf8")) as Record<string, unknown>;
|
|
94
|
+
} catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
41
97
|
}
|
|
42
98
|
|
|
43
99
|
function providerHomePath(provider: "claude" | "codex", config: RunnerSpawnConfig): string {
|
|
@@ -63,8 +119,8 @@ function linkExistingAuthItems(sourceHome: string, targetHome: string, items: st
|
|
|
63
119
|
return linked;
|
|
64
120
|
}
|
|
65
121
|
|
|
66
|
-
function
|
|
67
|
-
if (config.
|
|
122
|
+
function trustWorkspaceForCodex(codexHome: string, config: RunnerSpawnConfig): void {
|
|
123
|
+
if (!config.cwd) return;
|
|
68
124
|
const path = join(codexHome, "config.toml");
|
|
69
125
|
const project = tomlBasicString(resolve(config.cwd));
|
|
70
126
|
const section = `[projects.${project}]`;
|
package/src/runner.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { hostname } from "node:os";
|
|
2
2
|
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
4
5
|
import type { AgentProfile, ContextState, Message, ProviderCapabilities, TaskStatusInput, WorkspaceMetadata } from "agent-relay-sdk";
|
|
5
6
|
import { RelayBusClient, RelayHttpClient } from "agent-relay-sdk";
|
|
@@ -8,9 +9,11 @@ import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissio
|
|
|
8
9
|
import { messagesWithCachedAttachments } from "./attachment-cache";
|
|
9
10
|
import { ClaimTracker } from "./claim-tracker";
|
|
10
11
|
import { startControlServer, type ControlServer } from "./control-server";
|
|
12
|
+
import { extractLastAssistantTurn } from "./adapters/claude-transcript";
|
|
11
13
|
import { agentProfileProjectionReport } from "./profile-projection";
|
|
12
14
|
import { profileUsesHostProviderGlobals } from "./profile-home";
|
|
13
15
|
import { runtimeMetadata } from "./version";
|
|
16
|
+
import { ensureSessionScratch, reapSessionScratch, sweepStaleSessions, type SessionScratchLayout } from "./session-scratch";
|
|
14
17
|
|
|
15
18
|
interface RunnerOptions {
|
|
16
19
|
provider: string;
|
|
@@ -99,6 +102,8 @@ export class AgentRunner {
|
|
|
99
102
|
private readonly pendingMessages = new Map<number, Message>();
|
|
100
103
|
private readonly activeTaskClaims = new Map<number, ActiveTaskClaim>();
|
|
101
104
|
private pendingTimelineEvent?: { status: string; id?: string; timestamp: number };
|
|
105
|
+
private pendingPromptMessageId?: number;
|
|
106
|
+
private scratch?: SessionScratchLayout;
|
|
102
107
|
|
|
103
108
|
constructor(private readonly options: RunnerOptions) {
|
|
104
109
|
this.agentId = options.agentId ?? options.runnerId;
|
|
@@ -177,6 +182,7 @@ export class AgentRunner {
|
|
|
177
182
|
onStatus: (status) => this.setProviderStatus(status),
|
|
178
183
|
onTerminalAttachSpec: () => this.terminalAttachSpec(),
|
|
179
184
|
onReplyObligations: () => this.http.listReplyObligations(this.agentId),
|
|
185
|
+
onSessionTurn: (input) => this.publishSessionTurn(input),
|
|
180
186
|
});
|
|
181
187
|
this.writeRunnerInfoFile();
|
|
182
188
|
this.options.adapter.onStatusChange((status) => {
|
|
@@ -195,6 +201,8 @@ export class AgentRunner {
|
|
|
195
201
|
});
|
|
196
202
|
this.bus.on("error", (code, message) => this.handleBusError(String(code), String(message)));
|
|
197
203
|
await this.bus.connect();
|
|
204
|
+
this.ensureScratch();
|
|
205
|
+
void this.sweepStaleScratch();
|
|
198
206
|
this.process = await this.spawnProvider();
|
|
199
207
|
this.writeRunnerInfoFile();
|
|
200
208
|
this.processStartedAt = Date.now();
|
|
@@ -210,6 +218,11 @@ export class AgentRunner {
|
|
|
210
218
|
async stop(): Promise<void> {
|
|
211
219
|
const alreadyStopped = this.stopped;
|
|
212
220
|
this.stopped = true;
|
|
221
|
+
reapSessionScratch({
|
|
222
|
+
agentId: this.agentId,
|
|
223
|
+
cwd: this.options.cwd,
|
|
224
|
+
fallbackBaseDir: process.env.AGENT_RELAY_ORCHESTRATOR_BASE_DIR,
|
|
225
|
+
});
|
|
213
226
|
if (!alreadyStopped) await this.bus.statusAsync({ agentStatus: "offline", ready: false });
|
|
214
227
|
if (this.process && !alreadyStopped) {
|
|
215
228
|
await this.options.adapter.shutdown(this.process, {
|
|
@@ -239,6 +252,7 @@ export class AgentRunner {
|
|
|
239
252
|
AGENT_RELAY_RUNNER_ID: this.options.runnerId,
|
|
240
253
|
AGENT_RELAY_PROVIDER_SESSION_ID: this.providerSessionId,
|
|
241
254
|
AGENT_RELAY_ID: this.agentId,
|
|
255
|
+
...(this.scratch ? { AGENT_RELAY_SCRATCH_DIR: this.scratch.tmpDir } : {}),
|
|
242
256
|
AGENT_RELAY_URL: this.options.relayUrl,
|
|
243
257
|
AGENT_RELAY_APPROVAL: this.options.approvalMode,
|
|
244
258
|
...(this.currentToken ? { AGENT_RELAY_TOKEN: this.currentToken } : {}),
|
|
@@ -397,7 +411,7 @@ export class AgentRunner {
|
|
|
397
411
|
private async handleCommand(type: string, params: Record<string, unknown>, commandId: string, command?: Record<string, unknown>): Promise<void> {
|
|
398
412
|
const target = typeof command?.target === "string" ? command.target : this.agentId;
|
|
399
413
|
if (target !== this.agentId && target !== this.options.runnerId) return;
|
|
400
|
-
if (type !== "agent.shutdown" && type !== "agent.restart" && type !== "agent.reconnect" && type !== "agent.kill" && type !== "agent.compact" && type !== "agent.clearContext" && type !== "agent.injectContext" && type !== "agent.permissionDecision") return;
|
|
414
|
+
if (type !== "agent.shutdown" && type !== "agent.restart" && type !== "agent.reconnect" && type !== "agent.kill" && type !== "agent.compact" && type !== "agent.clearContext" && type !== "agent.injectContext" && type !== "agent.permissionDecision" && type !== "prompt.inject") return;
|
|
401
415
|
|
|
402
416
|
const exitAfterCommand = type === "agent.shutdown" || type === "agent.kill";
|
|
403
417
|
if (exitAfterCommand) {
|
|
@@ -425,6 +439,8 @@ export class AgentRunner {
|
|
|
425
439
|
providerResult = await this.injectContext(params);
|
|
426
440
|
} else if (type === "agent.permissionDecision") {
|
|
427
441
|
providerResult = await this.respondToPermissionDecision(params);
|
|
442
|
+
} else if (type === "prompt.inject") {
|
|
443
|
+
providerResult = await this.injectPrompt(params);
|
|
428
444
|
} else await this.shutdownProvider(type === "agent.kill", commandTimeoutMs(params));
|
|
429
445
|
await this.updateCommand(commandId, "succeeded", {
|
|
430
446
|
action: type,
|
|
@@ -496,6 +512,17 @@ export class AgentRunner {
|
|
|
496
512
|
return { approvalId, decision, ...(result ? { providerResult: result } : {}) };
|
|
497
513
|
}
|
|
498
514
|
|
|
515
|
+
private async injectPrompt(params: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
516
|
+
const body = typeof params.body === "string" ? params.body : "";
|
|
517
|
+
if (!body) throw new Error("body required");
|
|
518
|
+
if (!this.process) throw new Error("provider process is unavailable");
|
|
519
|
+
if (!this.options.adapter.deliverInitialPrompt) throw new Error("provider does not support prompt injection");
|
|
520
|
+
const messageId = typeof params.messageId === "number" ? params.messageId : undefined;
|
|
521
|
+
if (messageId) this.pendingPromptMessageId = messageId;
|
|
522
|
+
await this.options.adapter.deliverInitialPrompt(this.process, body);
|
|
523
|
+
return { injected: true, messageId };
|
|
524
|
+
}
|
|
525
|
+
|
|
499
526
|
private async restartProvider(): Promise<void> {
|
|
500
527
|
this.restartInProgress = true;
|
|
501
528
|
try {
|
|
@@ -632,6 +659,52 @@ export class AgentRunner {
|
|
|
632
659
|
this.publishStatus();
|
|
633
660
|
}
|
|
634
661
|
|
|
662
|
+
// Phase 1 live-session lane: capture the assistant turn from the Claude
|
|
663
|
+
// transcript and post it as an observed "session" message so it shows in the
|
|
664
|
+
// dashboard chat with zero agent tokens. Posting it as a reply to the
|
|
665
|
+
// triggering message also clears the reply obligation, so the Stop hook no
|
|
666
|
+
// longer nags the agent to /reply — which is what made it re-emit before.
|
|
667
|
+
private async publishSessionTurn(input: { transcriptPath: string }): Promise<void> {
|
|
668
|
+
// Find the triggering message to reply to: either a pending prompt injection
|
|
669
|
+
// (Phase 2 direct lane) or a reply obligation from the dashboard human.
|
|
670
|
+
let replyToMessageId: number | undefined;
|
|
671
|
+
const pendingPrompt = this.pendingPromptMessageId;
|
|
672
|
+
if (pendingPrompt) {
|
|
673
|
+
replyToMessageId = pendingPrompt;
|
|
674
|
+
this.pendingPromptMessageId = undefined;
|
|
675
|
+
} else {
|
|
676
|
+
try {
|
|
677
|
+
const obligations = await this.http.listReplyObligations(this.agentId);
|
|
678
|
+
const obligation = [...obligations].reverse().find((o) => o.from === "user");
|
|
679
|
+
replyToMessageId = obligation?.messageId;
|
|
680
|
+
} catch {
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (!replyToMessageId) return;
|
|
685
|
+
|
|
686
|
+
let jsonl: string;
|
|
687
|
+
try {
|
|
688
|
+
jsonl = await readFile(input.transcriptPath, "utf8");
|
|
689
|
+
} catch {
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const body = extractLastAssistantTurn(jsonl);
|
|
693
|
+
if (!body) return;
|
|
694
|
+
|
|
695
|
+
try {
|
|
696
|
+
await this.http.sendMessage({
|
|
697
|
+
from: this.agentId,
|
|
698
|
+
to: "user",
|
|
699
|
+
replyTo: replyToMessageId,
|
|
700
|
+
kind: "session",
|
|
701
|
+
body,
|
|
702
|
+
});
|
|
703
|
+
} catch (error) {
|
|
704
|
+
this.logRunnerDiagnostic(`session turn capture failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
635
708
|
private publishStatus(): void {
|
|
636
709
|
const status = this.claims.currentStatus();
|
|
637
710
|
const agentStatus = runnerAgentStatus(status);
|
|
@@ -735,6 +808,33 @@ export class AgentRunner {
|
|
|
735
808
|
}
|
|
736
809
|
}
|
|
737
810
|
|
|
811
|
+
private ensureScratch(): void {
|
|
812
|
+
try {
|
|
813
|
+
this.scratch = ensureSessionScratch({
|
|
814
|
+
agentId: this.agentId,
|
|
815
|
+
cwd: this.options.cwd,
|
|
816
|
+
fallbackBaseDir: process.env.AGENT_RELAY_ORCHESTRATOR_BASE_DIR,
|
|
817
|
+
});
|
|
818
|
+
} catch (error) {
|
|
819
|
+
this.logRunnerDiagnostic(`session scratch setup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
private async sweepStaleScratch(): Promise<void> {
|
|
824
|
+
try {
|
|
825
|
+
const agents = await this.http.listAgents().catch(() => [] as Awaited<ReturnType<RelayHttpClient["listAgents"]>>);
|
|
826
|
+
const keep = new Set(agents.map((a) => a.id));
|
|
827
|
+
keep.add(this.agentId);
|
|
828
|
+
const removed = sweepStaleSessions({
|
|
829
|
+
cwd: this.options.cwd,
|
|
830
|
+
fallbackBaseDir: process.env.AGENT_RELAY_ORCHESTRATOR_BASE_DIR,
|
|
831
|
+
keepAgentIds: keep,
|
|
832
|
+
now: Date.now(),
|
|
833
|
+
});
|
|
834
|
+
if (removed.length) this.logRunnerDiagnostic(`swept ${removed.length} stale session scratch dir(s)`);
|
|
835
|
+
} catch {}
|
|
836
|
+
}
|
|
837
|
+
|
|
738
838
|
private scheduleRuntimeTokenRenewal(delayMs?: number): void {
|
|
739
839
|
if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
|
|
740
840
|
this.tokenRenewTimer = undefined;
|
|
@@ -1069,6 +1169,10 @@ function runtimeProviderCapabilities(options: RunnerOptions, contextStats?: { so
|
|
|
1069
1169
|
},
|
|
1070
1170
|
...runtimeProviderContextCapabilities(options, contextStats),
|
|
1071
1171
|
...runtimeProviderTerminalCapabilities(options),
|
|
1172
|
+
liveSession: {
|
|
1173
|
+
capture: true,
|
|
1174
|
+
inject: Boolean(options.adapter.deliverInitialPrompt),
|
|
1175
|
+
},
|
|
1072
1176
|
source: "runtime",
|
|
1073
1177
|
confidence: "reported",
|
|
1074
1178
|
lastUpdatedAt: options.startedAt,
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { accessSync, appendFileSync, constants, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const SCRATCH_DIR_NAME = ".agent-relay";
|
|
6
|
+
// The local-ignore entry. Leading + trailing slash scopes it to the dir at the
|
|
7
|
+
// base, matching git's gitignore semantics.
|
|
8
|
+
const EXCLUDE_ENTRY = "/.agent-relay/";
|
|
9
|
+
|
|
10
|
+
export interface SessionScratchLayout {
|
|
11
|
+
baseDir: string; // dir that contains .agent-relay (cwd, or fallback base dir)
|
|
12
|
+
rootDir: string; // <base>/.agent-relay
|
|
13
|
+
sessionsDir: string; // <base>/.agent-relay/sessions
|
|
14
|
+
sessionDir: string; // <base>/.agent-relay/sessions/<id>
|
|
15
|
+
tmpDir: string; // <base>/.agent-relay/sessions/<id>/tmp
|
|
16
|
+
replyFile: string; // <tmp>/reply.md
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SessionScratchTarget {
|
|
20
|
+
agentId: string;
|
|
21
|
+
cwd: string;
|
|
22
|
+
// Orchestrator base dir, used only when cwd is not writable. NEVER home — a
|
|
23
|
+
// home fallback would place scratch above the orchestrator base dir and break
|
|
24
|
+
// cwd containment.
|
|
25
|
+
fallbackBaseDir?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isWritableDir(dir: string): boolean {
|
|
29
|
+
try {
|
|
30
|
+
if (!statSync(dir).isDirectory()) return false;
|
|
31
|
+
accessSync(dir, constants.W_OK);
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Prefer cwd; fall back to the orchestrator base dir only when cwd is not a
|
|
39
|
+
// writable dir. Returns cwd as a last resort so callers can still attempt mkdir
|
|
40
|
+
// (and surface the real error) rather than silently picking an unrelated path.
|
|
41
|
+
export function resolveScratchBase(cwd: string, fallbackBaseDir?: string): string {
|
|
42
|
+
if (isWritableDir(cwd)) return cwd;
|
|
43
|
+
if (fallbackBaseDir && isWritableDir(fallbackBaseDir)) return fallbackBaseDir;
|
|
44
|
+
return cwd;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function sessionScratchLayout(baseDir: string, agentId: string): SessionScratchLayout {
|
|
48
|
+
const rootDir = join(baseDir, SCRATCH_DIR_NAME);
|
|
49
|
+
const sessionsDir = join(rootDir, "sessions");
|
|
50
|
+
const sessionDir = join(sessionsDir, agentId);
|
|
51
|
+
const tmpDir = join(sessionDir, "tmp");
|
|
52
|
+
return { baseDir, rootDir, sessionsDir, sessionDir, tmpDir, replyFile: join(tmpDir, "reply.md") };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Resolve git's exclude file for baseDir, correctly handling plain repos,
|
|
56
|
+
// worktrees, and submodules (git rev-parse returns the shared commondir's
|
|
57
|
+
// info/exclude). Returns null when baseDir is not inside a git work tree.
|
|
58
|
+
function gitExcludePath(baseDir: string): string | null {
|
|
59
|
+
try {
|
|
60
|
+
const out = execFileSync("git", ["-C", baseDir, "rev-parse", "--git-path", "info/exclude"], {
|
|
61
|
+
encoding: "utf8",
|
|
62
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
63
|
+
}).trim();
|
|
64
|
+
if (!out) return null;
|
|
65
|
+
return isAbsolute(out) ? out : resolve(baseDir, out);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Idempotently append EXCLUDE_ENTRY to an ignore file (grep-before-write).
|
|
72
|
+
function appendIgnoreEntry(file: string): void {
|
|
73
|
+
let existing = "";
|
|
74
|
+
try {
|
|
75
|
+
existing = readFileSync(file, "utf8");
|
|
76
|
+
} catch {}
|
|
77
|
+
const wanted = EXCLUDE_ENTRY.trim();
|
|
78
|
+
if (existing.split("\n").some((line) => line.trim() === wanted)) return;
|
|
79
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
80
|
+
const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
81
|
+
appendFileSync(file, `${prefix}${EXCLUDE_ENTRY}\n`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Ensure `.agent-relay/` is locally ignored. Prefers .git/info/exclude (git's
|
|
85
|
+
// local, uncommitted ignore list) so no tracked file is ever modified; falls
|
|
86
|
+
// back to .gitignore only when baseDir is not a git repo. Returns which file was
|
|
87
|
+
// used (for logging/tests).
|
|
88
|
+
export function ensureScratchIgnored(baseDir: string): "exclude" | "gitignore" {
|
|
89
|
+
const excludePath = gitExcludePath(baseDir);
|
|
90
|
+
if (excludePath) {
|
|
91
|
+
appendIgnoreEntry(excludePath);
|
|
92
|
+
return "exclude";
|
|
93
|
+
}
|
|
94
|
+
appendIgnoreEntry(join(baseDir, ".gitignore"));
|
|
95
|
+
return "gitignore";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// SessionStart: create the session tmp dir and ensure local ignore. Dir
|
|
99
|
+
// creation is the load-bearing part; ignore setup is best-effort.
|
|
100
|
+
export function ensureSessionScratch(target: SessionScratchTarget): SessionScratchLayout {
|
|
101
|
+
const base = resolveScratchBase(target.cwd, target.fallbackBaseDir);
|
|
102
|
+
const layout = sessionScratchLayout(base, target.agentId);
|
|
103
|
+
mkdirSync(layout.tmpDir, { recursive: true });
|
|
104
|
+
try {
|
|
105
|
+
ensureScratchIgnored(base);
|
|
106
|
+
} catch {
|
|
107
|
+
// best-effort: a missing ignore entry never pollutes git because the dir is
|
|
108
|
+
// already created and the entry is the only thing at risk.
|
|
109
|
+
}
|
|
110
|
+
return layout;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function dedupeBases(bases: Array<string | undefined>): string[] {
|
|
114
|
+
const seen = new Set<string>();
|
|
115
|
+
const out: string[] = [];
|
|
116
|
+
for (const b of bases) {
|
|
117
|
+
if (!b || seen.has(b)) continue;
|
|
118
|
+
seen.add(b);
|
|
119
|
+
out.push(b);
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// SessionEnd: remove this session's dir from every candidate base.
|
|
125
|
+
export function reapSessionScratch(target: SessionScratchTarget): void {
|
|
126
|
+
for (const base of dedupeBases([target.cwd, target.fallbackBaseDir])) {
|
|
127
|
+
const { sessionDir } = sessionScratchLayout(base, target.agentId);
|
|
128
|
+
try {
|
|
129
|
+
rmSync(sessionDir, { recursive: true, force: true });
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface SweepOptions {
|
|
135
|
+
cwd: string;
|
|
136
|
+
fallbackBaseDir?: string;
|
|
137
|
+
// Agent ids to keep (currently-known agents + self). Any session dir whose id
|
|
138
|
+
// is not in this set AND older than minAgeMs is removed.
|
|
139
|
+
keepAgentIds: Set<string>;
|
|
140
|
+
now: number;
|
|
141
|
+
minAgeMs?: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Periodic sweep of leftover session dirs from agents no longer known to the
|
|
145
|
+
// relay. The minAgeMs grace avoids racing a peer that just created its dir but
|
|
146
|
+
// has not yet appeared in the agent list.
|
|
147
|
+
export function sweepStaleSessions(opts: SweepOptions): string[] {
|
|
148
|
+
const minAge = opts.minAgeMs ?? 60 * 60 * 1000;
|
|
149
|
+
const removed: string[] = [];
|
|
150
|
+
for (const base of dedupeBases([opts.cwd, opts.fallbackBaseDir])) {
|
|
151
|
+
const sessionsDir = join(base, SCRATCH_DIR_NAME, "sessions");
|
|
152
|
+
let ids: string[];
|
|
153
|
+
try {
|
|
154
|
+
ids = readdirSync(sessionsDir);
|
|
155
|
+
} catch {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
for (const id of ids) {
|
|
159
|
+
if (opts.keepAgentIds.has(id)) continue;
|
|
160
|
+
const dir = join(sessionsDir, id);
|
|
161
|
+
try {
|
|
162
|
+
if (opts.now - statSync(dir).mtimeMs < minAge) continue;
|
|
163
|
+
rmSync(dir, { recursive: true, force: true });
|
|
164
|
+
removed.push(dir);
|
|
165
|
+
} catch {}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return removed;
|
|
169
|
+
}
|