agent-relay-runner 0.121.5 → 0.122.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.121.5",
3
+ "version": "0.122.1",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "agent-relay-providers": "0.104.3",
24
- "agent-relay-sdk": "0.2.113",
24
+ "agent-relay-sdk": "0.2.114",
25
25
  "callmux": "0.23.0"
26
26
  },
27
27
  "devDependencies": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.121.5",
4
+ "version": "0.122.1",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -3,922 +3,8 @@
3
3
  function isRecord(value) {
4
4
  return typeof value === "object" && value !== null && !Array.isArray(value);
5
5
  }
6
- // sdk/src/types/constants.ts
7
- var DEFAULT_RELAY_PORT = 4850;
8
- var DEFAULT_RELAY_URL = `http://127.0.0.1:${DEFAULT_RELAY_PORT}`;
9
- // providers/registry.ts
10
- var PROVIDER_ID_RE = /^[a-z][a-z0-9-]*$/;
11
- var manifests = new Map;
12
- function registerProvider(manifest) {
13
- if (!PROVIDER_ID_RE.test(manifest.id)) {
14
- throw new Error(`invalid provider id: ${manifest.id}`);
15
- }
16
- if (!manifest.label.trim()) {
17
- throw new Error(`provider ${manifest.id} is missing a label`);
18
- }
19
- if (!manifest.icon.trim()) {
20
- throw new Error(`provider ${manifest.id} is missing an icon`);
21
- }
22
- if (manifests.has(manifest.id)) {
23
- throw new Error(`provider already registered: ${manifest.id}`);
24
- }
25
- manifests.set(manifest.id, manifest);
26
- }
27
- function getAllManifests() {
28
- return [...manifests.values()];
29
- }
30
- var accountKeyAliasResolvers = new Map;
31
-
32
- // providers/claude.ts
33
- var CLAUDE_LOW_TO_MAX = ["low", "medium", "high", "max"];
34
- var CLAUDE_LOW_TO_XHIGH_MAX = ["low", "medium", "high", "xhigh", "max"];
35
- var CONTEXT_200K = { value: 200000, source: "catalog", confidence: "declared" };
36
- var CONTEXT_1M = { value: 1e6, source: "catalog", confidence: "declared" };
37
- var CODE_MODEL_CAPABILITIES = {
38
- modalities: {
39
- input: { text: true, image: true },
40
- output: { text: true }
41
- },
42
- tools: {
43
- code: true,
44
- review: true,
45
- debug: true,
46
- refactor: true
47
- },
48
- source: "catalog",
49
- confidence: "declared"
50
- };
51
- var quotaWindows = [
52
- { name: "five_hour", unit: "%", reset: true },
53
- { name: "seven_day", unit: "%", reset: true }
54
- ];
55
- function codeModel(limits) {
56
- return {
57
- ...limits ? { limits } : {},
58
- capabilities: CODE_MODEL_CAPABILITIES
59
- };
60
- }
61
- var claudeProviderManifest = {
62
- id: "claude",
63
- label: "Claude",
64
- icon: "\uD83D\uDFE0",
65
- launch: {
66
- managedPolicyPrompt: {
67
- alwaysOn: "append-system-prompt"
68
- },
69
- taskHydration: {
70
- channel: "append-system-prompt"
71
- }
72
- },
73
- catalog: {
74
- defaultModel: "sonnet-5",
75
- models: [
76
- { alias: "fable-5", label: "Fable 5", providerModel: "claude-fable-5", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", unavailable: "suspended by export-control directive, 2026-06-12", ...codeModel({ contextWindowTokens: CONTEXT_1M }) },
77
- { alias: "opus-4.8", label: "Opus 4.8", providerModel: "claude-opus-4-8", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
78
- { alias: "opus-4.8[1m]", label: "Opus 4.8 1M", providerModel: "claude-opus-4-8[1m]", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_1M }) },
79
- { alias: "opus-4.7", label: "Opus 4.7", providerModel: "claude-opus-4-7", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
80
- { alias: "opus-4.7[1m]", label: "Opus 4.7 1M", providerModel: "claude-opus-4-7[1m]", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_1M }) },
81
- { alias: "opus-4.6", label: "Opus 4.6", providerModel: "claude-opus-4-6", efforts: CLAUDE_LOW_TO_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
82
- { alias: "opus-4.6[1m]", label: "Opus 4.6 1M", providerModel: "claude-opus-4-6[1m]", efforts: CLAUDE_LOW_TO_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_1M }) },
83
- { alias: "sonnet-5", label: "Sonnet 5", providerModel: "claude-sonnet-5", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
84
- { alias: "sonnet-5[1m]", label: "Sonnet 5 1M", providerModel: "claude-sonnet-5[1m]", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_1M }) },
85
- { alias: "haiku", label: "Haiku", providerModel: "haiku", efforts: [], ...codeModel() }
86
- ]
87
- },
88
- quotaWindows: [...quotaWindows],
89
- capabilityDefaults: {
90
- lifecycle: {
91
- managed: true,
92
- shutdownHard: { when: "headless", value: true, otherwise: false },
93
- restartHard: { when: "headless", value: true, otherwise: false },
94
- semanticStatus: true,
95
- reconnect: true
96
- },
97
- session: {
98
- fileRead: true,
99
- fileWrite: { when: "approval:not-read-only", value: true, otherwise: false },
100
- shell: true,
101
- shellMode: { open: "unrestricted", guarded: "guarded", readOnly: "read-only-guarded" }
102
- },
103
- context: {
104
- managed: "headless",
105
- compact: { when: "headless", value: true, otherwise: false },
106
- clear: { when: "headless", value: true, otherwise: false },
107
- inject: true,
108
- resumePreference: ["native", "clear-inject", "none"]
109
- },
110
- quota: {
111
- supported: true,
112
- source: "probe",
113
- unit: "%",
114
- windows: [...quotaWindows]
115
- },
116
- terminal: {
117
- mode: "live",
118
- condition: "headless",
119
- live: { read: true, write: true }
120
- },
121
- liveSession: {
122
- capture: true,
123
- inject: true,
124
- interrupt: true,
125
- promptEcho: true,
126
- reasoning: true,
127
- slashCommands: true
128
- }
129
- },
130
- slashCommands: ["/clear", "/compact", "/model", "/help"],
131
- resume: { supported: true, idFormat: "uuid-v4", idPattern: "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" },
132
- home: { configDir: ".claude", envPrefix: "CLAUDE", configDirEnvVar: "CLAUDE_CONFIG_DIR", quotaCredentialFile: ".credentials.json", authItems: [".credentials.json", "statsig"], globalInstructionFiles: ["CLAUDE.md"] },
133
- provisioning: {
134
- pluginManifestPath: ".claude-plugin/plugin.json",
135
- bundledPlugin: {
136
- ref: "agent-relay-claude-plugin",
137
- dir: "runner/plugins/claude",
138
- title: "Agent Relay Claude plugin",
139
- description: "Bundled Agent Relay Claude plugin, materialized whole through the provisioning registry.",
140
- omitFiles: ["monitors/relay-monitor.provisioned.mjs"],
141
- fileOverrides: [{ path: "monitors/relay-monitor.ts", sourcePath: "monitors/relay-monitor.provisioned.mjs" }]
142
- }
143
- },
144
- upgrade: {
145
- packages: ["agent-relay-runner", "agent-relay-plugin"],
146
- detectPackages: ["agent-relay-runner"],
147
- detectCommands: ["claude"]
148
- },
149
- probe: {
150
- command: "claude",
151
- args: ["--version"],
152
- healthCheck: "claude --version",
153
- featureChecks: [{ name: "rig", command: "claude-rig", homeDir: ".claude-rig" }]
154
- },
155
- quotaPoll: { strategy: "none" },
156
- shim: { kind: "claude-relay" }
157
- };
158
- registerProvider(claudeProviderManifest);
159
-
160
- // providers/codex.ts
161
- var CODEX_REASONING = ["low", "medium", "high", "xhigh"];
162
- var CONTEXT_200K2 = { value: 200000, source: "catalog", confidence: "declared" };
163
- var CODE_MODEL_CAPABILITIES2 = {
164
- modalities: {
165
- input: { text: true, image: true },
166
- output: { text: true }
167
- },
168
- tools: {
169
- code: true,
170
- review: true,
171
- debug: true,
172
- refactor: true
173
- },
174
- source: "catalog",
175
- confidence: "declared"
176
- };
177
- var quotaWindows2 = [
178
- { name: "primary", unit: "%", reset: true },
179
- { name: "secondary", unit: "%", reset: true }
180
- ];
181
- function codeModel2(limits) {
182
- return {
183
- ...limits ? { limits } : {},
184
- capabilities: CODE_MODEL_CAPABILITIES2
185
- };
186
- }
187
- var codexProviderManifest = {
188
- id: "codex",
189
- label: "Codex",
190
- icon: "\u2328\uFE0F",
191
- launch: {
192
- managedPolicyPrompt: {
193
- alwaysOn: "prompt"
194
- },
195
- taskHydration: {
196
- channel: "prompt"
197
- }
198
- },
199
- catalog: {
200
- defaultModel: "gpt-5.5",
201
- models: [
202
- { alias: "gpt-5.5", label: "GPT-5.5", providerModel: "gpt-5.5", efforts: CODEX_REASONING, defaultEffort: "medium", ...codeModel2({ contextWindowTokens: CONTEXT_200K2 }) },
203
- { alias: "gpt-5.4", label: "GPT-5.4", providerModel: "gpt-5.4", efforts: CODEX_REASONING, defaultEffort: "medium", ...codeModel2({ contextWindowTokens: CONTEXT_200K2 }) },
204
- { alias: "gpt-5.4-mini", label: "GPT-5.4 Mini", providerModel: "gpt-5.4-mini", efforts: CODEX_REASONING, defaultEffort: "medium", ...codeModel2({ contextWindowTokens: CONTEXT_200K2 }) },
205
- { alias: "gpt-5.3-codex", label: "GPT-5.3 Codex", providerModel: "gpt-5.3-codex", efforts: CODEX_REASONING, defaultEffort: "medium", ...codeModel2({ contextWindowTokens: CONTEXT_200K2 }) },
206
- { alias: "gpt-5.3-codex-spark", label: "GPT-5.3 Codex Spark", providerModel: "gpt-5.3-codex-spark", efforts: CODEX_REASONING, defaultEffort: "high", ...codeModel2({ contextWindowTokens: CONTEXT_200K2 }) },
207
- { alias: "gpt-5.2", label: "GPT-5.2", providerModel: "gpt-5.2", efforts: CODEX_REASONING, defaultEffort: "medium", ...codeModel2({ contextWindowTokens: CONTEXT_200K2 }) }
208
- ]
209
- },
210
- quotaWindows: [...quotaWindows2],
211
- capabilityDefaults: {
212
- lifecycle: {
213
- managed: true,
214
- shutdownHard: true,
215
- restartHard: true,
216
- semanticStatus: true,
217
- reconnect: true
218
- },
219
- session: {
220
- fileRead: true,
221
- fileWrite: { when: "approval:not-read-only", value: true, otherwise: false },
222
- shell: { when: "approval:not-read-only", value: true, otherwise: false },
223
- shellMode: { open: "unrestricted", guarded: "guarded", readOnly: "none" }
224
- },
225
- context: {
226
- managed: "always",
227
- compact: true,
228
- clear: true,
229
- inject: true,
230
- resumePreference: ["native", "clear-inject", "none"]
231
- },
232
- quota: {
233
- supported: true,
234
- source: "probe",
235
- unit: "%",
236
- windows: [...quotaWindows2]
237
- },
238
- terminal: {
239
- mode: "attach",
240
- attach: {
241
- create: true,
242
- read: true,
243
- write: true,
244
- detach: true
245
- }
246
- },
247
- liveSession: {
248
- capture: true,
249
- inject: true,
250
- interrupt: true,
251
- promptEcho: true,
252
- reasoning: true,
253
- slashCommands: true
254
- }
255
- },
256
- slashCommands: ["/compact", "/new"],
257
- resume: { supported: true, idFormat: "codex-thread-id", idPattern: "[\\w-]+" },
258
- home: { configDir: ".codex", envPrefix: "CODEX", configDirEnvVar: "CODEX_HOME", quotaCredentialFile: "auth.json", authItems: ["auth.json", "installation_id"], globalInstructionFiles: ["AGENTS.md"] },
259
- provisioning: {
260
- pluginManifestPath: ".codex-plugin/plugin.json",
261
- bundledSkills: {
262
- refPrefix: "agent-relay-codex-",
263
- dir: "runner/plugins/codex/skills",
264
- titlePrefix: "Agent Relay Codex skill: ",
265
- description: "Bundled Agent Relay Codex skill, materialized through the provisioning registry.",
266
- entryFile: "SKILL.md"
267
- }
268
- },
269
- upgrade: {
270
- packages: ["agent-relay-runner", "agent-relay-codex"],
271
- detectPackages: ["agent-relay-runner", "agent-relay-codex"],
272
- detectCommands: ["codex-relay", "agent-relay-runner"]
273
- },
274
- probe: {
275
- command: "codex",
276
- args: ["--version"],
277
- healthCheck: "codex --version"
278
- },
279
- quotaPoll: { strategy: "codex-app-server" },
280
- shim: { kind: "codex-relay", extraBinPaths: ["codex/bin/codex"], cleanLegacyHooks: true }
281
- };
282
- registerProvider(codexProviderManifest);
283
- // sdk/src/types/agents.ts
284
- var SPAWN_PROVIDERS = getAllManifests().map((manifest) => manifest.id);
285
- // sdk/src/provider-catalog.ts
286
- function manifestToCatalogEntry(manifest) {
287
- return {
288
- provider: manifest.id,
289
- label: manifest.label,
290
- defaultModel: manifest.catalog.defaultModel,
291
- models: manifest.catalog.models
292
- };
293
- }
294
- var PROVIDER_CATALOG = Object.fromEntries(getAllManifests().map((manifest) => [manifest.id, manifestToCatalogEntry(manifest)]));
295
- // sdk/src/reconnect.ts
296
- import { setTimeout as delay } from "timers/promises";
297
- var DEFAULTS = {
298
- initialMs: 1000,
299
- maxMs: 30000,
300
- jitterMs: 500
301
- };
302
-
303
- class ReconnectionManager {
304
- attempts = 0;
305
- abort;
306
- options;
307
- constructor(options = {}) {
308
- this.options = {
309
- initialMs: options.initialMs ?? DEFAULTS.initialMs,
310
- maxMs: options.maxMs ?? DEFAULTS.maxMs,
311
- jitterMs: options.jitterMs ?? DEFAULTS.jitterMs
312
- };
313
- }
314
- get attempt() {
315
- return this.attempts;
316
- }
317
- nextDelay() {
318
- const base = Math.min(this.options.maxMs, this.options.initialMs * 2 ** this.attempts);
319
- this.attempts += 1;
320
- const jitter = this.options.jitterMs > 0 ? Math.floor(Math.random() * (this.options.jitterMs + 1)) : 0;
321
- return Math.min(this.options.maxMs, base + jitter);
322
- }
323
- reset() {
324
- this.attempts = 0;
325
- this.cancel();
326
- }
327
- async schedule() {
328
- this.cancel();
329
- this.abort = new AbortController;
330
- const ms = this.nextDelay();
331
- await delay(ms, undefined, { signal: this.abort.signal }).catch((error) => {
332
- if (error.name !== "AbortError")
333
- throw error;
334
- });
335
- }
336
- cancel() {
337
- this.abort?.abort();
338
- this.abort = undefined;
339
- }
340
- }
341
- // sdk/src/config.ts
342
- function relayTokenFromEnv() {
343
- return process.env.AGENT_RELAY_TOKEN;
344
- }
345
- // sdk/src/bus-client.ts
346
- import { EventEmitter } from "events";
347
- function unrefTimer(timer) {
348
- timer?.unref?.();
349
- }
350
-
351
- class MemoryCursorStore {
352
- value = null;
353
- async load() {
354
- return this.value;
355
- }
356
- async save(cursor) {
357
- this.value = cursor;
358
- }
359
- }
360
-
361
- class RelayBusClient extends EventEmitter {
362
- options;
363
- ws = null;
364
- registeredEpoch = 0;
365
- lastCursor = 0;
366
- serverCursor = 0;
367
- closed = false;
368
- connecting = null;
369
- heartbeat;
370
- heartbeatWatchdog;
371
- pendingHeartbeatId;
372
- pendingHeartbeatSentAt;
373
- lastServerFrameAt = 0;
374
- lastHeartbeatAckAt = 0;
375
- heartbeatAckSupported = false;
376
- semanticStatus;
377
- transport = { connected: false, reconnecting: false };
378
- reconnect;
379
- cursorStore;
380
- subscriptions = new Set(["message.*", "agent.status", "task.*"]);
381
- pending = new Map;
382
- constructor(options) {
383
- super();
384
- this.options = options;
385
- this.reconnect = new ReconnectionManager({
386
- initialMs: options.reconnect?.initialMs,
387
- maxMs: options.reconnect?.maxMs,
388
- jitterMs: options.reconnect?.jitterMs
389
- });
390
- this.cursorStore = options.cursorStore ?? new MemoryCursorStore;
391
- this.semanticStatus = options.initialStatus ?? "idle";
392
- }
393
- get connected() {
394
- return this.ws?.readyState === WebSocket.OPEN && this.registeredEpoch > 0;
395
- }
396
- get epoch() {
397
- return this.registeredEpoch;
398
- }
399
- get cursor() {
400
- return this.lastCursor;
401
- }
402
- get transportState() {
403
- return { ...this.transport };
404
- }
405
- async connect() {
406
- if (this.connecting)
407
- return this.connecting;
408
- this.closed = false;
409
- this.connecting = this.connectLoop().finally(() => {
410
- this.connecting = null;
411
- });
412
- return this.connecting;
413
- }
414
- async close() {
415
- this.closed = true;
416
- this.stopHeartbeat();
417
- this.stopHeartbeatWatchdog();
418
- this.reconnect.cancel();
419
- for (const pending of this.pending.values())
420
- pending.reject(new Error("bus client closed"));
421
- this.pending.clear();
422
- if (this.ws && this.ws.readyState <= WebSocket.OPEN)
423
- this.ws.close();
424
- this.ws = null;
425
- }
426
- setToken(token) {
427
- this.options.token = token;
428
- }
429
- reconnectTransport(reason = "client requested reconnect") {
430
- this.forceReconnect(reason, { emitError: false, code: 4001 });
431
- }
432
- status(update) {
433
- if (update.agentStatus === "idle" || update.agentStatus === "busy")
434
- this.semanticStatus = update.agentStatus;
435
- this.send({
436
- type: "status",
437
- payload: {
438
- agentStatus: update.agentStatus,
439
- ready: update.ready,
440
- meta: update.meta
441
- }
442
- });
443
- }
444
- async statusAsync(update) {
445
- this.status(update);
446
- await Promise.resolve();
447
- }
448
- setSemanticStatus(status) {
449
- this.semanticStatus = status;
450
- }
451
- updateCommand(commandId, params) {
452
- return this.command("command.update", commandId, params);
453
- }
454
- command(type, target, params) {
455
- const id = crypto.randomUUID();
456
- this.send({ type: "command", id, payload: { commandType: type, target, params } });
457
- return new Promise((resolve, reject) => {
458
- this.pending.set(id, { resolve, reject });
459
- });
460
- }
461
- async subscribe(events, scopes) {
462
- for (const event of events)
463
- this.subscriptions.add(event);
464
- const id = crypto.randomUUID();
465
- this.send({ type: "subscribe", id, payload: { events, scopes } });
466
- await Promise.resolve();
467
- }
468
- on(event, listener) {
469
- return super.on(event, listener);
470
- }
471
- off(event, listener) {
472
- return super.off(event, listener);
473
- }
474
- once(event, listener) {
475
- return super.once(event, listener);
476
- }
477
- async connectLoop() {
478
- while (!this.closed) {
479
- try {
480
- await this.openOnce();
481
- this.reconnect.reset();
482
- return;
483
- } catch (error) {
484
- if (this.closed)
485
- throw error;
486
- const delayMs = this.reconnect.nextDelay();
487
- this.transport = { ...this.transport, connected: false, reconnecting: true };
488
- this.emit("reconnecting", this.reconnect.attempt, delayMs);
489
- await new Promise((resolve) => setTimeout(resolve, delayMs));
490
- }
491
- }
492
- }
493
- async openOnce() {
494
- const cursor = await this.cursorStore.load();
495
- if (cursor !== null)
496
- this.lastCursor = cursor;
497
- await new Promise((resolve, reject) => {
498
- const ws = new WebSocket(this.wsUrl());
499
- this.ws = ws;
500
- let resolved = false;
501
- ws.onopen = () => {
502
- if (this.ws !== ws)
503
- return;
504
- const now = Date.now();
505
- this.lastServerFrameAt = now;
506
- this.transport = { ...this.transport, connected: true, reconnecting: false, lastConnectedAt: now, lastServerFrameAt: now, reconnectReason: undefined };
507
- this.emit("connected");
508
- this.sendRegister();
509
- };
510
- ws.onmessage = (message) => {
511
- if (this.ws !== ws)
512
- return;
513
- this.handleMessage(message.data, () => {
514
- if (!resolved) {
515
- resolved = true;
516
- resolve();
517
- }
518
- });
519
- };
520
- ws.onerror = () => {
521
- if (this.ws !== ws)
522
- return;
523
- if (!resolved)
524
- reject(new Error("bus connection failed"));
525
- };
526
- ws.onclose = (event) => {
527
- if (this.ws !== ws)
528
- return;
529
- this.stopHeartbeat();
530
- this.stopHeartbeatWatchdog();
531
- this.registeredEpoch = 0;
532
- this.heartbeatAckSupported = false;
533
- this.transport = { ...this.transport, connected: false, reconnecting: !this.closed, lastDisconnectedAt: Date.now() };
534
- const reason = event.reason || `closed:${event.code}`;
535
- this.emit("disconnected", reason);
536
- if (!resolved) {
537
- resolved = true;
538
- reject(new Error(reason));
539
- return;
540
- }
541
- if (!this.closed)
542
- this.connect();
543
- };
544
- });
545
- }
546
- sendRegister() {
547
- this.send({
548
- type: "register",
549
- id: crypto.randomUUID(),
550
- payload: {
551
- role: this.options.role,
552
- componentId: this.options.componentId,
553
- agentId: this.options.agentId,
554
- instanceId: this.options.instanceId,
555
- capabilities: this.options.capabilities ?? [],
556
- tags: this.options.tags ?? [],
557
- machine: this.options.machine ?? "unknown",
558
- meta: this.registrationMeta()
559
- }
560
- });
561
- }
562
- async handleMessage(data, onRegistered) {
563
- this.recordServerFrame();
564
- let frame;
565
- try {
566
- frame = JSON.parse(typeof data === "string" ? data : String(data));
567
- } catch {
568
- this.emit("error", "INVALID_JSON", "server sent invalid JSON");
569
- return;
570
- }
571
- if (frame.type === "registered") {
572
- const payload = frame.payload;
573
- const resumeFrom = this.lastCursor;
574
- this.registeredEpoch = payload.epoch;
575
- this.serverCursor = payload.cursor;
576
- this.heartbeatAckSupported = payload.features?.heartbeatAck === true;
577
- this.emit("registered", payload.epoch, payload.cursor);
578
- this.send({ type: "subscribe", id: crypto.randomUUID(), payload: { events: [...this.subscriptions] } });
579
- if (resumeFrom > 0 && resumeFrom < payload.cursor) {
580
- this.send({ type: "resume", id: crypto.randomUUID(), payload: { since: resumeFrom } });
581
- } else {
582
- this.lastCursor = payload.cursor;
583
- await this.cursorStore.save(this.lastCursor);
584
- }
585
- this.startHeartbeat();
586
- if (this.heartbeatAckSupported)
587
- this.startHeartbeatWatchdog();
588
- else
589
- this.stopHeartbeatWatchdog();
590
- onRegistered();
591
- return;
592
- }
593
- if (frame.type === "ack") {
594
- if (frame.payload.frameId === this.pendingHeartbeatId) {
595
- this.lastHeartbeatAckAt = Date.now();
596
- this.pendingHeartbeatId = undefined;
597
- this.pendingHeartbeatSentAt = undefined;
598
- this.transport = { ...this.transport, lastHeartbeatAckAt: this.lastHeartbeatAckAt };
599
- }
600
- return;
601
- }
602
- if (frame.type === "event") {
603
- await this.handleEvent(frame.payload);
604
- return;
605
- }
606
- if (frame.type === "resumed") {
607
- for (const event of frame.payload.events)
608
- await this.handleEvent(event);
609
- if (frame.payload.truncated) {
610
- const nextSince = typeof frame.payload.nextSince === "number" ? frame.payload.nextSince : frame.payload.toSeq;
611
- this.send({ type: "resume", id: crypto.randomUUID(), payload: { since: nextSince } });
612
- }
613
- return;
614
- }
615
- if (frame.type === "command.result") {
616
- const pending = this.pending.get(frame.payload.commandId);
617
- if (!pending)
618
- return;
619
- this.pending.delete(frame.payload.commandId);
620
- if (frame.payload.status === "succeeded")
621
- pending.resolve(frame.payload.result ?? null);
622
- else
623
- pending.reject(new Error(frame.payload.error || frame.payload.status));
624
- return;
625
- }
626
- if (frame.type === "error") {
627
- const error = frame;
628
- if (error.payload.code === "REPLAY_GAP") {
629
- await this.options.reconcile?.();
630
- this.lastCursor = this.serverCursor;
631
- await this.cursorStore.save(this.lastCursor);
632
- }
633
- this.emit("error", error.payload.code, error.payload.message);
634
- if (error.payload.frameId) {
635
- const pending = this.pending.get(error.payload.frameId);
636
- if (pending) {
637
- this.pending.delete(error.payload.frameId);
638
- pending.reject(new Error(error.payload.message));
639
- }
640
- }
641
- }
642
- }
643
- async handleEvent(event) {
644
- this.lastCursor = Math.max(this.lastCursor, event.seq);
645
- await this.cursorStore.save(this.lastCursor);
646
- this.emit("event", event.eventType, event.data, event.seq);
647
- if (event.eventType === "command.requested") {
648
- const command = isRecord(event.data.command) ? event.data.command : undefined;
649
- if (command) {
650
- const commandType = typeof command.type === "string" ? command.type : "";
651
- const commandId = typeof command.id === "string" ? command.id : "";
652
- const params = isRecord(command.params) ? command.params : {};
653
- this.emit("command", commandType, params, commandId, command);
654
- }
655
- }
656
- this.emit(event.eventType, event.data);
657
- if (event.eventType.startsWith("task."))
658
- this.emit("task.changed", event.data);
659
- }
660
- startHeartbeat() {
661
- this.stopHeartbeat();
662
- this.sendHeartbeat();
663
- this.heartbeat = setInterval(() => {
664
- this.sendHeartbeat();
665
- }, this.options.heartbeatIntervalMs ?? 30000);
666
- unrefTimer(this.heartbeat);
667
- }
668
- stopHeartbeat() {
669
- if (this.heartbeat)
670
- clearInterval(this.heartbeat);
671
- this.heartbeat = undefined;
672
- this.pendingHeartbeatId = undefined;
673
- this.pendingHeartbeatSentAt = undefined;
674
- }
675
- startHeartbeatWatchdog() {
676
- this.stopHeartbeatWatchdog();
677
- const intervalMs = Math.max(100, Math.min(5000, this.heartbeatIntervalMs()));
678
- this.heartbeatWatchdog = setInterval(() => this.checkHeartbeatAck(), intervalMs);
679
- unrefTimer(this.heartbeatWatchdog);
680
- }
681
- stopHeartbeatWatchdog() {
682
- if (this.heartbeatWatchdog)
683
- clearInterval(this.heartbeatWatchdog);
684
- this.heartbeatWatchdog = undefined;
685
- }
686
- sendHeartbeat() {
687
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.registeredEpoch)
688
- return;
689
- const now = Date.now();
690
- const expectAck = this.heartbeatAckSupported;
691
- if (expectAck && this.pendingHeartbeatId && this.pendingHeartbeatSentAt && now - this.pendingHeartbeatSentAt < this.heartbeatIntervalMs())
692
- return;
693
- const id = expectAck ? crypto.randomUUID() : undefined;
694
- const meta = this.heartbeatMeta();
695
- if (expectAck && id) {
696
- this.pendingHeartbeatId = id;
697
- if (!this.pendingHeartbeatSentAt)
698
- this.pendingHeartbeatSentAt = now;
699
- }
700
- this.send({ type: "heartbeat", ...id ? { id } : {}, payload: { status: this.semanticStatus, ...meta ? { meta } : {} } });
701
- }
702
- checkHeartbeatAck() {
703
- if (!this.pendingHeartbeatSentAt || !this.ws || this.ws.readyState !== WebSocket.OPEN)
704
- return;
705
- const ageMs = Date.now() - this.pendingHeartbeatSentAt;
706
- if (ageMs <= this.heartbeatAckTimeoutMs())
707
- return;
708
- this.forceReconnect(`heartbeat ack timeout after ${ageMs}ms`, { emitError: true, code: 4000 });
709
- }
710
- forceReconnect(reason, opts) {
711
- if (this.closed)
712
- return;
713
- this.transport = { ...this.transport, connected: false, reconnecting: true, lastDisconnectedAt: Date.now(), reconnectReason: reason };
714
- if (opts.emitError)
715
- this.emit("error", "HEARTBEAT_TIMEOUT", reason);
716
- this.stopHeartbeat();
717
- this.stopHeartbeatWatchdog();
718
- const ws = this.ws;
719
- this.ws = null;
720
- this.registeredEpoch = 0;
721
- this.heartbeatAckSupported = false;
722
- if (ws && ws.readyState <= WebSocket.OPEN)
723
- ws.close(opts.code, reason.slice(0, 120));
724
- this.connect();
725
- }
726
- heartbeatIntervalMs() {
727
- return this.options.heartbeatIntervalMs ?? 30000;
728
- }
729
- heartbeatAckTimeoutMs() {
730
- return this.options.heartbeatAckTimeoutMs ?? Math.max(1e4, Math.ceil(this.heartbeatIntervalMs() * 1.5));
731
- }
732
- recordServerFrame() {
733
- this.lastServerFrameAt = Date.now();
734
- this.transport = { ...this.transport, lastServerFrameAt: this.lastServerFrameAt };
735
- }
736
- send(frame) {
737
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
738
- return;
739
- this.ws.send(JSON.stringify(frame));
740
- }
741
- wsUrl() {
742
- const url = new URL(this.options.url);
743
- const token = this.options.token ?? relayTokenFromEnv();
744
- if (token && !url.searchParams.has("token"))
745
- url.searchParams.set("token", token);
746
- return url.toString();
747
- }
748
- registrationMeta() {
749
- return {
750
- ...this.options.meta ?? {},
751
- ...this.options.providerCapabilities ? { providerCapabilities: this.options.providerCapabilities } : {},
752
- ...this.options.context ? { context: this.options.context } : {}
753
- };
754
- }
755
- heartbeatMeta() {
756
- const dynamic = this.options.heartbeatMeta?.();
757
- const meta = {
758
- ...dynamic ?? {},
759
- ...this.options.providerCapabilities ? { providerCapabilities: this.options.providerCapabilities } : {},
760
- ...this.options.context ? { context: this.options.context } : {}
761
- };
762
- return Object.keys(meta).length ? meta : undefined;
763
- }
764
- }
765
- // sdk/src/provider-base.ts
766
- function unrefTimer2(timer) {
767
- timer?.unref?.();
768
- }
769
6
 
770
- class ProviderBase {
771
- options;
772
- client;
773
- running = false;
774
- pending = new Map;
775
- drain;
776
- constructor(options) {
777
- this.options = options;
778
- this.client = new RelayBusClient({
779
- url: options.relayUrl,
780
- role: "provider",
781
- componentId: `${options.adapter.providerName}:${options.agentId}`,
782
- agentId: options.agentId,
783
- instanceId: options.instanceId,
784
- token: options.token,
785
- capabilities: options.adapter.capabilities,
786
- tags: options.tags ?? [options.adapter.providerName],
787
- machine: options.machine,
788
- meta: { provider: options.adapter.providerName, ...options.meta ?? {} }
789
- });
790
- }
791
- async run() {
792
- if (this.running)
793
- return;
794
- this.running = true;
795
- this.client.on("message.new", (message) => this.queueMessage(message));
796
- await this.client.connect();
797
- this.startStatusLoop();
798
- }
799
- async stop() {
800
- this.running = false;
801
- if (this.drain)
802
- clearInterval(this.drain);
803
- this.drain = undefined;
804
- await this.client.close();
805
- }
806
- queueMessage(message) {
807
- if (message.to !== this.options.agentId && message.to !== "broadcast" && !message.to.startsWith("tag:") && !message.to.startsWith("cap:") && !message.to.startsWith("label:") && !message.to.startsWith("team:")) {
808
- return;
809
- }
810
- this.pending.set(message.id, message);
811
- this.drainMessages();
812
- }
813
- async drainMessages() {
814
- if (!this.running || this.pending.size === 0)
815
- return;
816
- const messages2 = [...this.pending.values()].sort((a, b) => a.id - b.id);
817
- this.pending.clear();
818
- await this.options.adapter.deliverMessage(this.options.agentId, messages2);
819
- }
820
- startStatusLoop() {
821
- this.drain = setInterval(async () => {
822
- if (!this.running)
823
- return;
824
- try {
825
- const status = await this.options.adapter.getAgentStatus(this.options.agentId);
826
- this.client.status({ agentStatus: status, ready: true });
827
- } catch {
828
- this.client.status({ agentStatus: "online", ready: false });
829
- }
830
- }, 30000);
831
- unrefTimer2(this.drain);
832
- }
833
- }
834
- // sdk/src/claim-tracker.ts
835
- class WorkClaimTracker {
836
- providerActive = false;
837
- claims = new Map;
838
- setProviderActive(active) {
839
- this.providerActive = active;
840
- }
841
- start(kind, id, expiresAt) {
842
- this.claims.set(`${kind}:${id}`, { kind, id, startedAt: Date.now(), expiresAt });
843
- }
844
- finish(kind, id) {
845
- this.claims.delete(`${kind}:${id}`);
846
- }
847
- expire(now = Date.now()) {
848
- for (const [key, claim] of this.claims) {
849
- if (claim.expiresAt !== undefined && claim.expiresAt <= now)
850
- this.claims.delete(key);
851
- }
852
- }
853
- status() {
854
- return this.providerActive || this.claims.size > 0 ? "busy" : "idle";
855
- }
856
- active() {
857
- return [...this.claims.values()];
858
- }
859
- }
860
- // sdk/src/process-utils.ts
861
- import { execFile } from "child_process";
862
- import { promisify } from "util";
863
- var execFileAsync = promisify(execFile);
864
- // sdk/src/teams-config.ts
865
- var TEAM_IDLE_THRESHOLD_MINUTES_MAX = 24 * 60;
866
- // sdk/src/admission/semaphore.ts
867
- class Semaphore {
868
- #limit;
869
- #inFlight = 0;
870
- #waiters = [];
871
- constructor(limit) {
872
- if (!Number.isInteger(limit) || limit < 1) {
873
- throw new RangeError(`Semaphore limit must be an integer >= 1, got ${limit}`);
874
- }
875
- this.#limit = limit;
876
- }
877
- get limit() {
878
- return this.#limit;
879
- }
880
- get inFlight() {
881
- return this.#inFlight;
882
- }
883
- get waiting() {
884
- return this.#waiters.length;
885
- }
886
- get available() {
887
- return this.#limit - this.#inFlight;
888
- }
889
- tryAcquire() {
890
- if (this.#inFlight < this.#limit) {
891
- this.#inFlight++;
892
- return true;
893
- }
894
- return false;
895
- }
896
- acquire() {
897
- if (this.tryAcquire())
898
- return Promise.resolve();
899
- return new Promise((resolve) => {
900
- this.#waiters.push(resolve);
901
- });
902
- }
903
- release() {
904
- const next = this.#waiters.shift();
905
- if (next) {
906
- next();
907
- return;
908
- }
909
- if (this.#inFlight > 0)
910
- this.#inFlight--;
911
- }
912
- async run(fn) {
913
- await this.acquire();
914
- try {
915
- return await fn();
916
- } finally {
917
- this.release();
918
- }
919
- }
920
- }
921
- // runner/src/config.ts
7
+ // runner/src/message-body-config.ts
922
8
  function messageBodyMaxCharsFromEnv() {
923
9
  return process.env.AGENT_RELAY_MESSAGE_BODY_MAX_CHARS;
924
10
  }
@@ -1085,6 +171,38 @@ function providerMessageBodyMaxChars() {
1085
171
  }
1086
172
  return DEFAULT_PROVIDER_MESSAGE_BODY_MAX_CHARS;
1087
173
  }
174
+ function previewDeliveredBody(rawBody, messageId, maxChars = providerMessageBodyMaxChars()) {
175
+ if (rawBody.length <= maxChars)
176
+ return { body: rawBody, truncated: false, guidance: [] };
177
+ const fetchLines = [
178
+ `Read full: agent-relay get-message ${messageId}`,
179
+ `Body only: agent-relay get-message ${messageId} --body`
180
+ ];
181
+ const marker = (elided2) => `[\u2026 ${elided2} chars elided from the middle \u2014 full report: relay_get_message #${messageId} \u2026]`;
182
+ const budget = maxChars - marker(rawBody.length).length - 4;
183
+ if (budget < 200) {
184
+ const tail2 = rawBody.slice(rawBody.length - maxChars).replace(/^\S*\s+/, "").trimStart();
185
+ return {
186
+ body: tail2,
187
+ truncated: true,
188
+ guidance: [`[truncated: showing last ${tail2.length} of ${rawBody.length} chars \u2014 head trimmed, tail/verdict preserved]`, ...fetchLines]
189
+ };
190
+ }
191
+ const headChars = Math.floor(budget * 0.35);
192
+ const tailChars = budget - headChars;
193
+ const head = rawBody.slice(0, headChars).replace(/\s+\S*$/, "").trimEnd();
194
+ const tail = rawBody.slice(rawBody.length - tailChars).replace(/^\S*\s+/, "").trimStart();
195
+ const elided = rawBody.length - head.length - tail.length;
196
+ return {
197
+ body: `${head}
198
+
199
+ ${marker(elided)}
200
+
201
+ ${tail}`,
202
+ truncated: true,
203
+ guidance: [`[truncated: showing first ${head.length} + last ${tail.length} of ${rawBody.length} chars \u2014 tail/verdict preserved]`, ...fetchLines]
204
+ };
205
+ }
1088
206
  function attachmentRefs(message) {
1089
207
  const payloadRefs = message.payload?.attachments;
1090
208
  const topLevelRefs = message.attachments;
@@ -1111,9 +229,9 @@ var NOTIFICATION_NUDGE = "\u21AA Notification \u2014 no reply needed.";
1111
229
  function isNotificationMessage(message) {
1112
230
  return isPersistedRelayMessage(message) && message.replyExpected === false;
1113
231
  }
1114
- function dedupePersistedMessages(messages2) {
232
+ function dedupePersistedMessages(messages) {
1115
233
  const seen = new Set;
1116
- return messages2.filter((message) => {
234
+ return messages.filter((message) => {
1117
235
  if (!isPersistedRelayMessage(message))
1118
236
  return true;
1119
237
  if (seen.has(message.id))
@@ -1199,8 +317,8 @@ function providerSenderLabel(message) {
1199
317
  function shouldShowReplyReminder(deliveryCount) {
1200
318
  return deliveryCount <= 1 || deliveryCount % REMINDER_EVERY_DELIVERIES === 0;
1201
319
  }
1202
- function latestReplyableMessage(messages2) {
1203
- return messages2.filter((message) => isPersistedRelayMessage2(message) && !isMemoryInjection(message) && !isReactionNotification(message) && !isSpawnObligationMessage(message) && message.replyExpected !== false).at(-1);
320
+ function latestReplyableMessage(messages) {
321
+ return messages.filter((message) => isPersistedRelayMessage2(message) && !isMemoryInjection(message) && !isReactionNotification(message) && !isSpawnObligationMessage(message) && message.replyExpected !== false).at(-1);
1204
322
  }
1205
323
  function formatMessage(message, relaySurface) {
1206
324
  const subject = message.subject ? `Subject: ${message.subject}
@@ -1208,20 +326,8 @@ function formatMessage(message, relaySurface) {
1208
326
  const isMemoryContext = isMemoryInjection(message);
1209
327
  const canFetchMessage = isPersistedRelayMessage2(message);
1210
328
  const rawBody = relaySurface ? message.body : stripRelayScaffolding(message.body);
1211
- const maxChars = providerMessageBodyMaxChars();
1212
- const shouldPreview = canFetchMessage && rawBody.length > maxChars;
1213
- const preview = shouldPreview ? {
1214
- body: rawBody.slice(0, maxChars),
1215
- truncated: true
1216
- } : {
1217
- body: rawBody,
1218
- truncated: false
1219
- };
1220
- const truncationGuidance = preview.truncated ? [
1221
- `[truncated: showing first ${maxChars} of ${rawBody.length} chars]`,
1222
- `Read full: agent-relay get-message ${message.id}`,
1223
- `Body only: agent-relay get-message ${message.id} --body`
1224
- ].join(`
329
+ const preview = canFetchMessage ? previewDeliveredBody(rawBody, message.id, providerMessageBodyMaxChars()) : { body: rawBody, truncated: false, guidance: [] };
330
+ const truncationGuidance = preview.truncated ? preview.guidance.join(`
1225
331
  `) : undefined;
1226
332
  if (isMemoryContext) {
1227
333
  return [
@@ -1250,9 +356,9 @@ function replyReminder(message, readOnly) {
1250
356
  ].join(`
1251
357
  `);
1252
358
  }
1253
- function claudeProviderMessageText(messages2, options) {
359
+ function claudeProviderMessageText(messages, options) {
1254
360
  const relaySurface = options.relaySurface !== false;
1255
- const uniqueMessages = dedupePersistedMessages(messages2);
361
+ const uniqueMessages = dedupePersistedMessages(messages);
1256
362
  const sections = uniqueMessages.map((message) => formatMessage(message, relaySurface));
1257
363
  const replyable = latestReplyableMessage(uniqueMessages);
1258
364
  if (relaySurface && replyable && shouldShowReplyReminder(options.deliveryCount)) {
@@ -1276,7 +382,7 @@ ws.onmessage = (event) => {
1276
382
  const payload = parsePayload(String(event.data));
1277
383
  if (!payload || payload.type !== "message.deliver" || !Array.isArray(payload.messages))
1278
384
  return;
1279
- const messages2 = payload.messages;
385
+ const messages = payload.messages;
1280
386
  deliveryCount += 1;
1281
387
  if (firstDelivery) {
1282
388
  console.log(process.env.AGENT_RELAY_APPROVAL === "read-only" ? claudeReadOnlyRelayContext() : claudeRelayContext());
@@ -1288,14 +394,14 @@ ws.onmessage = (event) => {
1288
394
  console.log("");
1289
395
  firstDelivery = false;
1290
396
  }
1291
- console.log(claudeProviderMessageText(messages2, {
397
+ console.log(claudeProviderMessageText(messages, {
1292
398
  deliveryCount,
1293
399
  readOnly: process.env.AGENT_RELAY_APPROVAL === "read-only"
1294
400
  }));
1295
401
  ws.send(JSON.stringify({
1296
402
  type: "message.delivered",
1297
403
  deliveryId: typeof payload.deliveryId === "string" ? payload.deliveryId : "",
1298
- messageIds: messages2.map((message) => message.id)
404
+ messageIds: messages.map((message) => message.id)
1299
405
  }));
1300
406
  };
1301
407
  ws.onerror = () => process.exit(1);
package/src/adapter.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { AgentProfile, LivenessSignal, Message, ProviderState } from "agent-relay-sdk";
2
- import { isRecord } from "agent-relay-sdk";
2
+ import { isRecord } from "agent-relay-sdk/types/guards";
3
3
  import type { LivenessInputs } from "./liveness";
4
4
  import type { SessionEvent } from "./session-insights";
5
- import { messageBodyMaxCharsFromEnv } from "./config";
5
+ import { messageBodyMaxCharsFromEnv } from "./message-body-config";
6
6
  import { relayReplyReminderText } from "./relay-instructions";
7
7
  import { renderRunnerTemplate } from "./template-resolver";
8
8
 
@@ -250,6 +250,53 @@ export function providerMessageBodyMaxChars(): number {
250
250
  return DEFAULT_PROVIDER_MESSAGE_BODY_MAX_CHARS;
251
251
  }
252
252
 
253
+ export interface DeliveredBodyPreview {
254
+ body: string;
255
+ truncated: boolean;
256
+ /** Retrieval-guidance lines (empty when not truncated). Call sites may append provider extras. */
257
+ guidance: string[];
258
+ }
259
+
260
+ // #1058 — one home for the delivered-body cap. Head-only truncation silently ate the TAIL, which is
261
+ // exactly where a report-up final puts its verdict / SHIP recommendation. Preserve BOTH ends: keep a
262
+ // head slice for opening context and a tail slice for the conclusion, eliding the middle and pointing
263
+ // at the untrimmed body. The tail is favored (it holds the verdict). Slices snap to whitespace so we
264
+ // don't deliver a half-word at either seam. Grep before adding another truncation site — this is it.
265
+ export function previewDeliveredBody(
266
+ rawBody: string,
267
+ messageId: number,
268
+ maxChars: number = providerMessageBodyMaxChars(),
269
+ ): DeliveredBodyPreview {
270
+ if (rawBody.length <= maxChars) return { body: rawBody, truncated: false, guidance: [] };
271
+ const fetchLines = [
272
+ `Read full: agent-relay get-message ${messageId}`,
273
+ `Body only: agent-relay get-message ${messageId} --body`,
274
+ ];
275
+ const marker = (elided: number) => `[… ${elided} chars elided from the middle — full report: relay_get_message #${messageId} …]`;
276
+ // Reserve room for the marker and its two blank-line joins; upper-bound the marker length by using
277
+ // the (always-larger) full body length so the reserved budget can never underflow the real marker.
278
+ const budget = maxChars - marker(rawBody.length).length - 4;
279
+ if (budget < 200) {
280
+ // Cap too small to keep a useful head — front-truncate, preserving the tail/verdict whole.
281
+ const tail = rawBody.slice(rawBody.length - maxChars).replace(/^\S*\s+/, "").trimStart();
282
+ return {
283
+ body: tail,
284
+ truncated: true,
285
+ guidance: [`[truncated: showing last ${tail.length} of ${rawBody.length} chars — head trimmed, tail/verdict preserved]`, ...fetchLines],
286
+ };
287
+ }
288
+ const headChars = Math.floor(budget * 0.35);
289
+ const tailChars = budget - headChars;
290
+ const head = rawBody.slice(0, headChars).replace(/\s+\S*$/, "").trimEnd();
291
+ const tail = rawBody.slice(rawBody.length - tailChars).replace(/^\S*\s+/, "").trimStart();
292
+ const elided = rawBody.length - head.length - tail.length;
293
+ return {
294
+ body: `${head}\n\n${marker(elided)}\n\n${tail}`,
295
+ truncated: true,
296
+ guidance: [`[truncated: showing first ${head.length} + last ${tail.length} of ${rawBody.length} chars — tail/verdict preserved]`, ...fetchLines],
297
+ };
298
+ }
299
+
253
300
  function attachmentRefs(message: Message): Record<string, unknown>[] {
254
301
  const payloadRefs = message.payload?.attachments;
255
302
  const topLevelRefs = (message as Message & { attachments?: unknown }).attachments;
@@ -375,23 +422,11 @@ export function providerMessageText(messages: Message[]): string {
375
422
  const subject = message.subject ? `Subject: ${message.subject}\n` : "";
376
423
  const isMemoryContext = isMemoryInjection(message);
377
424
  const canReferenceMessage = isPersistedRelayMessage(message) && !isMemoryContext && !isReactionNotification(message);
378
- const shouldPreview = canReferenceMessage && message.body.length > maxChars;
379
- const preview = shouldPreview
380
- ? {
381
- body: message.body.slice(0, maxChars),
382
- truncated: true,
383
- }
384
- : {
385
- body: message.body,
386
- truncated: false,
387
- };
425
+ const preview = canReferenceMessage
426
+ ? previewDeliveredBody(message.body, message.id, maxChars)
427
+ : { body: message.body, truncated: false, guidance: [] };
388
428
  const truncationGuidance = preview.truncated
389
- ? [
390
- `[truncated: showing first ${maxChars} of ${message.body.length} chars]`,
391
- `Read full: agent-relay get-message ${message.id}`,
392
- `Body only: agent-relay get-message ${message.id} --body`,
393
- `Long reply CLI fallback: agent-relay /reply ${message.id} --body-file response.md`,
394
- ].join("\n")
429
+ ? [...preview.guidance, `Long reply CLI fallback: agent-relay /reply ${message.id} --body-file response.md`].join("\n")
395
430
  : undefined;
396
431
  if (isMemoryContext) {
397
432
  return [
@@ -1,6 +1,6 @@
1
1
  import type { Message } from "agent-relay-sdk";
2
- import { isRecord } from "agent-relay-sdk";
3
- import { dedupePersistedMessages, isNotificationMessage, NOTIFICATION_NUDGE, providerAttachmentText, providerMessageBodyMaxChars } from "../adapter";
2
+ import { isRecord } from "agent-relay-sdk/types/guards";
3
+ import { dedupePersistedMessages, isNotificationMessage, NOTIFICATION_NUDGE, previewDeliveredBody, providerAttachmentText, providerMessageBodyMaxChars } from "../adapter";
4
4
  import { relayReplyReminderText } from "../relay-instructions";
5
5
 
6
6
  const REMINDER_EVERY_DELIVERIES = 5;
@@ -74,24 +74,10 @@ function formatMessage(message: Message, relaySurface: boolean): string {
74
74
  const isMemoryContext = isMemoryInjection(message);
75
75
  const canFetchMessage = isPersistedRelayMessage(message);
76
76
  const rawBody = relaySurface ? message.body : stripRelayScaffolding(message.body);
77
- const maxChars = providerMessageBodyMaxChars();
78
- const shouldPreview = canFetchMessage && rawBody.length > maxChars;
79
- const preview = shouldPreview
80
- ? {
81
- body: rawBody.slice(0, maxChars),
82
- truncated: true,
83
- }
84
- : {
85
- body: rawBody,
86
- truncated: false,
87
- };
88
- const truncationGuidance = preview.truncated
89
- ? [
90
- `[truncated: showing first ${maxChars} of ${rawBody.length} chars]`,
91
- `Read full: agent-relay get-message ${message.id}`,
92
- `Body only: agent-relay get-message ${message.id} --body`,
93
- ].join("\n")
94
- : undefined;
77
+ const preview = canFetchMessage
78
+ ? previewDeliveredBody(rawBody, message.id, providerMessageBodyMaxChars())
79
+ : { body: rawBody, truncated: false, guidance: [] };
80
+ const truncationGuidance = preview.truncated ? preview.guidance.join("\n") : undefined;
95
81
 
96
82
  if (isMemoryContext) {
97
83
  return [
package/src/config.ts CHANGED
@@ -6,6 +6,7 @@ import { DEFAULT_RELAY_URL, RELAY_TOKEN_HEADER, errMessage, stringValue } from "
6
6
  import type { SettingEntry, WorkspaceMetadata } from "agent-relay-sdk";
7
7
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
8
8
  import { getManifest } from "agent-relay-providers";
9
+ export { messageBodyMaxCharsFromEnv } from "./message-body-config";
9
10
 
10
11
  interface ProviderConfig {
11
12
  command: string;
@@ -241,10 +242,6 @@ export function providerHomeRootFromEnv(): string {
241
242
  return process.env.AGENT_RELAY_PROVIDER_HOME_ROOT || join(homedir(), ".agent-relay", "provider-homes");
242
243
  }
243
244
 
244
- export function messageBodyMaxCharsFromEnv(): string | undefined {
245
- return process.env.AGENT_RELAY_MESSAGE_BODY_MAX_CHARS;
246
- }
247
-
248
245
  export function loadProviderConfig(provider: string, home = agentRelayHome()): LoadedProviderConfig {
249
246
  const path = join(providersDir(home), `${provider}.json`);
250
247
  const raw = readJson(path);
@@ -0,0 +1,3 @@
1
+ export function messageBodyMaxCharsFromEnv(): string | undefined {
2
+ return process.env.AGENT_RELAY_MESSAGE_BODY_MAX_CHARS;
3
+ }
@@ -0,0 +1 @@
1
+ export { messageBodyMaxCharsFromEnv } from "./message-body-config/config";