agent-relay-runner 0.10.19 → 0.10.21
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 +2 -2
- package/plugins/claude/.claude-plugin/plugin.json +4 -1
- package/plugins/claude/hooks/hooks.json +114 -0
- package/plugins/claude/hooks/permission-request.sh +20 -0
- package/plugins/claude/hooks/post-compact.sh +5 -0
- package/plugins/claude/hooks/pre-compact.sh +5 -0
- package/plugins/claude/hooks/relay-status.sh +66 -0
- package/plugins/claude/hooks/session-end.sh +16 -3
- package/plugins/claude/hooks/session-start.sh +14 -0
- package/plugins/claude/hooks/stop-failure.sh +15 -0
- package/plugins/claude/hooks/stop.sh +13 -3
- package/plugins/claude/hooks/subagent-start.sh +12 -0
- package/plugins/claude/hooks/subagent-stop.sh +12 -0
- package/plugins/claude/hooks/user-prompt-submit.sh +2 -3
- package/plugins/claude/monitors/relay-monitor.ts +16 -4
- package/plugins/claude/skills/react/SKILL.md +18 -0
- package/plugins/claude/skills/read-message/SKILL.md +24 -0
- package/plugins/claude/skills/reply/SKILL.md +7 -3
- package/plugins/codex/skills/guide/SKILL.md +15 -0
- package/plugins/codex/skills/react/SKILL.md +17 -0
- package/plugins/codex/skills/read-message/SKILL.md +23 -0
- package/plugins/codex/skills/reply/SKILL.md +6 -2
- package/src/adapter.ts +207 -6
- package/src/adapters/claude-delivery.ts +108 -0
- package/src/adapters/claude.ts +232 -31
- package/src/adapters/codex-client.ts +27 -1
- package/src/adapters/codex.ts +635 -26
- package/src/attachment-cache.ts +190 -0
- package/src/claim-tracker.ts +48 -5
- package/src/control-server.ts +193 -6
- package/src/index.ts +203 -6
- package/src/profile-home.ts +85 -0
- package/src/profile-projection.ts +146 -0
- package/src/relay-instructions.ts +25 -0
- package/src/runner.ts +811 -40
- package/src/version.ts +39 -0
package/src/adapter.ts
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
|
-
import type { Message } from "agent-relay-sdk";
|
|
1
|
+
import type { AgentProfile, Message } from "agent-relay-sdk";
|
|
2
2
|
|
|
3
3
|
export type SemanticStatus = "idle" | "busy" | "offline" | "error";
|
|
4
|
+
type ProviderWorkKind = "provider-turn" | "subagent";
|
|
5
|
+
|
|
6
|
+
export interface ProviderStatusEvent {
|
|
7
|
+
status: SemanticStatus;
|
|
8
|
+
providerSessionId?: string;
|
|
9
|
+
reason?: ProviderWorkKind;
|
|
10
|
+
clear?: ProviderWorkKind[];
|
|
11
|
+
timeline?: {
|
|
12
|
+
status: string;
|
|
13
|
+
id?: string;
|
|
14
|
+
timestamp?: number;
|
|
15
|
+
};
|
|
16
|
+
id?: string;
|
|
17
|
+
label?: string;
|
|
18
|
+
role?: string;
|
|
19
|
+
parentId?: string;
|
|
20
|
+
providerState?: Record<string, unknown>;
|
|
21
|
+
metadata?: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ProviderStatusUpdate = SemanticStatus | ProviderStatusEvent;
|
|
4
25
|
|
|
5
26
|
export interface ProviderConfig {
|
|
6
27
|
command: string;
|
|
@@ -18,6 +39,8 @@ export interface ProviderConfig {
|
|
|
18
39
|
|
|
19
40
|
export interface RunnerSpawnConfig {
|
|
20
41
|
provider: string;
|
|
42
|
+
model?: string;
|
|
43
|
+
effort?: string;
|
|
21
44
|
runnerId: string;
|
|
22
45
|
instanceId: string;
|
|
23
46
|
agentId: string;
|
|
@@ -27,7 +50,11 @@ export interface RunnerSpawnConfig {
|
|
|
27
50
|
approvalMode: string;
|
|
28
51
|
label?: string;
|
|
29
52
|
rig?: string;
|
|
53
|
+
profile?: string;
|
|
54
|
+
agentProfile?: AgentProfile;
|
|
30
55
|
prompt?: string;
|
|
56
|
+
systemPromptAppend?: string;
|
|
57
|
+
tmuxSession?: string;
|
|
31
58
|
providerArgs: string[];
|
|
32
59
|
providerConfig: ProviderConfig;
|
|
33
60
|
env: Record<string, string>;
|
|
@@ -51,20 +78,194 @@ export interface ManagedProcess {
|
|
|
51
78
|
meta?: Record<string, unknown>;
|
|
52
79
|
}
|
|
53
80
|
|
|
81
|
+
export interface TerminalAttachSpec {
|
|
82
|
+
mode: "guest";
|
|
83
|
+
provider: string;
|
|
84
|
+
cwd: string;
|
|
85
|
+
command: string[];
|
|
86
|
+
env?: Record<string, string>;
|
|
87
|
+
title?: string;
|
|
88
|
+
ttlMs?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type ProviderPermissionDecision = "approve" | "approve-session" | "deny" | "abort";
|
|
92
|
+
|
|
93
|
+
export interface ProviderPermissionDecisionInput {
|
|
94
|
+
approvalId: string;
|
|
95
|
+
decision: ProviderPermissionDecision;
|
|
96
|
+
reason?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
54
99
|
export interface ProviderAdapter {
|
|
55
100
|
provider: string;
|
|
56
101
|
spawn(config: RunnerSpawnConfig): Promise<ManagedProcess>;
|
|
57
102
|
shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void>;
|
|
103
|
+
compact?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
|
|
104
|
+
clearContext?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
|
|
105
|
+
terminalAttachSpec?(process: ManagedProcess): Promise<TerminalAttachSpec>;
|
|
106
|
+
respondToPermissionDecision?(process: ManagedProcess, input: ProviderPermissionDecisionInput): Promise<Record<string, unknown> | void>;
|
|
107
|
+
deliverInitialPrompt?(process: ManagedProcess, prompt: string): Promise<void>;
|
|
58
108
|
deliver(process: ManagedProcess, messages: Message[]): Promise<void>;
|
|
59
|
-
onStatusChange(cb: (status:
|
|
109
|
+
onStatusChange(cb: (status: ProviderStatusUpdate) => void): void;
|
|
110
|
+
// Headless providers with no tmux session (e.g. the Codex app-server) still
|
|
111
|
+
// warrant an automatic restart on unexpected exit. Returning true opts the
|
|
112
|
+
// provider into the runner's restart-with-backoff path.
|
|
113
|
+
supportsUnexpectedExitRestart?(): boolean;
|
|
60
114
|
buildSpawnArgs(config: RunnerSpawnConfig, providerConfig: ProviderConfig): SpawnArgs;
|
|
61
115
|
}
|
|
62
116
|
|
|
63
|
-
export function
|
|
117
|
+
export function profileAllowsRelayFeature(config: RunnerSpawnConfig, feature: keyof AgentProfile["relay"]): boolean {
|
|
118
|
+
return config.agentProfile?.relay?.[feature] !== false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const RELAY_CONTEXT = `[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval. If Relay MCP tools are available, prefer relay_reply, relay_get_message, relay_get_thread, relay_send_message, relay_upload_artifact, relay_attach_artifact, relay_agent_status, relay_spawn_agent, and relay_shutdown_agent. CLI fallback: agent-relay /reply <messageId> --stdin < response.md; if a delivered message says it was truncated, fetch the full body with: agent-relay get-message <messageId>. For command details, run: agent-relay /guide`;
|
|
122
|
+
|
|
123
|
+
const PROVIDER_MESSAGE_BODY_PREVIEW_CHARS = 4000;
|
|
124
|
+
|
|
125
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
126
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function attachmentRefs(message: Message): Record<string, unknown>[] {
|
|
130
|
+
const payloadRefs = message.payload?.attachments;
|
|
131
|
+
const topLevelRefs = (message as Message & { attachments?: unknown }).attachments;
|
|
132
|
+
const refs = Array.isArray(payloadRefs) ? payloadRefs : Array.isArray(topLevelRefs) ? topLevelRefs : [];
|
|
133
|
+
return refs.filter(isRecord);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function attachmentLabel(ref: Record<string, unknown>): string {
|
|
137
|
+
const parts = [
|
|
138
|
+
typeof ref.kind === "string" ? ref.kind : undefined,
|
|
139
|
+
typeof ref.role === "string" ? ref.role : undefined,
|
|
140
|
+
typeof ref.title === "string" ? `"${ref.title}"` : undefined,
|
|
141
|
+
].filter(Boolean);
|
|
142
|
+
return parts.length ? ` (${parts.join(", ")})` : "";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function attachmentCacheMetadata(ref: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
146
|
+
const metadata = isRecord(ref.metadata) ? ref.metadata : undefined;
|
|
147
|
+
const agentRelay = metadata && isRecord(metadata.agentRelay) ? metadata.agentRelay : undefined;
|
|
148
|
+
return agentRelay;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isMemoryInjection(message: Message): boolean {
|
|
152
|
+
return isRecord(message.payload) && message.payload.memoryInjection === true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function isReactionNotification(message: Message): boolean {
|
|
156
|
+
const event = isRecord(message.payload?.event) ? message.payload.event : undefined;
|
|
157
|
+
return message.payload?.reactionNotification === true || event?.type === "message.reaction";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isPersistedRelayMessage(message: Message): boolean {
|
|
161
|
+
return Number.isSafeInteger(message.id) && message.id > 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function latestReplyableMessage(messages: Message[]): Message | undefined {
|
|
64
165
|
return messages
|
|
166
|
+
.filter((message) => isPersistedRelayMessage(message) && !isMemoryInjection(message) && !isReactionNotification(message))
|
|
167
|
+
.at(-1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function providerSenderLabel(message: Message): string {
|
|
171
|
+
if (message.from === "user") return "human user";
|
|
172
|
+
const source = isRecord(message.payload?.source) ? message.payload.source : undefined;
|
|
173
|
+
const isHumanChannel = Boolean(source && (
|
|
174
|
+
isRecord(source.telegram) ||
|
|
175
|
+
isRecord(source.slack) ||
|
|
176
|
+
isRecord(source.discord)
|
|
177
|
+
));
|
|
178
|
+
return isHumanChannel ? `human user via ${message.from}` : message.from;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function providerAttachmentText(message: Message): string | undefined {
|
|
182
|
+
const refs = attachmentRefs(message);
|
|
183
|
+
if (!refs.length) return undefined;
|
|
184
|
+
const lines = refs.flatMap((ref) => {
|
|
185
|
+
const artifactId = typeof ref.artifactId === "string" ? ref.artifactId : "unknown-artifact";
|
|
186
|
+
const source = isRecord(ref.ref)
|
|
187
|
+
? [
|
|
188
|
+
typeof ref.ref.provider === "string" ? ref.ref.provider : undefined,
|
|
189
|
+
typeof ref.ref.id === "string" ? ref.ref.id : undefined,
|
|
190
|
+
].filter(Boolean).join(":")
|
|
191
|
+
: "";
|
|
192
|
+
const cache = attachmentCacheMetadata(ref);
|
|
193
|
+
const localPath = typeof cache?.localPath === "string" ? cache.localPath : undefined;
|
|
194
|
+
const cacheError = typeof cache?.cacheError === "string" ? cache.cacheError : undefined;
|
|
195
|
+
return [
|
|
196
|
+
`- ${artifactId}${attachmentLabel(ref)}${source ? ` source=${source}` : ""}`,
|
|
197
|
+
...(localPath ? [` local: ${localPath}`] : []),
|
|
198
|
+
...(cacheError ? [` cache error: ${cacheError}`] : []),
|
|
199
|
+
];
|
|
200
|
+
});
|
|
201
|
+
const hasLocalPaths = refs.some((ref) => typeof attachmentCacheMetadata(ref)?.localPath === "string");
|
|
202
|
+
const canFetchMessage = isPersistedRelayMessage(message);
|
|
203
|
+
const guidance = !canFetchMessage
|
|
204
|
+
? []
|
|
205
|
+
: hasLocalPaths
|
|
206
|
+
? [
|
|
207
|
+
"Use the local path above when inspecting the attachment.",
|
|
208
|
+
`Read attachment details: agent-relay get-message ${message.id} --json`,
|
|
209
|
+
]
|
|
210
|
+
: [
|
|
211
|
+
`Read attachment details: agent-relay get-message ${message.id} --json`,
|
|
212
|
+
`Fetch content: curl -fsS -H "X-Agent-Relay-Token: $AGENT_RELAY_TOKEN" "$AGENT_RELAY_URL/api/artifacts/<artifactId>/content" -o <filename>`,
|
|
213
|
+
];
|
|
214
|
+
return [
|
|
215
|
+
"Attachments:",
|
|
216
|
+
...lines,
|
|
217
|
+
...guidance,
|
|
218
|
+
].join("\n");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function providerMessageText(messages: Message[]): string {
|
|
222
|
+
const replyable = latestReplyableMessage(messages);
|
|
223
|
+
const sections = messages
|
|
65
224
|
.map((message) => {
|
|
66
225
|
const subject = message.subject ? `Subject: ${message.subject}\n` : "";
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
226
|
+
const isMemoryContext = isMemoryInjection(message);
|
|
227
|
+
const canReferenceMessage = isPersistedRelayMessage(message) && !isMemoryContext && !isReactionNotification(message);
|
|
228
|
+
const shouldPreview = canReferenceMessage && message.body.length > PROVIDER_MESSAGE_BODY_PREVIEW_CHARS;
|
|
229
|
+
const preview = shouldPreview
|
|
230
|
+
? {
|
|
231
|
+
body: message.body.slice(0, PROVIDER_MESSAGE_BODY_PREVIEW_CHARS),
|
|
232
|
+
truncated: true,
|
|
233
|
+
}
|
|
234
|
+
: {
|
|
235
|
+
body: message.body,
|
|
236
|
+
truncated: false,
|
|
237
|
+
};
|
|
238
|
+
const truncationGuidance = preview.truncated
|
|
239
|
+
? [
|
|
240
|
+
`[truncated: showing first ${PROVIDER_MESSAGE_BODY_PREVIEW_CHARS} of ${message.body.length} chars]`,
|
|
241
|
+
`Read full: agent-relay get-message ${message.id}`,
|
|
242
|
+
`Body only: agent-relay get-message ${message.id} --body`,
|
|
243
|
+
`Long reply: agent-relay /reply ${message.id} --stdin < response.md`,
|
|
244
|
+
].join("\n")
|
|
245
|
+
: undefined;
|
|
246
|
+
if (isMemoryContext) {
|
|
247
|
+
return [
|
|
248
|
+
`[agent-relay context from ${message.from}]`,
|
|
249
|
+
"Guide: agent-relay /guide",
|
|
250
|
+
providerAttachmentText(message),
|
|
251
|
+
`${subject}${preview.body}`,
|
|
252
|
+
].join("\n");
|
|
253
|
+
}
|
|
254
|
+
return [
|
|
255
|
+
`[relay message #${message.id} from ${providerSenderLabel(message)}]`,
|
|
256
|
+
"Guide: agent-relay /guide",
|
|
257
|
+
truncationGuidance,
|
|
258
|
+
providerAttachmentText(message),
|
|
259
|
+
`${subject}${preview.body}`,
|
|
260
|
+
].join("\n");
|
|
261
|
+
});
|
|
262
|
+
if (replyable) {
|
|
263
|
+
sections.push([
|
|
264
|
+
"[agent-relay reply reminder]",
|
|
265
|
+
`If this batch needs a response, send one useful reply to the latest relevant message: agent-relay /reply ${replyable.id} --stdin < response.md`,
|
|
266
|
+
"If you already delivered the useful response through Relay, do not send a separate status-only confirmation.",
|
|
267
|
+
"If multiple messages arrived together, cover them in one reply instead of answering each line separately.",
|
|
268
|
+
].join("\n"));
|
|
269
|
+
}
|
|
270
|
+
return sections.join("\n\n");
|
|
70
271
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Message } from "agent-relay-sdk";
|
|
2
|
+
import { providerAttachmentText } from "../adapter";
|
|
3
|
+
|
|
4
|
+
const PROVIDER_MESSAGE_BODY_PREVIEW_CHARS = 4000;
|
|
5
|
+
const REMINDER_EVERY_DELIVERIES = 5;
|
|
6
|
+
|
|
7
|
+
interface ClaudeDeliveryTextOptions {
|
|
8
|
+
deliveryCount: number;
|
|
9
|
+
readOnly?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
13
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isMemoryInjection(message: Message): boolean {
|
|
17
|
+
return isRecord(message.payload) && message.payload.memoryInjection === true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isReactionNotification(message: Message): boolean {
|
|
21
|
+
const event = isRecord(message.payload?.event) ? message.payload.event : undefined;
|
|
22
|
+
return message.payload?.reactionNotification === true || event?.type === "message.reaction";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isPersistedRelayMessage(message: Message): boolean {
|
|
26
|
+
return Number.isSafeInteger(message.id) && message.id > 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function providerSenderLabel(message: Message): string {
|
|
30
|
+
if (message.from === "user") return "human user";
|
|
31
|
+
const source = isRecord(message.payload?.source) ? message.payload.source : undefined;
|
|
32
|
+
const isHumanChannel = Boolean(source && (
|
|
33
|
+
isRecord(source.telegram) ||
|
|
34
|
+
isRecord(source.slack) ||
|
|
35
|
+
isRecord(source.discord)
|
|
36
|
+
));
|
|
37
|
+
return isHumanChannel ? `human user via ${message.from}` : message.from;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function shouldShowReplyReminder(deliveryCount: number): boolean {
|
|
41
|
+
return deliveryCount <= 1 || deliveryCount % REMINDER_EVERY_DELIVERIES === 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function latestReplyableMessage(messages: Message[]): Message | undefined {
|
|
45
|
+
return messages
|
|
46
|
+
.filter((message) => isPersistedRelayMessage(message) && !isMemoryInjection(message) && !isReactionNotification(message))
|
|
47
|
+
.at(-1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatMessage(message: Message): string {
|
|
51
|
+
const subject = message.subject ? `Subject: ${message.subject}\n` : "";
|
|
52
|
+
const isMemoryContext = isMemoryInjection(message);
|
|
53
|
+
const canFetchMessage = isPersistedRelayMessage(message);
|
|
54
|
+
const shouldPreview = canFetchMessage && message.body.length > PROVIDER_MESSAGE_BODY_PREVIEW_CHARS;
|
|
55
|
+
const preview = shouldPreview
|
|
56
|
+
? {
|
|
57
|
+
body: message.body.slice(0, PROVIDER_MESSAGE_BODY_PREVIEW_CHARS),
|
|
58
|
+
truncated: true,
|
|
59
|
+
}
|
|
60
|
+
: {
|
|
61
|
+
body: message.body,
|
|
62
|
+
truncated: false,
|
|
63
|
+
};
|
|
64
|
+
const truncationGuidance = preview.truncated
|
|
65
|
+
? [
|
|
66
|
+
`[truncated: showing first ${PROVIDER_MESSAGE_BODY_PREVIEW_CHARS} of ${message.body.length} chars]`,
|
|
67
|
+
`Read full: agent-relay get-message ${message.id}`,
|
|
68
|
+
`Body only: agent-relay get-message ${message.id} --body`,
|
|
69
|
+
].join("\n")
|
|
70
|
+
: undefined;
|
|
71
|
+
|
|
72
|
+
if (isMemoryContext) {
|
|
73
|
+
return [
|
|
74
|
+
`[agent-relay context from ${message.from}]`,
|
|
75
|
+
providerAttachmentText(message),
|
|
76
|
+
`${subject}${preview.body}`,
|
|
77
|
+
truncationGuidance,
|
|
78
|
+
].filter(Boolean).join("\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return [
|
|
82
|
+
`[relay message #${message.id} from ${providerSenderLabel(message)}]`,
|
|
83
|
+
providerAttachmentText(message),
|
|
84
|
+
`${subject}${preview.body}`,
|
|
85
|
+
truncationGuidance,
|
|
86
|
+
].filter(Boolean).join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function replyReminder(message: Message, readOnly: boolean): string {
|
|
90
|
+
const command = readOnly
|
|
91
|
+
? `agent-relay /reply ${message.id} "<your reply>"`
|
|
92
|
+
: `agent-relay /reply ${message.id} --stdin < response.md`;
|
|
93
|
+
return [
|
|
94
|
+
"[agent-relay reply reminder]",
|
|
95
|
+
`If this batch needs a response, send one useful reply to the latest relevant message: ${command}`,
|
|
96
|
+
"If you already delivered the useful response through Relay, do not send a separate status-only confirmation.",
|
|
97
|
+
"If multiple messages arrived together, cover them in one reply instead of answering each line separately.",
|
|
98
|
+
].join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function claudeProviderMessageText(messages: Message[], options: ClaudeDeliveryTextOptions): string {
|
|
102
|
+
const sections = messages.map(formatMessage);
|
|
103
|
+
const replyable = latestReplyableMessage(messages);
|
|
104
|
+
if (replyable && shouldShowReplyReminder(options.deliveryCount)) {
|
|
105
|
+
sections.push(replyReminder(replyable, options.readOnly === true));
|
|
106
|
+
}
|
|
107
|
+
return sections.join("\n\n");
|
|
108
|
+
}
|