agent-relay-server 0.122.0 → 0.123.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/docs/openapi.json +1 -1
- package/package.json +7 -8
- package/public/assets/{MessageBubble-DRdUD-kW.js → MessageBubble-BTeFExjj.js} +2 -2
- package/public/assets/{MessageBubble-DRdUD-kW.js.map → MessageBubble-BTeFExjj.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-siKMPCln.js → PlanGraphPanel-jU1DXg7w.js} +2 -2
- package/public/assets/{PlanGraphPanel-siKMPCln.js.map → PlanGraphPanel-jU1DXg7w.js.map} +1 -1
- package/public/assets/{activity-D3MNcSaV.js → activity-DUig2iYW.js} +2 -2
- package/public/assets/{activity-D3MNcSaV.js.map → activity-DUig2iYW.js.map} +1 -1
- package/public/assets/{agent-profiles-CKzttq6G.js → agent-profiles-D-oFmb6S.js} +2 -2
- package/public/assets/{agent-profiles-CKzttq6G.js.map → agent-profiles-D-oFmb6S.js.map} +1 -1
- package/public/assets/{agent-type-icon-0ZJRP1Vy.js → agent-type-icon-Dan2l3Tm.js} +2 -2
- package/public/assets/{agent-type-icon-0ZJRP1Vy.js.map → agent-type-icon-Dan2l3Tm.js.map} +1 -1
- package/public/assets/{agents-CuVj_rfG.js → agents-BkC6mcTh.js} +2 -2
- package/public/assets/{agents-CuVj_rfG.js.map → agents-BkC6mcTh.js.map} +1 -1
- package/public/assets/{analytics-iob6C27d.js → analytics-C3Dlmqe9.js} +2 -2
- package/public/assets/{analytics-iob6C27d.js.map → analytics-C3Dlmqe9.js.map} +1 -1
- package/public/assets/{automation-B2ixxs4q.js → automation-AeMZ70WQ.js} +2 -2
- package/public/assets/{automation-B2ixxs4q.js.map → automation-AeMZ70WQ.js.map} +1 -1
- package/public/assets/{branch-state-badge-CafBJWKo.js → branch-state-badge-CNW10MTC.js} +2 -2
- package/public/assets/{branch-state-badge-CafBJWKo.js.map → branch-state-badge-CNW10MTC.js.map} +1 -1
- package/public/assets/{channels-C-WsbL2H.js → channels-Bc_mEd56.js} +2 -2
- package/public/assets/{channels-C-WsbL2H.js.map → channels-Bc_mEd56.js.map} +1 -1
- package/public/assets/{chat-DeoVTVob.js → chat-BCNCuTgw.js} +2 -2
- package/public/assets/{chat-DeoVTVob.js.map → chat-BCNCuTgw.js.map} +1 -1
- package/public/assets/{connectors-C2Ac03LJ.js → connectors-h0d5Mc6e.js} +2 -2
- package/public/assets/{connectors-C2Ac03LJ.js.map → connectors-h0d5Mc6e.js.map} +1 -1
- package/public/assets/{display-CJzSoTMq.js → display-GFbGUG0O.js} +2 -2
- package/public/assets/{display-CJzSoTMq.js.map → display-GFbGUG0O.js.map} +1 -1
- package/public/assets/{formatted-body-35XBOY5L.js → formatted-body-CVr3Iev9.js} +3 -3
- package/public/assets/{formatted-body-35XBOY5L.js.map → formatted-body-CVr3Iev9.js.map} +1 -1
- package/public/assets/{formatted-body-impl-RmDxI5Ux.js → formatted-body-impl-Cq5SRGpm.js} +2 -2
- package/public/assets/{formatted-body-impl-RmDxI5Ux.js.map → formatted-body-impl-Cq5SRGpm.js.map} +1 -1
- package/public/assets/{index-D0xMQQb_.js → index--9UMzCFf.js} +6 -6
- package/public/assets/{index-D0xMQQb_.js.map → index--9UMzCFf.js.map} +1 -1
- package/public/assets/{insights-B643SFvm.js → insights-C8lIBXg3.js} +2 -2
- package/public/assets/{insights-B643SFvm.js.map → insights-C8lIBXg3.js.map} +1 -1
- package/public/assets/{integrations-hFIHeCF4.js → integrations-7OFsJtY7.js} +2 -2
- package/public/assets/{integrations-hFIHeCF4.js.map → integrations-7OFsJtY7.js.map} +1 -1
- package/public/assets/{maintenance-CURvdp_Z.js → maintenance-HDpg20OY.js} +2 -2
- package/public/assets/{maintenance-CURvdp_Z.js.map → maintenance-HDpg20OY.js.map} +1 -1
- package/public/assets/{managed-agents-DrZtdlSn.js → managed-agents-muCIMxng.js} +2 -2
- package/public/assets/{managed-agents-DrZtdlSn.js.map → managed-agents-muCIMxng.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-CVDO--ek.js → markdown-preview-impl-Cx7pCfA6.js} +2 -2
- package/public/assets/{markdown-preview-impl-CVDO--ek.js.map → markdown-preview-impl-Cx7pCfA6.js.map} +1 -1
- package/public/assets/{memory-Bm7dt_Qf.js → memory-CTO1FfJ-.js} +2 -2
- package/public/assets/{memory-Bm7dt_Qf.js.map → memory-CTO1FfJ-.js.map} +1 -1
- package/public/assets/{messages-DTq4i2KZ.js → messages-RWSXG2UM.js} +2 -2
- package/public/assets/{messages-DTq4i2KZ.js.map → messages-RWSXG2UM.js.map} +1 -1
- package/public/assets/{orchestrators-Dra318Ap.js → orchestrators-5aiYw3yx.js} +2 -2
- package/public/assets/{orchestrators-Dra318Ap.js.map → orchestrators-5aiYw3yx.js.map} +1 -1
- package/public/assets/{overview-yr1Vmk64.js → overview-Du6XLt5l.js} +2 -2
- package/public/assets/{overview-yr1Vmk64.js.map → overview-Du6XLt5l.js.map} +1 -1
- package/public/assets/{pairs-mnlKDBGp.js → pairs-BDRnlEta.js} +2 -2
- package/public/assets/{pairs-mnlKDBGp.js.map → pairs-BDRnlEta.js.map} +1 -1
- package/public/assets/{projects-Dzdf-gkK.js → projects-DwsWE9es.js} +2 -2
- package/public/assets/{projects-Dzdf-gkK.js.map → projects-DwsWE9es.js.map} +1 -1
- package/public/assets/{prompt-settings-ByL8SNCi.js → prompt-settings-CaJX_LbA.js} +2 -2
- package/public/assets/{prompt-settings-ByL8SNCi.js.map → prompt-settings-CaJX_LbA.js.map} +1 -1
- package/public/assets/{registry-CXWyOtpr.js → registry-C-uIL0Nl.js} +2 -2
- package/public/assets/{registry-CXWyOtpr.js.map → registry-C-uIL0Nl.js.map} +1 -1
- package/public/assets/{security-0SBsc_4W.js → security-BpVrdYhJ.js} +2 -2
- package/public/assets/{security-0SBsc_4W.js.map → security-BpVrdYhJ.js.map} +1 -1
- package/public/assets/{select-Bw5z2KJ_.js → select-DallbU93.js} +2 -2
- package/public/assets/{select-Bw5z2KJ_.js.map → select-DallbU93.js.map} +1 -1
- package/public/assets/{settings-78Cyq_tA.js → settings-BUQirdyI.js} +2 -2
- package/public/assets/{settings-78Cyq_tA.js.map → settings-BUQirdyI.js.map} +1 -1
- package/public/assets/{store-BPrVuKiv.js → store-DMZSTozC.js} +2 -2
- package/public/assets/{store-BPrVuKiv.js.map → store-DMZSTozC.js.map} +1 -1
- package/public/assets/{tasks-Cg2MMeri.js → tasks-k8mQDfKy.js} +2 -2
- package/public/assets/{tasks-Cg2MMeri.js.map → tasks-k8mQDfKy.js.map} +1 -1
- package/public/assets/{teams-DWv6UwFV.js → teams-DI-Lhhbl.js} +2 -2
- package/public/assets/{teams-DWv6UwFV.js.map → teams-DI-Lhhbl.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-DBLDjbIg.js → terminal-viewer-impl-BL853Fpi.js} +2 -2
- package/public/assets/{terminal-viewer-impl-DBLDjbIg.js.map → terminal-viewer-impl-BL853Fpi.js.map} +1 -1
- package/public/assets/types-CRptbSYh.js +2 -0
- package/public/assets/types-CRptbSYh.js.map +1 -0
- package/public/assets/{user-avatar-BsOLyVJj.js → user-avatar-D9xyGi3u.js} +2 -2
- package/public/assets/{user-avatar-BsOLyVJj.js.map → user-avatar-D9xyGi3u.js.map} +1 -1
- package/public/assets/{work-queue-DmwvnF6F.js → work-queue-DrLIdvxB.js} +2 -2
- package/public/assets/{work-queue-DmwvnF6F.js.map → work-queue-DrLIdvxB.js.map} +1 -1
- package/public/assets/{workspaces-dIBivJN8.js → workspaces-CnNL6T8Q.js} +2 -2
- package/public/assets/{workspaces-dIBivJN8.js.map → workspaces-CnNL6T8Q.js.map} +1 -1
- package/public/index.html +6 -6
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/src/adapters/claude-delivery.ts +127 -0
- package/runner/src/adapters/claude-quota-harvest.ts +92 -0
- package/runner/src/adapters/claude-session-probe.ts +225 -0
- package/runner/src/adapters/claude-status-detectors.ts +147 -0
- package/runner/src/adapters/claude-transcript-tail.ts +128 -0
- package/runner/src/adapters/claude-transcript.ts +414 -0
- package/runner/src/adapters/claude.ts +675 -0
- package/runner/src/adapters/codex-agent-message-capture.ts +139 -0
- package/runner/src/adapters/codex-client.ts +279 -0
- package/runner/src/adapters/codex.ts +1345 -0
- package/runner/src/attachment-cache.ts +189 -0
- package/runner/src/busy-reconciler.ts +102 -0
- package/runner/src/claim-tracker.ts +140 -0
- package/runner/src/claude-prompt-gates.ts +268 -0
- package/runner/src/codex-version.ts +63 -0
- package/runner/src/config.ts +7 -0
- package/runner/src/context-probe-state.ts +6 -0
- package/runner/src/continuation-archive.ts +179 -0
- package/runner/src/control-server.ts +639 -0
- package/runner/src/index.ts +396 -0
- package/runner/src/launch-assembly.ts +674 -0
- package/runner/src/liveness.ts +50 -0
- package/runner/src/logger.ts +99 -0
- package/runner/src/mcp-outbox.ts +120 -0
- package/runner/src/message-body-config/config.ts +3 -0
- package/runner/src/message-body-config.ts +1 -0
- package/runner/src/native-self-resume.ts +202 -0
- package/runner/src/outbox.ts +342 -0
- package/runner/src/process-meta.ts +9 -0
- package/runner/src/profile-home.ts +260 -0
- package/runner/src/profile-projection.ts +324 -0
- package/runner/src/providers.ts +20 -0
- package/runner/src/provisioning.ts +368 -0
- package/runner/src/quota.ts +40 -0
- package/runner/src/rate-limit.ts +189 -0
- package/runner/src/relay-injection-events.ts +105 -0
- package/runner/src/relay-instructions.ts +60 -0
- package/runner/src/relay-mcp-proxy.ts +383 -0
- package/runner/src/relay-mcp.ts +156 -0
- package/runner/src/reply-obligation-cache.ts +136 -0
- package/runner/src/response-capture-report.ts +63 -0
- package/runner/src/runner-core.ts +2409 -0
- package/runner/src/runner-helpers.ts +435 -0
- package/runner/src/runner-insights.ts +105 -0
- package/runner/src/runner.ts +14 -0
- package/runner/src/session-destroy.ts +22 -0
- package/runner/src/session-insights.ts +118 -0
- package/runner/src/session-scratch.ts +271 -0
- package/runner/src/template-resolver.ts +29 -0
- package/runner/src/version.ts +44 -0
- package/src/config-store.ts +2 -0
- package/src/db/provisioning-registry.ts +40 -9
- package/src/db/provisioning-resolve.ts +66 -5
- package/src/gate-resolver.ts +7 -12
- package/src/routes/provisioning-registry.ts +1 -1
- package/src/services/spawn-agent.ts +1 -0
- package/public/assets/types-BeJUSfDj.js +0 -2
- package/public/assets/types-BeJUSfDj.js.map +0 -1
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
6
|
+
import { errMessage } from "agent-relay-sdk";
|
|
7
|
+
import { logger } from "./logger";
|
|
8
|
+
import { runnerOutboxDirFromEnv } from "./config";
|
|
9
|
+
|
|
10
|
+
// Phase 2 (#196) — the "nothing is ever lost" half. Runner→server events that used to be
|
|
11
|
+
// fire-and-forget over HTTP (session turns, reasoning/tool traces, prompt echoes, insights,
|
|
12
|
+
// hook-fatal reports) were silently dropped whenever the server was momentarily down. This
|
|
13
|
+
// is a durable, FIFO, disk-backed queue that:
|
|
14
|
+
// - survives Runner/server restart (bun:sqlite file in the runtime dir),
|
|
15
|
+
// - stamps true event time (`occurredAt`) once at enqueue and preserves it through retries,
|
|
16
|
+
// - retries with capped exponential backoff, strictly in order (an append log must not
|
|
17
|
+
// reorder turns),
|
|
18
|
+
// - poisons a permanently-failing head after maxAttempts so it can't block the queue,
|
|
19
|
+
// - is bounded with a logged drop policy (never silently truncates).
|
|
20
|
+
//
|
|
21
|
+
// Status deliberately does NOT go through here: it rides the WebSocket bus, which is
|
|
22
|
+
// last-wins and self-heals on reconnect (so it already satisfies "coalesce, don't replay
|
|
23
|
+
// stale busyes"). The coalesce mode below exists so a future state event could migrate here.
|
|
24
|
+
|
|
25
|
+
type OutboxMode = "append" | "coalesce";
|
|
26
|
+
|
|
27
|
+
interface OutboxEventInput {
|
|
28
|
+
kind: string;
|
|
29
|
+
payload: unknown;
|
|
30
|
+
mode?: OutboxMode;
|
|
31
|
+
// Required for coalesce mode: prior un-poisoned rows with the same dedupeKey are replaced.
|
|
32
|
+
dedupeKey?: string;
|
|
33
|
+
// Defaults to now. Set explicitly only to backdate (e.g. replaying a captured timestamp).
|
|
34
|
+
occurredAt?: number;
|
|
35
|
+
// Defaults to a stable derived key so server-side dedup makes retries exactly-once.
|
|
36
|
+
idempotencyKey?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface OutboxRecord {
|
|
40
|
+
seq: number;
|
|
41
|
+
kind: string;
|
|
42
|
+
mode: OutboxMode;
|
|
43
|
+
occurredAt: number;
|
|
44
|
+
idempotencyKey: string;
|
|
45
|
+
payload: unknown;
|
|
46
|
+
attempts: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The transport. Resolve = delivered (row deleted). Reject = failed (retried with backoff).
|
|
50
|
+
type OutboxSend = (record: OutboxRecord) => Promise<void>;
|
|
51
|
+
|
|
52
|
+
interface OutboxOptions {
|
|
53
|
+
agentId: string;
|
|
54
|
+
send: OutboxSend;
|
|
55
|
+
// Storage directory. Defaults to AGENT_RELAY_RUNNER_OUTBOX_DIR, else a per-host temp dir.
|
|
56
|
+
dir?: string;
|
|
57
|
+
maxRows?: number;
|
|
58
|
+
maxAttempts?: number;
|
|
59
|
+
baseBackoffMs?: number;
|
|
60
|
+
maxBackoffMs?: number;
|
|
61
|
+
pollMs?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const DEFAULTS = {
|
|
65
|
+
maxRows: 5000,
|
|
66
|
+
maxAttempts: 12,
|
|
67
|
+
baseBackoffMs: 1_000,
|
|
68
|
+
maxBackoffMs: 60_000,
|
|
69
|
+
pollMs: 5_000,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Pause between forced delivery attempts during flush() — keeps a brief server outage
|
|
73
|
+
// from hot-looping while still retrying fast enough to land within the flush deadline.
|
|
74
|
+
const FLUSH_RETRY_PAUSE_MS = 150;
|
|
75
|
+
|
|
76
|
+
interface Row {
|
|
77
|
+
seq: number;
|
|
78
|
+
kind: string;
|
|
79
|
+
mode: string;
|
|
80
|
+
occurred_at: number;
|
|
81
|
+
idempotency_key: string;
|
|
82
|
+
payload: string;
|
|
83
|
+
attempts: number;
|
|
84
|
+
next_attempt_at: number;
|
|
85
|
+
poisoned: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class Outbox {
|
|
89
|
+
private readonly db: Database;
|
|
90
|
+
private readonly agentId: string;
|
|
91
|
+
private readonly send: OutboxSend;
|
|
92
|
+
private readonly maxRows: number;
|
|
93
|
+
private readonly maxAttempts: number;
|
|
94
|
+
private readonly baseBackoffMs: number;
|
|
95
|
+
private readonly maxBackoffMs: number;
|
|
96
|
+
private readonly pollMs: number;
|
|
97
|
+
readonly path: string;
|
|
98
|
+
|
|
99
|
+
private draining = false;
|
|
100
|
+
private rerun = false;
|
|
101
|
+
// Set by flush() to make the next drain pass ignore per-row backoff (deliver NOW).
|
|
102
|
+
private forceDue = false;
|
|
103
|
+
private pollTimer?: ReturnType<typeof setInterval>;
|
|
104
|
+
private dueTimer?: ReturnType<typeof setTimeout>;
|
|
105
|
+
private stopped = false;
|
|
106
|
+
|
|
107
|
+
constructor(options: OutboxOptions) {
|
|
108
|
+
this.agentId = options.agentId;
|
|
109
|
+
this.send = options.send;
|
|
110
|
+
this.maxRows = options.maxRows ?? DEFAULTS.maxRows;
|
|
111
|
+
this.maxAttempts = options.maxAttempts ?? DEFAULTS.maxAttempts;
|
|
112
|
+
this.baseBackoffMs = options.baseBackoffMs ?? DEFAULTS.baseBackoffMs;
|
|
113
|
+
this.maxBackoffMs = options.maxBackoffMs ?? DEFAULTS.maxBackoffMs;
|
|
114
|
+
this.pollMs = options.pollMs ?? DEFAULTS.pollMs;
|
|
115
|
+
|
|
116
|
+
const dir = options.dir ?? runnerOutboxDirFromEnv() ?? join(tmpdir(), "agent-relay-outbox");
|
|
117
|
+
this.path = options.dir === ":memory:" ? ":memory:" : join(dir, `outbox-${safeName(this.agentId)}.sqlite`);
|
|
118
|
+
if (this.path !== ":memory:") mkdirSync(dirname(this.path), { recursive: true });
|
|
119
|
+
|
|
120
|
+
this.db = new Database(this.path, { create: true });
|
|
121
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
122
|
+
this.db.exec("PRAGMA busy_timeout = 2000");
|
|
123
|
+
this.db.exec(`
|
|
124
|
+
CREATE TABLE IF NOT EXISTS outbox (
|
|
125
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
126
|
+
kind TEXT NOT NULL,
|
|
127
|
+
mode TEXT NOT NULL DEFAULT 'append',
|
|
128
|
+
dedupe_key TEXT,
|
|
129
|
+
occurred_at INTEGER NOT NULL,
|
|
130
|
+
idempotency_key TEXT NOT NULL,
|
|
131
|
+
payload TEXT NOT NULL,
|
|
132
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
133
|
+
next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
|
134
|
+
poisoned INTEGER NOT NULL DEFAULT 0,
|
|
135
|
+
created_at INTEGER NOT NULL
|
|
136
|
+
)
|
|
137
|
+
`);
|
|
138
|
+
// A restart is a fresh start: clear any backoff timers left by the prior process so
|
|
139
|
+
// pending events get an immediate retry (the down server may now be back). `attempts`
|
|
140
|
+
// is kept so the poison threshold still counts cumulative failures.
|
|
141
|
+
this.db.exec("UPDATE outbox SET next_attempt_at = 0 WHERE next_attempt_at > 0");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Persist an event. Returns the assigned seq. Triggers a drain.
|
|
145
|
+
enqueue(input: OutboxEventInput): number {
|
|
146
|
+
if (this.stopped) throw new Error("outbox is stopped");
|
|
147
|
+
const mode: OutboxMode = input.mode ?? "append";
|
|
148
|
+
const occurredAt = input.occurredAt ?? Date.now();
|
|
149
|
+
const payloadJson = JSON.stringify(input.payload ?? null);
|
|
150
|
+
const idempotencyKey = input.idempotencyKey ?? `${this.agentId}:${input.kind}:${occurredAt}:${shortHash(payloadJson)}`;
|
|
151
|
+
|
|
152
|
+
if (mode === "coalesce") {
|
|
153
|
+
if (!input.dedupeKey) throw new Error("coalesce mode requires a dedupeKey");
|
|
154
|
+
this.db.query("DELETE FROM outbox WHERE dedupe_key = ? AND poisoned = 0").run(input.dedupeKey);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const info = this.db
|
|
158
|
+
.query(`INSERT INTO outbox (kind, mode, dedupe_key, occurred_at, idempotency_key, payload, created_at)
|
|
159
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
160
|
+
.run(input.kind, mode, input.dedupeKey ?? null, occurredAt, idempotencyKey, payloadJson, Date.now());
|
|
161
|
+
const seq = Number(info.lastInsertRowid);
|
|
162
|
+
|
|
163
|
+
this.enforceBound();
|
|
164
|
+
// Defer the drain to a microtask so a synchronous burst of enqueues (e.g. several
|
|
165
|
+
// coalesce updates) all land — and coalesce — before the pump pulls the head.
|
|
166
|
+
queueMicrotask(() => { void this.drain(); });
|
|
167
|
+
return seq;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Bounded ring buffer: if over capacity, drop the oldest rows (defined overflow policy).
|
|
171
|
+
// Logged, never silent. Prefers dropping already-poisoned rows first, then oldest by seq.
|
|
172
|
+
private enforceBound(): void {
|
|
173
|
+
const { n } = this.db.query("SELECT count(*) AS n FROM outbox").get() as { n: number };
|
|
174
|
+
if (n <= this.maxRows) return;
|
|
175
|
+
const overflow = n - this.maxRows;
|
|
176
|
+
// Oldest poisoned first, then oldest live — both by seq.
|
|
177
|
+
const victims = this.db
|
|
178
|
+
.query("SELECT seq FROM outbox ORDER BY poisoned DESC, seq ASC LIMIT ?")
|
|
179
|
+
.all(overflow) as Array<{ seq: number }>;
|
|
180
|
+
const ids = victims.map((v) => v.seq);
|
|
181
|
+
if (ids.length === 0) return;
|
|
182
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
183
|
+
this.db.query(`DELETE FROM outbox WHERE seq IN (${placeholders})`).run(...ids);
|
|
184
|
+
logger.warn("outbox", `bound exceeded (${n}/${this.maxRows}) — dropped ${ids.length} oldest event(s)`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Begin the background pump: an initial drain plus a poll timer as a backstop.
|
|
188
|
+
start(): void {
|
|
189
|
+
if (this.pollTimer || this.stopped) return;
|
|
190
|
+
void this.drain();
|
|
191
|
+
this.pollTimer = setInterval(() => { void this.drain(); }, this.pollMs);
|
|
192
|
+
this.pollTimer.unref?.();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Drain everything we can before the process exits, ignoring per-row backoff. Used on
|
|
196
|
+
// shutdown/kill/crash (#183): the capture seam durably enqueues the end-of-session
|
|
197
|
+
// Insights datapoint, but the per-agent outbox is never reopened once the agent is gone,
|
|
198
|
+
// so a row that hasn't been POSTed yet is stranded forever. flush() blocks (bounded) on
|
|
199
|
+
// delivery — the opposite intent of the polite, backoff-respecting steady-state drain.
|
|
200
|
+
// Returns true if the queue fully drained within the deadline.
|
|
201
|
+
async flush(timeoutMs: number): Promise<boolean> {
|
|
202
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
203
|
+
for (;;) {
|
|
204
|
+
if (this.stopped) return this.pendingCount() === 0;
|
|
205
|
+
if (this.pendingCount() === 0) return true;
|
|
206
|
+
await this.drain(true);
|
|
207
|
+
if (this.pendingCount() === 0) return true;
|
|
208
|
+
if (Date.now() >= deadline) return false;
|
|
209
|
+
await new Promise<void>((resolve) => setTimeout(resolve, FLUSH_RETRY_PAUSE_MS));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Process the queue strictly oldest-first. Coalesces concurrent calls; if a drain is
|
|
214
|
+
// requested while one is running, it re-runs once at the end (so an enqueue during a
|
|
215
|
+
// send isn't missed). `force` (from flush) makes this pass ignore per-row backoff.
|
|
216
|
+
async drain(force = false): Promise<void> {
|
|
217
|
+
if (this.stopped) return;
|
|
218
|
+
if (force) this.forceDue = true;
|
|
219
|
+
if (this.draining) { this.rerun = true; return; }
|
|
220
|
+
this.draining = true;
|
|
221
|
+
try {
|
|
222
|
+
do {
|
|
223
|
+
this.rerun = false;
|
|
224
|
+
const due = this.forceDue;
|
|
225
|
+
this.forceDue = false;
|
|
226
|
+
await this.drainOnce(due);
|
|
227
|
+
} while ((this.rerun || this.forceDue) && !this.stopped);
|
|
228
|
+
} finally {
|
|
229
|
+
this.draining = false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private async drainOnce(force = false): Promise<void> {
|
|
234
|
+
for (;;) {
|
|
235
|
+
if (this.stopped) return;
|
|
236
|
+
const row = this.db
|
|
237
|
+
.query("SELECT * FROM outbox WHERE poisoned = 0 ORDER BY seq ASC LIMIT 1")
|
|
238
|
+
.get() as Row | null;
|
|
239
|
+
if (!row) return;
|
|
240
|
+
|
|
241
|
+
const now = Date.now();
|
|
242
|
+
if (!force && row.next_attempt_at > now) {
|
|
243
|
+
// Head isn't due yet. Don't reorder past it (FIFO) — schedule a wake-up and stop.
|
|
244
|
+
this.scheduleDue(row.next_attempt_at - now);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const record: OutboxRecord = {
|
|
249
|
+
seq: row.seq,
|
|
250
|
+
kind: row.kind,
|
|
251
|
+
mode: row.mode as OutboxMode,
|
|
252
|
+
occurredAt: row.occurred_at,
|
|
253
|
+
idempotencyKey: row.idempotency_key,
|
|
254
|
+
payload: safeParse(row.payload),
|
|
255
|
+
attempts: row.attempts,
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
await this.send(record);
|
|
260
|
+
this.db.query("DELETE FROM outbox WHERE seq = ?").run(row.seq);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
const attempts = row.attempts + 1;
|
|
263
|
+
const reason = errMessage(error);
|
|
264
|
+
if (attempts >= this.maxAttempts) {
|
|
265
|
+
this.db.query("UPDATE outbox SET attempts = ?, poisoned = 1 WHERE seq = ?").run(attempts, row.seq);
|
|
266
|
+
logger.fatal("outbox", `event seq=${row.seq} kind=${row.kind} poisoned after ${attempts} attempts: ${reason}`);
|
|
267
|
+
// Move on — the next iteration picks the new head (poison no longer blocks).
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const delay = this.backoff(attempts);
|
|
271
|
+
this.db.query("UPDATE outbox SET attempts = ?, next_attempt_at = ? WHERE seq = ?").run(attempts, now + delay, row.seq);
|
|
272
|
+
logger.debug("outbox", `event seq=${row.seq} kind=${row.kind} retry ${attempts}/${this.maxAttempts} in ${delay}ms: ${reason}`);
|
|
273
|
+
this.scheduleDue(delay);
|
|
274
|
+
return; // head is now scheduled; stop until it's due (preserve order)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Pure per-row delay calc: the attempt count lives in SQLite (one outbox row
|
|
280
|
+
// at a time may be due), so this stays a stateless function rather than the
|
|
281
|
+
// stateful SDK ReconnectionManager (single in-memory counter) used for the
|
|
282
|
+
// orchestrator/voice reconnect loops — different model, deliberately not shared.
|
|
283
|
+
private backoff(attempts: number): number {
|
|
284
|
+
const exp = Math.min(this.maxBackoffMs, this.baseBackoffMs * 2 ** (attempts - 1));
|
|
285
|
+
return Math.round(exp / 2 + Math.random() * (exp / 2)); // full-ish jitter, never below half
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private scheduleDue(delayMs: number): void {
|
|
289
|
+
if (this.stopped || this.dueTimer) return;
|
|
290
|
+
this.dueTimer = setTimeout(() => {
|
|
291
|
+
this.dueTimer = undefined;
|
|
292
|
+
void this.drain();
|
|
293
|
+
}, Math.max(0, delayMs));
|
|
294
|
+
this.dueTimer.unref?.();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Observability / tests.
|
|
298
|
+
pendingCount(): number {
|
|
299
|
+
return (this.db.query("SELECT count(*) AS n FROM outbox WHERE poisoned = 0").get() as { n: number }).n;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
updatePayload(seq: number, payload: unknown): void {
|
|
303
|
+
const payloadJson = JSON.stringify(payload ?? null);
|
|
304
|
+
this.db.query("UPDATE outbox SET payload = ? WHERE seq = ? AND poisoned = 0").run(payloadJson, seq);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
poisonedCount(): number {
|
|
308
|
+
return (this.db.query("SELECT count(*) AS n FROM outbox WHERE poisoned = 1").get() as { n: number }).n;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
stop(): void {
|
|
312
|
+
this.stopped = true;
|
|
313
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
314
|
+
this.pollTimer = undefined;
|
|
315
|
+
if (this.dueTimer) clearTimeout(this.dueTimer);
|
|
316
|
+
this.dueTimer = undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
close(): void {
|
|
320
|
+
this.stop();
|
|
321
|
+
try { this.db.close(); } catch { /* already closed */ }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function safeName(value: string): string {
|
|
326
|
+
return sanitizeFsName(value, { replacement: "_", maxLen: 180, fallback: "agent" });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function safeParse(json: string): unknown {
|
|
330
|
+
try { return JSON.parse(json); } catch { return null; }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Small, fast, stable string hash (FNV-1a, 32-bit) — enough to disambiguate identical
|
|
334
|
+
// kind+timestamp payloads in the idempotency key. Not security-sensitive.
|
|
335
|
+
function shortHash(value: string): string {
|
|
336
|
+
let h = 0x811c9dc5;
|
|
337
|
+
for (let i = 0; i < value.length; i++) {
|
|
338
|
+
h ^= value.charCodeAt(i);
|
|
339
|
+
h = Math.imul(h, 0x01000193);
|
|
340
|
+
}
|
|
341
|
+
return (h >>> 0).toString(36);
|
|
342
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ManagedProcess } from "./adapter";
|
|
2
|
+
|
|
3
|
+
export function providerTerminalSession(process?: ManagedProcess): string | undefined {
|
|
4
|
+
return typeof process?.meta?.tmuxSession === "string" ? process.meta.tmuxSession : undefined;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function providerTerminalSocket(process?: ManagedProcess): string | undefined {
|
|
8
|
+
return typeof process?.meta?.tmuxSocket === "string" ? process.meta.tmuxSocket : undefined;
|
|
9
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
5
|
+
import type { ProvisioningMcpServer, SpawnProvider } from "agent-relay-sdk";
|
|
6
|
+
import { getManifest } from "agent-relay-providers";
|
|
7
|
+
import { profileAllowsRelayFeature, type RunnerSpawnConfig } from "./adapter";
|
|
8
|
+
import { claudeRelayManual } from "./relay-instructions";
|
|
9
|
+
import { providerHomeRootFromEnv } from "./config";
|
|
10
|
+
import { applyClaudeConfigPromptGatePreventions } from "./claude-prompt-gates";
|
|
11
|
+
|
|
12
|
+
type ProviderHome = {
|
|
13
|
+
path: string;
|
|
14
|
+
authLinked: string[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function profileUsesHostProviderGlobals(config: { agentProfile?: RunnerSpawnConfig["agentProfile"] }): boolean {
|
|
18
|
+
return !config.agentProfile || config.agentProfile.base === "host";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function profileAllowsHostAssets(
|
|
22
|
+
config: { agentProfile?: RunnerSpawnConfig["agentProfile"] },
|
|
23
|
+
asset: "skills" | "plugins",
|
|
24
|
+
): boolean {
|
|
25
|
+
return profileUsesProviderHostGlobals(config) || Boolean(config.agentProfile?.hostAssets?.[asset]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function profileAllowsHostGlobalInstructions(config: { agentProfile?: RunnerSpawnConfig["agentProfile"] }): boolean {
|
|
29
|
+
return !config.agentProfile || config.agentProfile.instructions.globalInstructions === "allow";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function projectConfigProjectionOn(config: { agentProfile?: RunnerSpawnConfig["agentProfile"] }): boolean {
|
|
33
|
+
return Boolean(config.agentProfile?.projectSkills || config.agentProfile?.projectSettings);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @deprecated Use {@link projectConfigProjectionOn}. */
|
|
37
|
+
export { projectConfigProjectionOn as claudeProjectConfigProjectionOn };
|
|
38
|
+
|
|
39
|
+
export function profileUsesProviderHostGlobals(config: { agentProfile?: RunnerSpawnConfig["agentProfile"] }): boolean {
|
|
40
|
+
return profileUsesHostProviderGlobals(config) && !projectConfigProjectionOn(config);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @deprecated Use {@link profileUsesProviderHostGlobals}. */
|
|
44
|
+
export { profileUsesProviderHostGlobals as profileUsesClaudeHostProviderGlobals };
|
|
45
|
+
|
|
46
|
+
function profileRequiresIsolatedHome(_provider: SpawnProvider, config: RunnerSpawnConfig): boolean {
|
|
47
|
+
if (!config.agentProfile) return false;
|
|
48
|
+
if (config.agentProfile.base !== "host") return true;
|
|
49
|
+
return projectConfigProjectionOn(config);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// #557 — PURE: the isolated provider config home path a launch WILL use, or undefined
|
|
53
|
+
// for a host-base profile (which uses the provider's normal home). The launch assembler
|
|
54
|
+
// computes the home this way to report it + build CLAUDE_CONFIG_DIR/skill/plugin paths
|
|
55
|
+
// without side effects; prepare*ProfileHome creates it at the same path at spawn time.
|
|
56
|
+
export function providerHomePathFor(provider: SpawnProvider, config: RunnerSpawnConfig): string | undefined {
|
|
57
|
+
if (!profileRequiresIsolatedHome(provider, config)) return undefined;
|
|
58
|
+
return providerHomePath(provider, config);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Provisioned file-backed assets need a relay-owned materialization root even for
|
|
62
|
+
// host-base profiles, where the provider's real config home stays in use. This path is
|
|
63
|
+
// only passed explicitly through launch args such as Codex skills.config or Claude
|
|
64
|
+
// --plugin-dir; it does not imply CODEX_HOME/CLAUDE_CONFIG_DIR isolation.
|
|
65
|
+
export function providerProvisioningHomePathFor(provider: SpawnProvider, config: RunnerSpawnConfig): string | undefined {
|
|
66
|
+
if (!config.agentProfile) return undefined;
|
|
67
|
+
return providerHomePathFor(provider, config) ?? join(providerHomePath(provider, config), "provisioning");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// First-run bootstrap (provider layer)
|
|
71
|
+
// -------------------------------------
|
|
72
|
+
// Isolated profile homes are keyed by instanceId, so every spawn gets a brand
|
|
73
|
+
// new CLAUDE_CONFIG_DIR / CODEX_HOME — to the provider it always looks like the
|
|
74
|
+
// first time it has ever run, even in a repo we've used before. Without
|
|
75
|
+
// bootstrapping, the CLI stalls on first-run gates (theme/onboarding picker,
|
|
76
|
+
// "do you trust this folder?") and the agent never starts; the runner then sees
|
|
77
|
+
// repeated ~2s exits and gives up. bootstrap<Provider>FirstRun makes a fresh
|
|
78
|
+
// home launch-ready non-interactively, guaranteeing three things:
|
|
79
|
+
// 1. host auth is linked in,
|
|
80
|
+
// 2. first-run / onboarding flags are pre-accepted (where the provider has them),
|
|
81
|
+
// 3. the cwd workspace is trusted.
|
|
82
|
+
// Workspace trust is independent of repoInstructions: "ignore" means don't load
|
|
83
|
+
// the repo's CLAUDE.md / AGENTS.md as behavioral instructions, NOT "don't
|
|
84
|
+
// operate here" — an isolated agent must still read/edit/execute in its own cwd.
|
|
85
|
+
// (claude-rig / host-base launches dodge all of this via onboarded host config +
|
|
86
|
+
// --dangerously-skip-permissions; only isolated homes need the bootstrap.)
|
|
87
|
+
|
|
88
|
+
// Provider-specific first-run bootstrap hooks, registered at module load.
|
|
89
|
+
type BootstrapHook = (home: string, config: RunnerSpawnConfig) => void;
|
|
90
|
+
const BOOTSTRAP_HOOKS = new Map<string, BootstrapHook>();
|
|
91
|
+
|
|
92
|
+
// Shared skeleton: gate on isolated-profile, make the instance-keyed home,
|
|
93
|
+
// link auth items from the manifest, and run any registered first-run hook.
|
|
94
|
+
function prepareProviderHome(provider: SpawnProvider, config: RunnerSpawnConfig): ProviderHome | undefined {
|
|
95
|
+
if (!profileRequiresIsolatedHome(provider, config)) return undefined;
|
|
96
|
+
const target = providerHomePath(provider, config);
|
|
97
|
+
mkdirSync(target, { recursive: true });
|
|
98
|
+
const hook = BOOTSTRAP_HOOKS.get(provider);
|
|
99
|
+
if (hook) hook(target, config);
|
|
100
|
+
const manifest = getManifest(provider);
|
|
101
|
+
const authItems = manifest?.home?.authItems ?? [];
|
|
102
|
+
const sourceHome = providerSourceHome(provider);
|
|
103
|
+
const authLinked = linkExistingAuthItems(sourceHome, target, authItems);
|
|
104
|
+
linkHostGlobalInstructionFiles(sourceHome, target, config);
|
|
105
|
+
return { path: target, authLinked };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const prepareCodexProfileHome = (config: RunnerSpawnConfig) => prepareProviderHome("codex", config);
|
|
109
|
+
export const prepareClaudeProfileHome = (config: RunnerSpawnConfig) => prepareProviderHome("claude", config);
|
|
110
|
+
|
|
111
|
+
function bootstrapCodexFirstRun(codexHome: string, config: RunnerSpawnConfig): void {
|
|
112
|
+
disableCodexAppsConnectors(codexHome);
|
|
113
|
+
trustWorkspaceForCodex(codexHome, config);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function bootstrapClaudeFirstRun(claudeHome: string, config: RunnerSpawnConfig): void {
|
|
117
|
+
if (profileAllowsRelayFeature(config, "context") && !profileAllowsHostGlobalInstructions(config)) {
|
|
118
|
+
writeClaudeRelayManual(claudeHome);
|
|
119
|
+
}
|
|
120
|
+
seedClaudeConfigIfMissing(claudeHome, config);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
BOOTSTRAP_HOOKS.set("codex", bootstrapCodexFirstRun);
|
|
124
|
+
BOOTSTRAP_HOOKS.set("claude", bootstrapClaudeFirstRun);
|
|
125
|
+
|
|
126
|
+
export function providerSourceHome(provider: string): string {
|
|
127
|
+
const manifest = getManifest(provider);
|
|
128
|
+
const envVar = manifest?.home?.configDirEnvVar;
|
|
129
|
+
const configDir = manifest?.home?.configDir ?? `.${provider}`;
|
|
130
|
+
return (envVar && process.env[envVar]) || join(homedir(), configDir);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function seedClaudeConfigIfMissing(claudeHome: string, config: RunnerSpawnConfig): void {
|
|
134
|
+
const path = join(claudeHome, ".claude.json");
|
|
135
|
+
if (existsSync(path)) return;
|
|
136
|
+
const host = readHostClaudeConfig();
|
|
137
|
+
const seed = applyClaudeConfigPromptGatePreventions({}, host, config);
|
|
138
|
+
writeFileSync(path, JSON.stringify(seed, null, 2), { mode: 0o600 });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function readHostClaudeConfig(): Record<string, unknown> | undefined {
|
|
142
|
+
try {
|
|
143
|
+
const hostPath = join(homedir(), ".claude.json");
|
|
144
|
+
if (!existsSync(hostPath)) return undefined;
|
|
145
|
+
return JSON.parse(readFileSync(hostPath, "utf8")) as Record<string, unknown>;
|
|
146
|
+
} catch {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// #662 — the host USER-scope MCP servers: the top-level `mcpServers` of `~/.claude.json`.
|
|
152
|
+
// These are the ambient host MCP a host-base Claude auto-loads on EVERY launch when NOT
|
|
153
|
+
// strict (user scope is global across all projects — callmux/qmd/gh on this host). Since
|
|
154
|
+
// the #662 wedge fix makes every managed launch `--strict-mcp-config` (so Claude ignores
|
|
155
|
+
// all on-disk config and never renders the project-`.mcp.json` enable modal), an
|
|
156
|
+
// `mcp.mode:"host"` profile must re-inject these explicitly into `--mcp-config` to keep
|
|
157
|
+
// honoring host mode. Deliberately NOT the project-cwd `.mcp.json` (project scope — the
|
|
158
|
+
// modal's source, which must stay excluded) nor `projects[cwd].mcpServers` (local scope).
|
|
159
|
+
export function hostUserScopeMcpServers(): Record<string, ProvisioningMcpServer> {
|
|
160
|
+
const servers = readHostClaudeConfig()?.mcpServers;
|
|
161
|
+
if (!servers || typeof servers !== "object") return {};
|
|
162
|
+
return servers as Record<string, ProvisioningMcpServer>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function providerHomePath(provider: SpawnProvider, config: RunnerSpawnConfig): string {
|
|
166
|
+
const root = providerHomeRootFromEnv();
|
|
167
|
+
const profileName = sanitizePathPart(config.agentProfile?.name || config.profile || "profile");
|
|
168
|
+
const instance = sanitizePathPart(config.instanceId || config.runnerId);
|
|
169
|
+
return join(root, provider, profileName, instance);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function sanitizePathPart(value: string): string {
|
|
173
|
+
return sanitizeFsName(value, { replacement: "-", collapse: false, maxLen: 120, fallback: "profile" });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function linkExistingAuthItems(sourceHome: string, targetHome: string, items: string[]): string[] {
|
|
177
|
+
const linked: string[] = [];
|
|
178
|
+
for (const item of items) {
|
|
179
|
+
if (linkExistingPath(join(sourceHome, item), join(targetHome, item))) linked.push(item);
|
|
180
|
+
}
|
|
181
|
+
return linked;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function linkExistingPath(source: string, target: string): boolean {
|
|
185
|
+
if (!existsSync(source) || existsSync(target)) return false;
|
|
186
|
+
symlinkSync(source, target);
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function linkHostGlobalInstructionFiles(sourceHome: string, targetHome: string, config: RunnerSpawnConfig): void {
|
|
191
|
+
if (!profileAllowsHostGlobalInstructions(config)) return;
|
|
192
|
+
for (const file of config.providerConfig.globalInstructionFiles ?? []) {
|
|
193
|
+
linkExistingPath(join(sourceHome, file), join(targetHome, file));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function trustWorkspaceForCodex(codexHome: string, config: RunnerSpawnConfig): void {
|
|
198
|
+
if (!config.cwd) return;
|
|
199
|
+
const path = join(codexHome, "config.toml");
|
|
200
|
+
const project = tomlBasicString(resolve(config.cwd));
|
|
201
|
+
const section = `[projects.${project}]`;
|
|
202
|
+
const entry = `${section}\ntrust_level = "trusted"\n`;
|
|
203
|
+
const current = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
204
|
+
if (current.includes(section)) return;
|
|
205
|
+
const prefix = current.trimEnd();
|
|
206
|
+
writeFileSync(path, `${prefix ? `${prefix}\n\n` : ""}${entry}`, { mode: 0o600 });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function disableCodexAppsConnectors(codexHome: string): void {
|
|
210
|
+
const path = join(codexHome, "config.toml");
|
|
211
|
+
const current = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
212
|
+
const next = withTomlFeatureFlag(current, "apps", false);
|
|
213
|
+
if (next === current) return;
|
|
214
|
+
writeFileSync(path, next, { mode: 0o600 });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function withTomlFeatureFlag(input: string, key: string, value: boolean): string {
|
|
218
|
+
const lines = input.length ? input.split("\n") : [];
|
|
219
|
+
const headerIndex = lines.findIndex((line) => line.trim() === "[features]");
|
|
220
|
+
const entry = `${key} = ${value ? "true" : "false"}`;
|
|
221
|
+
if (headerIndex === -1) {
|
|
222
|
+
const prefix = input.trimEnd();
|
|
223
|
+
return `${prefix ? `${prefix}\n\n` : ""}[features]\n${entry}\n`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let sectionEnd = lines.length;
|
|
227
|
+
for (let i = headerIndex + 1; i < lines.length; i += 1) {
|
|
228
|
+
const line = lines[i] ?? "";
|
|
229
|
+
if (/^\s*\[[^\]]+\]\s*(?:#.*)?$/.test(line)) {
|
|
230
|
+
sectionEnd = i;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const flagPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
|
|
236
|
+
for (let i = headerIndex + 1; i < sectionEnd; i += 1) {
|
|
237
|
+
const line = lines[i] ?? "";
|
|
238
|
+
if (flagPattern.test(line)) {
|
|
239
|
+
if (lines[i] === entry) return input;
|
|
240
|
+
lines[i] = entry;
|
|
241
|
+
return lines.join("\n");
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
lines.splice(headerIndex + 1, 0, entry);
|
|
246
|
+
return lines.join("\n");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function escapeRegExp(value: string): string {
|
|
250
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function writeClaudeRelayManual(claudeHome: string): void {
|
|
254
|
+
const path = join(claudeHome, "CLAUDE.md");
|
|
255
|
+
writeFileSync(path, claudeRelayManual(), { mode: 0o600 });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function tomlBasicString(value: string): string {
|
|
259
|
+
return JSON.stringify(value);
|
|
260
|
+
}
|