agent-relay-runner 0.127.4 → 0.127.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/adapter.ts +82 -4
- package/src/adapters/claude-permission.ts +191 -0
- package/src/{claude-prompt-gates.ts → adapters/claude-prompt-gates.ts} +11 -5
- package/src/adapters/claude-session-capture.ts +235 -0
- package/src/adapters/claude.ts +18 -26
- package/src/{codex-version.ts → adapters/codex-version.ts} +1 -1
- package/src/adapters/{claude-quota-harvest.ts → provider-quota-harvest.ts} +3 -3
- package/src/control-server.ts +102 -267
- package/src/launch-assembly.ts +2 -2
- package/src/mcp-outbox.ts +54 -4
- package/src/pending-permission-store.ts +148 -0
- package/src/profile-home.ts +1 -1
- package/src/runner-core.ts +134 -322
- package/src/runner-helpers.ts +36 -1
package/src/mcp-outbox.ts
CHANGED
|
@@ -68,8 +68,15 @@ export async function deliverRunnerOutboxEvent(input: RunnerOutboxDelivery): Pro
|
|
|
68
68
|
logger.warn("outbox", `dropping event with unknown kind: ${record.kind}`);
|
|
69
69
|
} catch (error) {
|
|
70
70
|
if (isHttpStatusError(error, 409)) {
|
|
71
|
+
const reason = errMessage(error);
|
|
71
72
|
if (record.kind === "session-message-batch") {
|
|
72
|
-
|
|
73
|
+
const messages = sessionBatchMessages(record);
|
|
74
|
+
logger.error("outbox", `relay reported duplicate session batch; acking row seq=${record.seq} batchKey=${record.idempotencyKey} itemKeys=${sessionBatchItemKeys(record).join(",")} error=${reason}`);
|
|
75
|
+
await emitMirrorDropNotice(input, messages, messages.length, `duplicate idempotency key: ${reason}`, "duplicate-batch");
|
|
76
|
+
} else if (record.kind === "session-message") {
|
|
77
|
+
const message = record.payload as SendMessageInput;
|
|
78
|
+
logger.error("outbox", `relay reported duplicate session message; acking row seq=${record.seq} key=${record.idempotencyKey} error=${reason}`);
|
|
79
|
+
await emitMirrorDropNotice(input, [message], 1, `duplicate idempotency key: ${reason}`, "duplicate-message");
|
|
73
80
|
}
|
|
74
81
|
return;
|
|
75
82
|
}
|
|
@@ -79,14 +86,20 @@ export async function deliverRunnerOutboxEvent(input: RunnerOutboxDelivery): Pro
|
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
async function deliverSessionBatchItems(input: RunnerOutboxDelivery, messages: SendMessageInput[]): Promise<void> {
|
|
89
|
+
const dropped: Array<{ message: SendMessageInput; reason: string }> = [];
|
|
82
90
|
for (const message of messages) {
|
|
83
91
|
try {
|
|
84
92
|
await input.http.sendMessage(message);
|
|
85
93
|
} catch (error) {
|
|
86
94
|
if (!isDeterministicValidationError(error)) throw error;
|
|
87
|
-
|
|
95
|
+
const reason = errMessage(error);
|
|
96
|
+
logger.error("outbox", `dropping invalid session batch item key=${message.idempotencyKey ?? "(none)"} error=${reason}`);
|
|
97
|
+
dropped.push({ message, reason });
|
|
88
98
|
}
|
|
89
99
|
}
|
|
100
|
+
for (const item of dropped) {
|
|
101
|
+
await emitMirrorDropNotice(input, [item.message], 1, item.reason, "invalid-item");
|
|
102
|
+
}
|
|
90
103
|
}
|
|
91
104
|
|
|
92
105
|
function isDeterministicValidationError(error: unknown): boolean {
|
|
@@ -95,10 +108,47 @@ function isDeterministicValidationError(error: unknown): boolean {
|
|
|
95
108
|
}
|
|
96
109
|
|
|
97
110
|
function sessionBatchItemKeys(record: OutboxRecord): string[] {
|
|
98
|
-
const messages =
|
|
111
|
+
const messages = sessionBatchMessages(record);
|
|
112
|
+
return messages.map((message, index) => message.idempotencyKey ?? `${record.idempotencyKey}:${index}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sessionBatchMessages(record: OutboxRecord): SendMessageInput[] {
|
|
116
|
+
return Array.isArray((record.payload as { messages?: unknown }).messages)
|
|
99
117
|
? (record.payload as { messages: SendMessageInput[] }).messages
|
|
100
118
|
: [];
|
|
101
|
-
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function emitMirrorDropNotice(input: RunnerOutboxDelivery, messages: SendMessageInput[], count: number, reason: string, suffix: string): Promise<void> {
|
|
122
|
+
if (count <= 0) return;
|
|
123
|
+
const anchor = messages[0];
|
|
124
|
+
const from = typeof anchor?.from === "string" && anchor.from ? anchor.from : "system";
|
|
125
|
+
const to = typeof anchor?.to === "string" && anchor.to ? anchor.to : "user";
|
|
126
|
+
const occurredAt = typeof anchor?.occurredAt === "number" ? anchor.occurredAt : input.record.occurredAt;
|
|
127
|
+
const body = `${count} steps could not be mirrored: ${reason}`;
|
|
128
|
+
const idempotencyKey = `session-mirror-drop:${input.record.idempotencyKey}:${suffix}:${shortHash(body)}`;
|
|
129
|
+
logger.error("outbox", `session mirror drop notice seq=${input.record.seq} count=${count} reason=${reason} noticeKey=${idempotencyKey}`);
|
|
130
|
+
try {
|
|
131
|
+
await input.http.sendMessage({
|
|
132
|
+
from,
|
|
133
|
+
to,
|
|
134
|
+
kind: "session",
|
|
135
|
+
body,
|
|
136
|
+
idempotencyKey,
|
|
137
|
+
occurredAt,
|
|
138
|
+
payload: { session: { type: "notice", origin: "provider", label: "mirror-drop", stepId: idempotencyKey } },
|
|
139
|
+
});
|
|
140
|
+
} catch (error) {
|
|
141
|
+
logger.error("outbox", `failed to emit session mirror drop notice seq=${input.record.seq}; acking original row anyway: ${errMessage(error)}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function shortHash(value: string): string {
|
|
146
|
+
let h = 0x811c9dc5;
|
|
147
|
+
for (let i = 0; i < value.length; i++) {
|
|
148
|
+
h ^= value.charCodeAt(i);
|
|
149
|
+
h = Math.imul(h, 0x01000193);
|
|
150
|
+
}
|
|
151
|
+
return (h >>> 0).toString(36);
|
|
102
152
|
}
|
|
103
153
|
|
|
104
154
|
export async function deliverBufferedMcpCall(input: {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import type { InteractivePrompt } from "agent-relay-sdk";
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
7
|
+
import { runnerOutboxDirFromEnv } from "./config";
|
|
8
|
+
import { logger } from "./logger";
|
|
9
|
+
import { errMessage } from "agent-relay-sdk";
|
|
10
|
+
|
|
11
|
+
export interface PendingPermissionRequestRecord {
|
|
12
|
+
approvalId: string;
|
|
13
|
+
view: InteractivePrompt;
|
|
14
|
+
hookDeadlineAt: number;
|
|
15
|
+
ownerInstanceId: string;
|
|
16
|
+
occurredAt: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface PendingPermissionRow {
|
|
20
|
+
approval_id: string;
|
|
21
|
+
view: string;
|
|
22
|
+
hook_deadline_at: number;
|
|
23
|
+
owner_instance_id: string;
|
|
24
|
+
occurred_at: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class PendingPermissionStore {
|
|
28
|
+
private db?: Database;
|
|
29
|
+
readonly path: string;
|
|
30
|
+
|
|
31
|
+
constructor(options: { agentId: string; dir?: string }) {
|
|
32
|
+
const dir = options.dir ?? runnerOutboxDirFromEnv() ?? join(tmpdir(), "agent-relay-outbox");
|
|
33
|
+
this.path = options.dir === ":memory:" ? ":memory:" : join(dir, `pending-permissions-${safeName(options.agentId)}.sqlite`);
|
|
34
|
+
try {
|
|
35
|
+
if (this.path !== ":memory:") mkdirSync(dirname(this.path), { recursive: true });
|
|
36
|
+
const db = new Database(this.path, { create: true });
|
|
37
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
38
|
+
db.exec("PRAGMA busy_timeout = 2000");
|
|
39
|
+
db.exec(`
|
|
40
|
+
CREATE TABLE IF NOT EXISTS pending_permission_requests (
|
|
41
|
+
approval_id TEXT PRIMARY KEY,
|
|
42
|
+
view TEXT NOT NULL,
|
|
43
|
+
hook_deadline_at INTEGER NOT NULL,
|
|
44
|
+
owner_instance_id TEXT NOT NULL,
|
|
45
|
+
occurred_at INTEGER NOT NULL,
|
|
46
|
+
created_at INTEGER NOT NULL
|
|
47
|
+
)
|
|
48
|
+
`);
|
|
49
|
+
this.db = db;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
logger.error("pending-permission-store", `disabled after open failed path=${this.path}: ${errMessage(error)}`);
|
|
52
|
+
this.db = undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
upsert(record: PendingPermissionRequestRecord): void {
|
|
57
|
+
if (!this.db) return;
|
|
58
|
+
try {
|
|
59
|
+
this.db.query(`
|
|
60
|
+
INSERT INTO pending_permission_requests (approval_id, view, hook_deadline_at, owner_instance_id, occurred_at, created_at)
|
|
61
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
62
|
+
ON CONFLICT(approval_id) DO UPDATE SET
|
|
63
|
+
view = excluded.view,
|
|
64
|
+
hook_deadline_at = excluded.hook_deadline_at,
|
|
65
|
+
owner_instance_id = excluded.owner_instance_id,
|
|
66
|
+
occurred_at = excluded.occurred_at
|
|
67
|
+
`).run(record.approvalId, JSON.stringify(record.view), record.hookDeadlineAt, record.ownerInstanceId, record.occurredAt, Date.now());
|
|
68
|
+
} catch (error) {
|
|
69
|
+
logger.error("pending-permission-store", `upsert failed approvalId=${record.approvalId}; continuing in-memory only: ${errMessage(error)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
delete(approvalId: string): void {
|
|
74
|
+
if (!this.db) return;
|
|
75
|
+
try {
|
|
76
|
+
this.db.query("DELETE FROM pending_permission_requests WHERE approval_id = ?").run(approvalId);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
logger.error("pending-permission-store", `delete failed approvalId=${approvalId}; continuing in-memory only: ${errMessage(error)}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
list(): PendingPermissionRequestRecord[] {
|
|
83
|
+
if (!this.db) return [];
|
|
84
|
+
try {
|
|
85
|
+
const rows = this.db.query("SELECT * FROM pending_permission_requests ORDER BY occurred_at ASC").all() as PendingPermissionRow[];
|
|
86
|
+
return rows.map((row) => ({
|
|
87
|
+
approvalId: row.approval_id,
|
|
88
|
+
view: safeParseView(row.view),
|
|
89
|
+
hookDeadlineAt: row.hook_deadline_at,
|
|
90
|
+
ownerInstanceId: row.owner_instance_id,
|
|
91
|
+
occurredAt: row.occurred_at,
|
|
92
|
+
}));
|
|
93
|
+
} catch (error) {
|
|
94
|
+
logger.error("pending-permission-store", `list failed; continuing without persisted approvals: ${errMessage(error)}`);
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
close(): void {
|
|
100
|
+
try { this.db?.close(); } catch { /* already closed */ }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function bestEffortListPendingPermissionRequests(store: PendingPermissionStore | undefined): PendingPermissionRequestRecord[] {
|
|
105
|
+
try {
|
|
106
|
+
return store?.list() ?? [];
|
|
107
|
+
} catch (error) {
|
|
108
|
+
logger.error("pending-permission-store", `list failed; continuing without persisted approvals: ${errMessage(error)}`);
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function bestEffortUpsertPendingPermissionRequest(store: PendingPermissionStore | undefined, record: PendingPermissionRequestRecord): void {
|
|
114
|
+
try {
|
|
115
|
+
store?.upsert(record);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
logger.error("pending-permission-store", `upsert failed approvalId=${record.approvalId}; continuing in-memory only: ${errMessage(error)}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function bestEffortDeletePendingPermissionRequest(store: PendingPermissionStore | undefined, approvalId: string): void {
|
|
122
|
+
try {
|
|
123
|
+
store?.delete(approvalId);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
logger.error("pending-permission-store", `delete failed approvalId=${approvalId}; continuing in-memory only: ${errMessage(error)}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function safeName(value: string): string {
|
|
130
|
+
return sanitizeFsName(value, { replacement: "_", maxLen: 180, fallback: "agent" });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function safeParseView(json: string): InteractivePrompt {
|
|
134
|
+
try {
|
|
135
|
+
const parsed = JSON.parse(json);
|
|
136
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as InteractivePrompt;
|
|
137
|
+
} catch {
|
|
138
|
+
// Fall through to a valid placeholder so recovery can still emit a notice and clear the row.
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
id: "unknown",
|
|
142
|
+
kind: "tool",
|
|
143
|
+
title: "Recovered permission request",
|
|
144
|
+
body: "The original permission request could not be decoded.",
|
|
145
|
+
choices: [],
|
|
146
|
+
reasoningSettled: false,
|
|
147
|
+
};
|
|
148
|
+
}
|
package/src/profile-home.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { getManifest } from "agent-relay-providers";
|
|
|
7
7
|
import { profileAllowsRelayFeature, type RunnerSpawnConfig } from "./adapter";
|
|
8
8
|
import { claudeRelayManual } from "./relay-instructions";
|
|
9
9
|
import { providerHomeRootFromEnv } from "./config";
|
|
10
|
-
import { applyClaudeConfigPromptGatePreventions } from "./claude-prompt-gates";
|
|
10
|
+
import { applyClaudeConfigPromptGatePreventions } from "./adapters/claude-prompt-gates";
|
|
11
11
|
|
|
12
12
|
type ProviderHome = {
|
|
13
13
|
path: string;
|