agent-relay-runner 0.40.0 → 0.41.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "directory": "runner"
21
21
  },
22
22
  "dependencies": {
23
- "agent-relay-sdk": "0.2.25"
23
+ "agent-relay-sdk": "0.2.26"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "latest",
@@ -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.40.0",
4
+ "version": "0.41.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
package/src/adapter.ts CHANGED
@@ -136,7 +136,8 @@ export interface ProviderAdapter {
136
136
  provider: string;
137
137
  spawn(config: RunnerSpawnConfig): Promise<ManagedProcess>;
138
138
  shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void>;
139
- compact?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
139
+ compact?(process: ManagedProcess, opts?: { instructions?: string }): Promise<Record<string, unknown> | void>;
140
+ compactSupportsInstructions?: boolean;
140
141
  clearContext?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
141
142
  // Normalize the session so far into the provider-agnostic SessionEvent stream the
142
143
  // Insights context-ratio signal (#183/#184) reduces. Called by the runner's
@@ -147,6 +148,10 @@ export interface ProviderAdapter {
147
148
  // ignore it and return their accumulated log. Return null when there is nothing to
148
149
  // measure. Best-effort: may be omitted by providers without a session view yet.
149
150
  collectSessionEvents?(process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<SessionEvent[] | null>;
151
+ // Full searchable transcript/archive source for destructive boundaries. The runner
152
+ // slices the returned stream by cursor, so adapters should return the session-so-far
153
+ // view when they have one.
154
+ collectSessionArchiveSegment?(process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<string | null>;
150
155
  // Interrupt the in-flight turn without ending the session (ESC for Claude's
151
156
  // tmux pane, turn/interrupt for the Codex app-server). Provider-independent at
152
157
  // the runner boundary; each adapter does what its provider actually supports.
@@ -185,7 +190,7 @@ export function profileAllowsRelayFeature(config: RunnerSpawnConfig, feature: ke
185
190
  return config.agentProfile?.relay?.[feature] !== false;
186
191
  }
187
192
 
188
- export const RELAY_CONTEXT = `[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval. If Relay MCP tools are available, prefer relay_reply, relay_get_message, relay_get_thread, relay_send_message, relay_upload_artifact, relay_attach_artifact, relay_agent_status, relay_find_agents, relay_spawn_agent, and relay_shutdown_agent. You never need to know or pass your own agent id — relay fills it from your token; use relay_whoami only if you need to reason about yourself. relay_spawn_targets / relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present, call relay_spawn_targets FIRST for the live host/provider/model matrix + your quota, then stand up long-living child agents and shut down your own — find them later with relay_find_agents spawnedBy:me. CLI fallback: agent-relay /reply <messageId> --stdin < response.md; if a delivered message says it was truncated, fetch the full body with: agent-relay get-message <messageId>. For command details, run: agent-relay /guide`;
193
+ export const RELAY_CONTEXT = `[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval. If Relay MCP tools are available, prefer relay_reply, relay_get_message, relay_get_thread, relay_send_message, relay_upload_artifact, relay_attach_artifact, relay_agent_status, relay_find_agents, relay_compact_and_resume, relay_recall, relay_spawn_agent, and relay_shutdown_agent. You never need to know or pass your own agent id — relay fills it from your token; use relay_whoami only if you need to reason about yourself. relay_compact_and_resume is for clean-seam self-resume after a context advisory: pass workingState and optional ruledOut; Relay owns the objective envelope. relay_recall searches your own archived pre-compaction segments by keyword when a discarded detail is needed. relay_spawn_targets / relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present, call relay_spawn_targets FIRST for the live host/provider/model matrix + your quota, then stand up long-living child agents and shut down your own — find them later with relay_find_agents spawnedBy:me. CLI fallback: agent-relay /reply <messageId> --stdin < response.md; if a delivered message says it was truncated, fetch the full body with: agent-relay get-message <messageId>. For command details, run: agent-relay /guide`;
189
194
 
190
195
  // #306 — deliver the FULL message body by default. Only a pathological body beyond this
191
196
  // high cap truncates (with a get-message hint) so it can't nuke an agent's context; the 99%
@@ -15,6 +15,7 @@ import { claudeProviderMessageText } from "./claude-delivery";
15
15
 
16
16
  export class ClaudeAdapter implements ProviderAdapter {
17
17
  readonly provider = "claude";
18
+ readonly compactSupportsInstructions = true;
18
19
  // #352: initial prompt is seeded as Claude's positional launch arg (buildSpawnArgs) — reliable,
19
20
  // no send-keys/onboarding race; tells the runner to skip the redundant post-launch delivery.
20
21
  readonly seedsInitialPromptAtLaunch = true;
@@ -55,12 +56,13 @@ export class ClaudeAdapter implements ProviderAdapter {
55
56
  await terminateSingleProcess(process, opts);
56
57
  }
57
58
 
58
- async compact(process: ManagedProcess): Promise<Record<string, unknown>> {
59
+ async compact(process: ManagedProcess, opts?: { instructions?: string }): Promise<Record<string, unknown>> {
59
60
  const session = process.meta?.tmuxSession as string | undefined;
60
61
  const socket = process.meta?.tmuxSocket as string | undefined;
61
62
  if (!session || !tmuxHasSession(session, socket)) throw new Error("no active tmux session for compact");
62
- await submitTextToTmux(session, "/compact", socket);
63
- return { method: "tmux-inject", command: "/compact" };
63
+ const command = opts?.instructions ? `/compact ${opts.instructions}` : "/compact";
64
+ await submitTextToTmux(session, command, socket);
65
+ return { method: "tmux-inject", command };
64
66
  }
65
67
 
66
68
  async clearContext(process: ManagedProcess): Promise<Record<string, unknown>> {
@@ -84,6 +86,15 @@ export class ClaudeAdapter implements ProviderAdapter {
84
86
  return collectClaudeSessionEvents(jsonl);
85
87
  }
86
88
 
89
+ async collectSessionArchiveSegment(_process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<string | null> {
90
+ if (!ctx.transcriptPath) return null;
91
+ try {
92
+ return await readFile(ctx.transcriptPath, "utf8");
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
87
98
  async interrupt(process: ManagedProcess): Promise<Record<string, unknown>> {
88
99
  const session = process.meta?.tmuxSession as string | undefined;
89
100
  const socket = process.meta?.tmuxSocket as string | undefined;
package/src/runner.ts CHANGED
@@ -231,6 +231,8 @@ export class AgentRunner {
231
231
  // (transcript rotated, Codex buffer trimmed) resets the cursor.
232
232
  private insightsObserved = 0;
233
233
  private insightsCursorKey = "";
234
+ private archiveObservedChars = 0;
235
+ private archiveCursorKey = "";
234
236
  // Memoized repo-name project id for insight observations (resolved once; involves a
235
237
  // git toplevel lookup for direct agents). Aggregates by repo, not per-session worktree.
236
238
  private insightProjectName?: string;
@@ -737,7 +739,9 @@ export class AgentRunner {
737
739
  else if (type === "agent.reconnect") this.publishStatus();
738
740
  else if (type === "agent.compact") {
739
741
  if (!this.options.adapter.compact || !this.process) throw new Error("provider does not support compact");
740
- providerResult = await this.options.adapter.compact(this.process);
742
+ providerResult = await this.options.adapter.compact(this.process, {
743
+ instructions: typeof params.instructions === "string" ? params.instructions : undefined,
744
+ });
741
745
  } else if (type === "agent.clearContext") {
742
746
  if (!this.options.adapter.clearContext || !this.process) throw new Error("provider does not support clearContext");
743
747
  providerResult = await this.options.adapter.clearContext(this.process);
@@ -768,7 +772,11 @@ export class AgentRunner {
768
772
  } finally {
769
773
  this.claims.finishClaim("command", commandId);
770
774
  if (exitAfterCommand) {
771
- await this.http.deleteAgent(this.agentId).catch(() => {});
775
+ if (params.preserveRegistration === true) {
776
+ await this.http.setStatus(this.agentId, "offline", this.options.instanceId).catch(() => {});
777
+ } else {
778
+ await this.http.deleteAgent(this.agentId).catch(() => {});
779
+ }
772
780
  if (this.options.exitProcessOnShutdown !== false) {
773
781
  setTimeout(() => void this.stop().catch((error) => {
774
782
  logger.error("lifecycle", `stop after command failed: ${error}`);
@@ -1301,6 +1309,13 @@ export class AgentRunner {
1301
1309
  });
1302
1310
  return;
1303
1311
  }
1312
+ if (record.kind === "continuation-archive") {
1313
+ await this.http.recordContinuationArchive({
1314
+ ...(record.payload as Parameters<RelayHttpClient["recordContinuationArchive"]>[0]),
1315
+ occurredAt: record.occurredAt,
1316
+ });
1317
+ return;
1318
+ }
1304
1319
  if (record.kind === "mcp-tool-call") {
1305
1320
  await this.deliverBufferedMcpCall(record);
1306
1321
  return;
@@ -1434,11 +1449,14 @@ export class AgentRunner {
1434
1449
  this.publishFinalizing(reason);
1435
1450
  try {
1436
1451
  await Promise.race([
1437
- this.captureContextRatio(reason, opts),
1452
+ Promise.all([
1453
+ this.captureContextRatio(reason, opts),
1454
+ this.captureContinuationArchive(reason, opts),
1455
+ ]).then(() => undefined),
1438
1456
  new Promise<void>((resolve) => setTimeout(resolve, PRE_DESTROY_TIMEOUT_MS)),
1439
1457
  ]);
1440
1458
  } catch (error) {
1441
- this.sessionLog(`insights: pre-destroy capture failed: ${errMessage(error)}`);
1459
+ this.sessionLog(`pre-destroy capture failed: ${errMessage(error)}`);
1442
1460
  }
1443
1461
  // For exit-bound transitions the runner won't be alive afterward to drain the durable
1444
1462
  // outbox, so block (bounded) on delivering what capture just enqueued. This runs before
@@ -1478,6 +1496,30 @@ export class AgentRunner {
1478
1496
  return (this.insightProjectName ??= resolveProjectName(this.options.cwd, this.options.workspace));
1479
1497
  }
1480
1498
 
1499
+ private async captureContinuationArchive(reason: SessionDestroyReason, opts?: { transcriptPath?: string }): Promise<void> {
1500
+ const adapter = this.options.adapter;
1501
+ if (!adapter.collectSessionArchiveSegment || !this.process) return;
1502
+ const transcriptPath = opts?.transcriptPath ?? this.lastTranscriptPath;
1503
+ const archive = await adapter.collectSessionArchiveSegment(this.process, { transcriptPath });
1504
+ if (!archive) return;
1505
+ const key = transcriptPath ?? `session:${this.providerSessionId}`;
1506
+ if (key !== this.archiveCursorKey || archive.length < this.archiveObservedChars) {
1507
+ this.archiveCursorKey = key;
1508
+ this.archiveObservedChars = 0;
1509
+ }
1510
+ const segment = archive.slice(this.archiveObservedChars).trim();
1511
+ this.archiveObservedChars = archive.length;
1512
+ if (!segment) return;
1513
+ this.outbox.enqueue({
1514
+ kind: "continuation-archive",
1515
+ payload: {
1516
+ agentId: this.agentId,
1517
+ segment,
1518
+ },
1519
+ });
1520
+ this.sessionLog(`continuation archive queued (${segment.length} chars, ${reason})`);
1521
+ }
1522
+
1481
1523
  private async captureContextRatio(reason: SessionDestroyReason, opts?: { transcriptPath?: string }): Promise<void> {
1482
1524
  const adapter = this.options.adapter;
1483
1525
  if (!adapter.collectSessionEvents || !this.process) return;
@@ -2066,7 +2108,7 @@ export class AgentRunner {
2066
2108
  const meta: Record<string, unknown> = {
2067
2109
  providerCapabilities: runtimeProviderCapabilities(
2068
2110
  this.options,
2069
- context ? { source: context.source, confidence: context.confidence } : undefined,
2111
+ context,
2070
2112
  probeModel,
2071
2113
  ),
2072
2114
  ...(terminalSession ? { tmuxSession: terminalSession } : {}),
@@ -2319,7 +2361,7 @@ interface ProbeModelInfo {
2319
2361
  effort?: string;
2320
2362
  }
2321
2363
 
2322
- function runtimeProviderCapabilities(options: RunnerOptions, contextStats?: { source: "api" | "statusline" | "hook" | "estimate"; confidence: "exact" | "reported" | "estimated" }, probeModel?: ProbeModelInfo): ProviderCapabilities {
2364
+ function runtimeProviderCapabilities(options: RunnerOptions, contextState?: ContextState, probeModel?: ProbeModelInfo): ProviderCapabilities {
2323
2365
  const model = options.model ?? probeModel?.model;
2324
2366
  const effort = options.effort ?? probeModel?.effort;
2325
2367
  const modelSource = options.model ? "runtime" as const : probeModel?.model ? "provider" as const : "runtime" as const;
@@ -2349,7 +2391,7 @@ function runtimeProviderCapabilities(options: RunnerOptions, contextStats?: { so
2349
2391
  confidence: "reported",
2350
2392
  lastUpdatedAt: options.startedAt,
2351
2393
  },
2352
- ...runtimeProviderContextCapabilities(options, contextStats),
2394
+ ...runtimeProviderContextCapabilities(options, contextState),
2353
2395
  ...runtimeProviderTerminalCapabilities(options),
2354
2396
  liveSession: {
2355
2397
  capture: true,
@@ -2418,14 +2460,22 @@ function appliedAgentProfileMetadata(provider: string, profile: AgentProfile): R
2418
2460
  };
2419
2461
  }
2420
2462
 
2421
- function runtimeProviderContextCapabilities(options: RunnerOptions, contextStats?: { source: "api" | "statusline" | "hook" | "estimate"; confidence: "exact" | "reported" | "estimated" }): Pick<ProviderCapabilities, "context"> {
2422
- const context: NonNullable<ProviderCapabilities["context"]> = {};
2423
- if (contextStats) context.stats = contextStats;
2424
- if (options.provider === "codex" || (options.provider === "claude" && options.headless)) {
2463
+ function runtimeProviderContextCapabilities(options: RunnerOptions, contextState?: ContextState): Pick<ProviderCapabilities, "context"> {
2464
+ const context: NonNullable<ProviderCapabilities["context"]> = { resume: "none" };
2465
+ const supportsManagedContext = options.provider === "codex" || (options.provider === "claude" && options.headless);
2466
+ if (contextState) {
2467
+ context.stats = { source: contextState.source, confidence: contextState.confidence };
2468
+ if (typeof contextState.tokensMax === "number" && Number.isFinite(contextState.tokensMax)) {
2469
+ context.windowTokens = contextState.tokensMax;
2470
+ }
2471
+ }
2472
+ if (supportsManagedContext) {
2425
2473
  context.compact = true;
2426
2474
  context.clear = true;
2427
2475
  }
2428
2476
  context.inject = true;
2477
+ if (supportsManagedContext && options.adapter.compact && options.adapter.compactSupportsInstructions) context.resume = "native";
2478
+ else if (supportsManagedContext && options.adapter.clearContext) context.resume = "clear-inject";
2429
2479
  return Object.keys(context).length ? { context } : {};
2430
2480
  }
2431
2481