agent-relay-server 0.122.0 → 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/docs/openapi.json +1 -1
- package/package.json +5 -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 +672 -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/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 +508 -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 +305 -0
- package/runner/src/providers.ts +20 -0
- package/runner/src/provisioning.ts +314 -0
- package/runner/src/quota.ts +40 -0
- package/runner/src/rate-limit.ts +189 -0
- package/runner/src/relay-injection-events.ts +102 -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 +43 -0
- package/src/gate-resolver.ts +7 -12
package/docs/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Relay API",
|
|
5
|
-
"version": "0.122.
|
|
5
|
+
"version": "0.122.1",
|
|
6
6
|
"description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
|
|
7
7
|
"license": {
|
|
8
8
|
"name": "MIT",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-server",
|
|
3
|
-
"version": "0.122.
|
|
3
|
+
"version": "0.122.1",
|
|
4
4
|
"description": "Lightweight HTTP message relay for inter-agent communication across machines",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -26,8 +26,7 @@
|
|
|
26
26
|
"recipes/**",
|
|
27
27
|
"scripts/install-bin-shim.cjs",
|
|
28
28
|
"scripts/orchestrator-spawn-smoke.ts",
|
|
29
|
-
"runner/src
|
|
30
|
-
"runner/src/adapter.ts",
|
|
29
|
+
"runner/src/**/*.ts",
|
|
31
30
|
"runner/plugins/**",
|
|
32
31
|
"!runner/**/*.test.ts",
|
|
33
32
|
"public/**",
|
|
@@ -37,7 +36,7 @@
|
|
|
37
36
|
"CONTRIBUTING.md"
|
|
38
37
|
],
|
|
39
38
|
"dependencies": {
|
|
40
|
-
"agent-relay-channels-host": "0.122.
|
|
39
|
+
"agent-relay-channels-host": "0.122.1",
|
|
41
40
|
"agent-relay-providers": "0.104.3",
|
|
42
41
|
"agent-relay-sdk": "0.2.114",
|
|
43
42
|
"ajv": "^8.20.0"
|
|
@@ -54,8 +53,8 @@
|
|
|
54
53
|
"build:dashboard": "bun run build:dashboard:bundle && bun run deploy:dashboard",
|
|
55
54
|
"build:dashboard:bundle": "bun run build:sdk && cd dashboard && bun run build",
|
|
56
55
|
"deploy:dashboard": "bun run scripts/deploy-dashboard.ts",
|
|
57
|
-
"ci:land": "bun run lint:import-boundaries && bun run lint:provider-contract && bun run typecheck && bun run docs:api:check && bun test",
|
|
58
|
-
"ci": "bun run
|
|
56
|
+
"ci:land": "bun run build:dashboard:bundle && bun run build:claude-monitor:check && bun run lint:import-boundaries && bun run lint:provider-contract && bun run typecheck && bun run docs:api:check && bun test",
|
|
57
|
+
"ci": "bun run ci:land",
|
|
59
58
|
"test": "bun test",
|
|
60
59
|
"smoke:spawn": "bun run scripts/orchestrator-spawn-smoke.ts",
|
|
61
60
|
"publish:smoke": "bun run scripts/publish-smoke.ts",
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { Message } from "agent-relay-sdk";
|
|
2
|
+
import { isRecord } from "agent-relay-sdk/types/guards";
|
|
3
|
+
import { dedupePersistedMessages, isNotificationMessage, NOTIFICATION_NUDGE, previewDeliveredBody, providerAttachmentText, providerMessageBodyMaxChars } from "../adapter";
|
|
4
|
+
import { relayReplyReminderText } from "../relay-instructions";
|
|
5
|
+
|
|
6
|
+
const REMINDER_EVERY_DELIVERIES = 5;
|
|
7
|
+
|
|
8
|
+
interface ClaudeDeliveryTextOptions {
|
|
9
|
+
deliveryCount: number;
|
|
10
|
+
readOnly?: boolean;
|
|
11
|
+
// false = isolated profile with no relay plugin/CLI surface. The agent cannot /reply or
|
|
12
|
+
// /claim, so strip the reply-reminder block and the server-baked claim-task instruction —
|
|
13
|
+
// it's just confusing noise in a clean-room run. Defaults to true (relay-aware agents).
|
|
14
|
+
relaySurface?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Server-baked trailing line from taskMessageBody() in src/db.ts. Meaningless to an isolated
|
|
18
|
+
// agent (sole claimant, no relay CLI), so drop it from delivered text when there's no surface.
|
|
19
|
+
const TASK_CLAIM_INSTRUCTION = "Claim this task before working it, then update task status when finished.";
|
|
20
|
+
|
|
21
|
+
function stripRelayScaffolding(body: string): string {
|
|
22
|
+
const lines = body.split("\n");
|
|
23
|
+
const popTrailingBlanks = () => {
|
|
24
|
+
while (lines.length && (lines.at(-1) ?? "").trim() === "") lines.pop();
|
|
25
|
+
};
|
|
26
|
+
popTrailingBlanks();
|
|
27
|
+
if (lines.at(-1) === TASK_CLAIM_INSTRUCTION) {
|
|
28
|
+
lines.pop();
|
|
29
|
+
popTrailingBlanks();
|
|
30
|
+
}
|
|
31
|
+
return lines.join("\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isMemoryInjection(message: Message): boolean {
|
|
35
|
+
return isRecord(message.payload) && message.payload.memoryInjection === true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isReactionNotification(message: Message): boolean {
|
|
39
|
+
const event = isRecord(message.payload?.event) ? message.payload.event : undefined;
|
|
40
|
+
return message.payload?.reactionNotification === true || event?.type === "message.reaction";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isSpawnObligationMessage(message: Message): boolean {
|
|
44
|
+
return isRecord(message.payload) && message.payload.source === "spawn-obligation";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isPersistedRelayMessage(message: Message): boolean {
|
|
48
|
+
return Number.isSafeInteger(message.id) && message.id > 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function providerSenderLabel(message: Message): string {
|
|
52
|
+
if (message.from === "user") return "human user";
|
|
53
|
+
const source = isRecord(message.payload?.source) ? message.payload.source : undefined;
|
|
54
|
+
const isHumanChannel = Boolean(source && (
|
|
55
|
+
isRecord(source.telegram) ||
|
|
56
|
+
isRecord(source.slack) ||
|
|
57
|
+
isRecord(source.discord)
|
|
58
|
+
));
|
|
59
|
+
return isHumanChannel ? `human user via ${message.from}` : message.from;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function shouldShowReplyReminder(deliveryCount: number): boolean {
|
|
63
|
+
return deliveryCount <= 1 || deliveryCount % REMINDER_EVERY_DELIVERIES === 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function latestReplyableMessage(messages: Message[]): Message | undefined {
|
|
67
|
+
return messages
|
|
68
|
+
.filter((message) => isPersistedRelayMessage(message) && !isMemoryInjection(message) && !isReactionNotification(message) && !isSpawnObligationMessage(message) && message.replyExpected !== false)
|
|
69
|
+
.at(-1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatMessage(message: Message, relaySurface: boolean): string {
|
|
73
|
+
const subject = message.subject ? `Subject: ${message.subject}\n` : "";
|
|
74
|
+
const isMemoryContext = isMemoryInjection(message);
|
|
75
|
+
const canFetchMessage = isPersistedRelayMessage(message);
|
|
76
|
+
const rawBody = relaySurface ? message.body : stripRelayScaffolding(message.body);
|
|
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;
|
|
81
|
+
|
|
82
|
+
if (isMemoryContext) {
|
|
83
|
+
return [
|
|
84
|
+
`[agent-relay context from ${message.from}]`,
|
|
85
|
+
providerAttachmentText(message),
|
|
86
|
+
`${subject}${preview.body}`,
|
|
87
|
+
truncationGuidance,
|
|
88
|
+
].filter(Boolean).join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return [
|
|
92
|
+
`[relay message #${message.id} from ${providerSenderLabel(message)}]`,
|
|
93
|
+
providerAttachmentText(message),
|
|
94
|
+
`${subject}${preview.body}`,
|
|
95
|
+
truncationGuidance,
|
|
96
|
+
].filter(Boolean).join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function replyReminder(message: Message, readOnly: boolean): string {
|
|
100
|
+
const command = readOnly
|
|
101
|
+
? `agent-relay /reply ${message.id} "<your reply>"`
|
|
102
|
+
: relayReplyReminderText(message.id);
|
|
103
|
+
return [
|
|
104
|
+
"[agent-relay reply reminder]",
|
|
105
|
+
readOnly
|
|
106
|
+
? `If this batch needs a response, send one useful reply to the latest relevant message: ${command}`
|
|
107
|
+
: command,
|
|
108
|
+
"If you already delivered the useful response through Relay, do not send a separate status-only confirmation.",
|
|
109
|
+
"If multiple messages arrived together, cover them in one reply instead of answering each line separately.",
|
|
110
|
+
].join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function claudeProviderMessageText(messages: Message[], options: ClaudeDeliveryTextOptions): string {
|
|
114
|
+
const relaySurface = options.relaySurface !== false;
|
|
115
|
+
const uniqueMessages = dedupePersistedMessages(messages);
|
|
116
|
+
const sections = uniqueMessages.map((message) => formatMessage(message, relaySurface));
|
|
117
|
+
const replyable = latestReplyableMessage(uniqueMessages);
|
|
118
|
+
// Isolated agents have no way to reply through Relay — never append the reminder/nudge.
|
|
119
|
+
if (relaySurface && replyable && shouldShowReplyReminder(options.deliveryCount)) {
|
|
120
|
+
sections.push(replyReminder(replyable, options.readOnly === true));
|
|
121
|
+
} else if (relaySurface && !replyable && uniqueMessages.some(isNotificationMessage)) {
|
|
122
|
+
// #283 — pure notification batch (no message wants a reply): drop the scaffold, append the
|
|
123
|
+
// one-line nudge so a long context can't make the agent forget the session-start no-reply rule.
|
|
124
|
+
sections.push(NOTIFICATION_NUDGE);
|
|
125
|
+
}
|
|
126
|
+
return sections.join("\n\n");
|
|
127
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { QuotaState, RelayHttpClient } from "agent-relay-sdk";
|
|
2
|
+
import { quotaStateFromProbeMetrics } from "agent-relay-sdk/context-probe";
|
|
3
|
+
import { readRunnerContextProbeState } from "../context-probe-state";
|
|
4
|
+
import { DEFAULT_PROVIDER_QUOTA_CONFIG, normalizeProviderQuotaConfig, resolveClaudeStatuslineQuotaIdentity } from "../quota";
|
|
5
|
+
|
|
6
|
+
const PASSIVE_QUOTA_CONFIG_REFRESH_MS = 60_000;
|
|
7
|
+
const PASSIVE_QUOTA_SAMPLE_STALE_MS = 10 * 60_000;
|
|
8
|
+
const PASSIVE_QUOTA_LEASE_MAX_TTL_MS = 10 * 60_000;
|
|
9
|
+
const PASSIVE_QUOTA_LEASE_PADDING_MS = 60_000;
|
|
10
|
+
|
|
11
|
+
interface ClaudeQuotaHarvestOptions {
|
|
12
|
+
agentId: string;
|
|
13
|
+
provider: string;
|
|
14
|
+
http: () => Pick<RelayHttpClient, "acquireProviderQuotaPublisherLease" | "getProviderQuotaConfig" | "upsertProviderQuota">;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class ClaudeQuotaHarvest {
|
|
18
|
+
private config = { ...DEFAULT_PROVIDER_QUOTA_CONFIG };
|
|
19
|
+
private configRefreshAt = 0;
|
|
20
|
+
private lastReportAt?: number;
|
|
21
|
+
private lease?: { accountKey: string; leaseToken: string; expiresAt: number };
|
|
22
|
+
private nextLeaseAttemptAt?: number;
|
|
23
|
+
|
|
24
|
+
constructor(private readonly options: ClaudeQuotaHarvestOptions) {}
|
|
25
|
+
|
|
26
|
+
async publish(): Promise<void> {
|
|
27
|
+
if (this.options.provider !== "claude") return;
|
|
28
|
+
const probeMetrics = readRunnerContextProbeState(this.options.agentId);
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
const quota = probeMetrics ? freshQuotaFromProbeMetrics(probeMetrics, now) : undefined;
|
|
31
|
+
if (!quota) return;
|
|
32
|
+
const config = await this.refreshConfig();
|
|
33
|
+
if (!config.enabled) return;
|
|
34
|
+
if (this.lastReportAt !== undefined && now - this.lastReportAt < config.pollIntervalMs) return;
|
|
35
|
+
const identity = await resolveClaudeStatuslineQuotaIdentity({ env: process.env });
|
|
36
|
+
if (!identity) return;
|
|
37
|
+
if (!await this.ensurePublisherLease(identity.accountKey, config.pollIntervalMs, now)) return;
|
|
38
|
+
await this.options.http().upsertProviderQuota({
|
|
39
|
+
provider: "claude",
|
|
40
|
+
accountKey: identity.accountKey,
|
|
41
|
+
quota,
|
|
42
|
+
lastAttemptAt: quota.updatedAt,
|
|
43
|
+
sourceAgentId: this.options.agentId,
|
|
44
|
+
});
|
|
45
|
+
this.lastReportAt = now;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private async refreshConfig() {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
if (now < this.configRefreshAt) return this.config;
|
|
51
|
+
this.configRefreshAt = now + PASSIVE_QUOTA_CONFIG_REFRESH_MS;
|
|
52
|
+
try {
|
|
53
|
+
const map = await this.options.http().getProviderQuotaConfig();
|
|
54
|
+
this.config = map.claude
|
|
55
|
+
? normalizeProviderQuotaConfig(map.claude)
|
|
56
|
+
: { ...DEFAULT_PROVIDER_QUOTA_CONFIG };
|
|
57
|
+
} catch {
|
|
58
|
+
// Keep the previous config; defaults preserve collection if the relay is older
|
|
59
|
+
// or temporarily unreachable during a liveness tick.
|
|
60
|
+
}
|
|
61
|
+
return this.config;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private async ensurePublisherLease(accountKey: string, pollIntervalMs: number, now: number): Promise<boolean> {
|
|
65
|
+
if (this.lease?.accountKey === accountKey && this.lease.expiresAt > now) return true;
|
|
66
|
+
if (this.nextLeaseAttemptAt !== undefined && now < this.nextLeaseAttemptAt) return false;
|
|
67
|
+
const leaseToken = this.lease?.accountKey === accountKey ? this.lease.leaseToken : undefined;
|
|
68
|
+
const result = await this.options.http().acquireProviderQuotaPublisherLease({
|
|
69
|
+
provider: "claude",
|
|
70
|
+
accountKey,
|
|
71
|
+
sourceAgentId: this.options.agentId,
|
|
72
|
+
...(leaseToken ? { leaseToken } : {}),
|
|
73
|
+
ttlMs: Math.min(PASSIVE_QUOTA_LEASE_MAX_TTL_MS, pollIntervalMs + PASSIVE_QUOTA_LEASE_PADDING_MS),
|
|
74
|
+
});
|
|
75
|
+
if (!result.acquired || !result.lease) {
|
|
76
|
+
this.lease = undefined;
|
|
77
|
+
this.nextLeaseAttemptAt = now + Math.min(result.retryAfterMs ?? pollIntervalMs, pollIntervalMs);
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
this.lease = { accountKey, leaseToken: result.lease.leaseToken, expiresAt: result.lease.expiresAt };
|
|
81
|
+
this.nextLeaseAttemptAt = undefined;
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function freshQuotaFromProbeMetrics(metrics: NonNullable<ReturnType<typeof readRunnerContextProbeState>>, now: number): QuotaState | undefined {
|
|
87
|
+
if (now - metrics.timestamp > PASSIVE_QUOTA_SAMPLE_STALE_MS) return undefined;
|
|
88
|
+
const quota = quotaStateFromProbeMetrics(metrics);
|
|
89
|
+
if (!quota) return undefined;
|
|
90
|
+
const windows = quota.windows.filter((window) => window.resetsAt === undefined || window.resetsAt > now);
|
|
91
|
+
return windows.length > 0 ? { ...quota, windows, updatedAt: now } : undefined;
|
|
92
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// #766/#768 — Claude Code's intrinsic, machine-readable session probe.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code ≥2.1.119 writes `$CLAUDE_CONFIG_DIR/sessions/<pid>.json` and rewrites
|
|
4
|
+
// it on every status transition. It carries an authoritative `status` field
|
|
5
|
+
// (busy|shell|idle|waiting, plus `waitingFor` when waiting) — the very
|
|
6
|
+
// "machine-readable ready/busy signal" the old pane-scrape comment claimed didn't
|
|
7
|
+
// exist. The runner manages the claude pid and its CLAUDE_CONFIG_DIR, so this is a
|
|
8
|
+
// direct keyed read — no enumeration, no cwd/solo-multi disambiguation.
|
|
9
|
+
//
|
|
10
|
+
// This is the single home (DRY) for reading + version-gating + mapping that probe.
|
|
11
|
+
// `probeActivity`, the BusyReconciler backstop, and the monitorless turn-watch all
|
|
12
|
+
// delegate here; the legacy pane-scrape stays only as the graceful fallback for
|
|
13
|
+
// pre-2.1.119 / probe-missing sessions (e.g. a claude-rig host whose real config
|
|
14
|
+
// home the runner can't resolve). Schema is reverse-engineered (pbauermeister/
|
|
15
|
+
// claude-busy-monitor), so we gate on `version`/presence and degrade gracefully if
|
|
16
|
+
// Anthropic ever drifts it.
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { isPidAlive } from "agent-relay-sdk/process-utils";
|
|
21
|
+
import { tmuxCommand } from "agent-relay-sdk/tmux-utils";
|
|
22
|
+
|
|
23
|
+
/** Raw probe status; "unknown" = absent / unparseable / version-gated out. */
|
|
24
|
+
export type ClaudeProbeStatus = "busy" | "shell" | "idle" | "waiting" | "unknown";
|
|
25
|
+
|
|
26
|
+
/** The 3-valued activity the reconciler + monitorless turn-watch consume. */
|
|
27
|
+
export type ClaudeProbeActivity = "busy" | "idle" | "unknown";
|
|
28
|
+
|
|
29
|
+
/** First Claude Code version that writes the session probe. Below this → fallback. */
|
|
30
|
+
export const CLAUDE_PROBE_MIN_VERSION = "2.1.119";
|
|
31
|
+
|
|
32
|
+
export interface ClaudeSessionReading {
|
|
33
|
+
/** Raw probe status, or "unknown" when the probe is absent/old/unparseable. */
|
|
34
|
+
status: ClaudeProbeStatus;
|
|
35
|
+
/** `waitingFor` reason when status==="waiting" (e.g. "approve Bash"), else null. */
|
|
36
|
+
waitingFor: string | null;
|
|
37
|
+
/** Probe `version`, or null when unavailable. */
|
|
38
|
+
version: string | null;
|
|
39
|
+
/** `procStart` (process start-time token, == `/proc/<pid>/stat` field 22), or null. */
|
|
40
|
+
procStart: string | null;
|
|
41
|
+
/** `updatedAt` epoch-ms of the last probe write, or null. */
|
|
42
|
+
updatedAt: number | null;
|
|
43
|
+
/** True iff a present, parseable, version-gated probe produced `status`. */
|
|
44
|
+
available: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const UNAVAILABLE: ClaudeSessionReading = { status: "unknown", waitingFor: null, version: null, procStart: null, updatedAt: null, available: false };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Process-liveness seam for the staleness guard (#769 P1-B), injectable in tests.
|
|
51
|
+
* procStartOf — the start-time token of a LIVE pid (to compare against the probe's
|
|
52
|
+
* `procStart`), or null when the pid is dead/unreadable. Catches both a frozen
|
|
53
|
+
* probe from a SIGKILLed claude AND a sub-second pid-reuse window (a reused pid
|
|
54
|
+
* has a different start time).
|
|
55
|
+
* pidAlive — liveness fallback for probes that predate `procStart`.
|
|
56
|
+
*/
|
|
57
|
+
export interface ClaudeProbeLiveness {
|
|
58
|
+
procStartOf(pid: number): string | null;
|
|
59
|
+
pidAlive(pid: number): boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Default (Linux /proc) liveness. `procStart` in the probe is `/proc/<pid>/stat`
|
|
63
|
+
// field 22 (starttime, clock-ticks since boot) — verified equal live on CC 2.1.193.
|
|
64
|
+
// NB: we deliberately do NOT gate on `updatedAt` freshness — Claude only rewrites
|
|
65
|
+
// the probe on a status *transition*, so a legitimately busy turn keeps a frozen
|
|
66
|
+
// `updatedAt` for its whole duration (verified: a 90s+ busy turn never rewrote it).
|
|
67
|
+
// A staleness threshold would force a long busy turn back to the pane-scrape and
|
|
68
|
+
// reintroduce the #766 idle-while-working bug; pid-liveness + procStart cover the
|
|
69
|
+
// SIGKILL-frozen and pid-reuse cases without that hazard.
|
|
70
|
+
const DEFAULT_LIVENESS: ClaudeProbeLiveness = {
|
|
71
|
+
procStartOf: readProcStartTime,
|
|
72
|
+
pidAlive: isPidAlive,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function readProcStartTime(pid: number): string | null {
|
|
76
|
+
try {
|
|
77
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
78
|
+
// comm (field 2) is parenthesized and may contain spaces — slice past the last
|
|
79
|
+
// ')' so the remaining whitespace split starts cleanly at field 3 (state).
|
|
80
|
+
const after = stat.slice(stat.lastIndexOf(")") + 1).trim();
|
|
81
|
+
const fields = after.split(/\s+/);
|
|
82
|
+
return fields[19] ?? null; // field 22 (starttime) = index 19 after dropping pid+comm
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The per-session probe path the runner reads directly (keyed by claude's pid). */
|
|
89
|
+
export function claudeSessionProbePath(configHome: string, pid: number): string {
|
|
90
|
+
return join(configHome, "sessions", `${pid}.json`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Dotted-version compare; true iff `version` >= `min`. Missing/non-numeric → gate closed. */
|
|
94
|
+
export function claudeVersionAtLeast(version: string | null | undefined, min: string): boolean {
|
|
95
|
+
if (!version) return false;
|
|
96
|
+
const actual = version.split(".");
|
|
97
|
+
const wanted = min.split(".");
|
|
98
|
+
for (let i = 0; i < wanted.length; i++) {
|
|
99
|
+
const x = Number.parseInt(actual[i] ?? "0", 10);
|
|
100
|
+
const y = Number.parseInt(wanted[i] ?? "0", 10);
|
|
101
|
+
if (Number.isNaN(x)) return false; // unparseable build → fall back, don't trust the probe
|
|
102
|
+
if (x !== y) return x > y;
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeStatus(value: unknown): ClaudeProbeStatus {
|
|
108
|
+
switch (value) {
|
|
109
|
+
case "busy":
|
|
110
|
+
case "shell":
|
|
111
|
+
case "idle":
|
|
112
|
+
case "waiting":
|
|
113
|
+
return value;
|
|
114
|
+
default:
|
|
115
|
+
return "unknown";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Pure: parse a probe JSON body + version-gate it. Malformed / old / unknown status → unavailable. */
|
|
120
|
+
export function parseClaudeSessionProbe(raw: string, minVersion = CLAUDE_PROBE_MIN_VERSION): ClaudeSessionReading {
|
|
121
|
+
let parsed: unknown;
|
|
122
|
+
try {
|
|
123
|
+
parsed = JSON.parse(raw);
|
|
124
|
+
} catch {
|
|
125
|
+
return UNAVAILABLE;
|
|
126
|
+
}
|
|
127
|
+
if (!parsed || typeof parsed !== "object") return UNAVAILABLE;
|
|
128
|
+
const probe = parsed as Record<string, unknown>;
|
|
129
|
+
const version = typeof probe.version === "string" ? probe.version : null;
|
|
130
|
+
const procStart = typeof probe.procStart === "string" ? probe.procStart : null;
|
|
131
|
+
const updatedAt = typeof probe.updatedAt === "number" ? probe.updatedAt : null;
|
|
132
|
+
if (!claudeVersionAtLeast(version, minVersion)) return { ...UNAVAILABLE, version, procStart, updatedAt };
|
|
133
|
+
const status = normalizeStatus(probe.status);
|
|
134
|
+
if (status === "unknown") return { status: "unknown", waitingFor: null, version, procStart, updatedAt, available: false };
|
|
135
|
+
const waitingFor = typeof probe.waitingFor === "string" && probe.waitingFor.trim() ? probe.waitingFor : null;
|
|
136
|
+
return { status, waitingFor, version, procStart, updatedAt, available: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Read + parse the probe for `pid` under `configHome`. Missing file (older CC, a
|
|
141
|
+
* config home the runner can't resolve, or claude not yet started) → unavailable,
|
|
142
|
+
* so the caller falls back to the pane-scrape. Sync: the file is a few hundred
|
|
143
|
+
* bytes and both call sites (4s reconcile poll, 1.5s monitorless tick) are timers.
|
|
144
|
+
*/
|
|
145
|
+
export function readClaudeSessionStatus(
|
|
146
|
+
configHome: string,
|
|
147
|
+
pid: number,
|
|
148
|
+
opts: { minVersion?: string; liveness?: ClaudeProbeLiveness } = {},
|
|
149
|
+
): ClaudeSessionReading {
|
|
150
|
+
let raw: string;
|
|
151
|
+
try {
|
|
152
|
+
raw = readFileSync(claudeSessionProbePath(configHome, pid), "utf8");
|
|
153
|
+
} catch {
|
|
154
|
+
return UNAVAILABLE;
|
|
155
|
+
}
|
|
156
|
+
const reading = parseClaudeSessionProbe(raw, opts.minVersion ?? CLAUDE_PROBE_MIN_VERSION);
|
|
157
|
+
if (!reading.available) return reading;
|
|
158
|
+
// #769 P1-B: a frozen probe from a SIGKILLed claude (or a reused pid) would report
|
|
159
|
+
// a stale busy/shell forever. Verify the pid still belongs to the same process.
|
|
160
|
+
const live = opts.liveness ?? DEFAULT_LIVENESS;
|
|
161
|
+
if (reading.procStart) {
|
|
162
|
+
if (live.procStartOf(pid) !== reading.procStart) return { ...reading, status: "unknown", available: false };
|
|
163
|
+
} else if (!live.pidAlive(pid)) {
|
|
164
|
+
return { ...reading, status: "unknown", available: false };
|
|
165
|
+
}
|
|
166
|
+
return reading;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Resolve the claude pid for the session probe (#766/#768): a directly-spawned
|
|
171
|
+
* process exposes it; a tmux-managed (headless) claude is the pane's process —
|
|
172
|
+
* buildTmuxArgs runs `bash launcher.sh` whose launcher `exec`s claude, so the pane
|
|
173
|
+
* pid IS the claude pid (verified live: pane_pid → sessions/<pid>.json). undefined
|
|
174
|
+
* when neither is available, which routes the caller to the pane-scrape fallback.
|
|
175
|
+
*/
|
|
176
|
+
export function resolveClaudePid(directPid: number | undefined, tmuxSession?: string, tmuxSocket?: string): number | undefined {
|
|
177
|
+
if (typeof directPid === "number" && directPid > 0) return directPid;
|
|
178
|
+
if (!tmuxSession) return undefined;
|
|
179
|
+
const result = Bun.spawnSync(tmuxCommand(tmuxSocket, "display-message", "-p", "-t", tmuxSession, "-F", "#{pane_pid}"), {
|
|
180
|
+
stdin: "ignore", stdout: "pipe", stderr: "ignore",
|
|
181
|
+
});
|
|
182
|
+
if (result.exitCode !== 0) return undefined;
|
|
183
|
+
const pid = Number.parseInt(result.stdout.toString().trim(), 10);
|
|
184
|
+
return Number.isFinite(pid) && pid > 0 ? pid : undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Resolve the managed claude's config home + pid, then read the probe. The runner
|
|
189
|
+
* knows the config home (CLAUDE_CONFIG_DIR in the spawn env, or the host default)
|
|
190
|
+
* and the pid (direct or via the tmux pane). Returns the reading plus the resolved
|
|
191
|
+
* pid (for #650 liveness wiring), or undefined when the pid can't be resolved.
|
|
192
|
+
*/
|
|
193
|
+
export function readManagedClaudeStatus(args: {
|
|
194
|
+
pid?: number;
|
|
195
|
+
tmuxSession?: string;
|
|
196
|
+
tmuxSocket?: string;
|
|
197
|
+
configHomeEnv?: string;
|
|
198
|
+
}): { reading: ClaudeSessionReading; pid: number } | undefined {
|
|
199
|
+
const pid = resolveClaudePid(args.pid, args.tmuxSession, args.tmuxSocket);
|
|
200
|
+
if (pid === undefined) return undefined;
|
|
201
|
+
const configHome = args.configHomeEnv || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
202
|
+
return { reading: readClaudeSessionStatus(configHome, pid), pid };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Map the intrinsic probe status to the 3-valued activity. The whole point of #766:
|
|
207
|
+
* busy/shell → busy — shell is shelled-out/local-bash (a tool/gate is running);
|
|
208
|
+
* the old pane-scrape misread it as idle and force-cleared.
|
|
209
|
+
* idle → idle
|
|
210
|
+
* waiting → unknown — a permission/input dialog is a parked (not done) turn, so
|
|
211
|
+
* we neither assert compute-busy nor let the backstop clear
|
|
212
|
+
* the live turn. `waitingFor` is surfaced separately for
|
|
213
|
+
* permission/asking detection.
|
|
214
|
+
*/
|
|
215
|
+
export function claudeProbeActivity(status: ClaudeProbeStatus): ClaudeProbeActivity {
|
|
216
|
+
switch (status) {
|
|
217
|
+
case "busy":
|
|
218
|
+
case "shell":
|
|
219
|
+
return "busy";
|
|
220
|
+
case "idle":
|
|
221
|
+
return "idle";
|
|
222
|
+
default:
|
|
223
|
+
return "unknown";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { extractClaudeModelUnavailableMessage } from "agent-relay-sdk";
|
|
2
|
+
import type { ProviderStatusUpdate } from "../adapter";
|
|
3
|
+
import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "../rate-limit";
|
|
4
|
+
|
|
5
|
+
// LEGACY PANE-SCRAPE FALLBACK (#766/#768) — busy/idle is now driven primarily by
|
|
6
|
+
// Claude Code's intrinsic session probe (readClaudeSessionStatus, ./claude-session-probe):
|
|
7
|
+
// `$CLAUDE_CONFIG_DIR/sessions/<pid>.json` carries a machine-readable `status` field
|
|
8
|
+
// on CC ≥2.1.119. These two pane heuristics are the SOLE fallback for pre-2.1.119 /
|
|
9
|
+
// probe-missing sessions, and claudePaneLooksReady still backs the input-readiness gate
|
|
10
|
+
// (waitForClaudeInputReady) + the prompt-gate busy guard — so they stay. They string-match
|
|
11
|
+
// CC's TUI chrome against captured tmux scrollback (~80 lines), so they break whenever CC
|
|
12
|
+
// restyles its footer/banner. Deliberately substring/regex based (no machine-readable
|
|
13
|
+
// signal existed when these were written). Known break conditions, so the next CC restyle
|
|
14
|
+
// is a fast fix rather than a hunt:
|
|
15
|
+
// readiness (claudePaneLooksReady) breaks if CC renames/removes ALL of: the
|
|
16
|
+
// "bypass permissions" / "shift+tab to cycle" / "? for shortcuts" footer hints,
|
|
17
|
+
// the "/effort" hint, or the "Welcome back" / "Claude Code" banner.
|
|
18
|
+
// busy (claudePaneIsBusy) breaks if CC drops the live "… (<elapsed>" spinner counter
|
|
19
|
+
// (the cross-version anchor; the "esc to interrupt" hint was already dropped in 2.1.x).
|
|
20
|
+
// FALSE POSITIVES: agent output that literally QUOTES any of these strings (e.g. a
|
|
21
|
+
// transcript discussing "esc to interrupt", or this very comment shown in a pane)
|
|
22
|
+
// reads as ready/busy. Tolerated because the markers are CC-specific enough to be
|
|
23
|
+
// rare in real output; if it bites, gate on the LAST N lines only (the live footer).
|
|
24
|
+
// History: 18067b5 (busy counter), and the readiness footer-vs-banner fix below.
|
|
25
|
+
export function claudePaneLooksReady(text: string): boolean {
|
|
26
|
+
// Claude's startup banner ("Claude Code" / "Welcome back") scrolls off the pane once the
|
|
27
|
+
// conversation fills it, so a mid-session delivery (e.g. the budget warning, minutes into
|
|
28
|
+
// a run) must detect readiness from the PERSISTENT input-box footer that Claude always
|
|
29
|
+
// renders at the prompt. Requiring the banner made every delivery after the first screen
|
|
30
|
+
// of output time out as "input not ready". Any one of these strong, Claude-specific
|
|
31
|
+
// markers means the TUI is up and the input box exists.
|
|
32
|
+
return text.includes("bypass permissions") // footer in --dangerously-skip-permissions mode
|
|
33
|
+
|| text.includes("shift+tab to cycle") // mode-cycle footer hint (persistent)
|
|
34
|
+
|| text.includes("? for shortcuts") // default footer hint (persistent)
|
|
35
|
+
|| text.includes("/effort")
|
|
36
|
+
|| text.includes("Welcome back")
|
|
37
|
+
|| text.includes("Claude Code");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The working-spinner footer carries a live elapsed-time counter while a turn is in
|
|
41
|
+
// flight, e.g. "✶ Perambulating… (2m 17s · ↓ 8.7k tokens)" — gerund, "… (", then
|
|
42
|
+
// "[Nh ][Nm ]Ns". Anchored on the gerund ellipsis so it can't match the "… +N lines
|
|
43
|
+
// (ctrl+o to expand)" truncation marker, the idle input box, or the persistent
|
|
44
|
+
// "/btw … without interrupting Claude's current work" queue hint.
|
|
45
|
+
const CLAUDE_BUSY_SPINNER_RE = /…\s*\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b/;
|
|
46
|
+
|
|
47
|
+
export function claudePaneIsBusy(text: string): boolean {
|
|
48
|
+
// Claude Code <2.1.x rendered "(esc to interrupt)" in the spinner footer; 2.1.x
|
|
49
|
+
// dropped that hint but kept the "(<elapsed>" counter, which is the stable busy
|
|
50
|
+
// signal across versions. Match either so the busy probe (and the reconciler
|
|
51
|
+
// backstop that depends on it) keep working as the footer wording changes.
|
|
52
|
+
return CLAUDE_BUSY_SPINNER_RE.test(text) || text.includes("esc to interrupt");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// #286: detect the subscription usage-limit modal in the pane and turn it into the
|
|
56
|
+
// provider-neutral rate-limit hold (idle + blocked) that the relay's resume sweep lifts
|
|
57
|
+
// at reset. Mirrors claudeModelUnavailableStatus, but non-terminal (idle, not error).
|
|
58
|
+
export function claudeRateLimitStatus(text: string, sessionName?: string): ProviderStatusUpdate | null {
|
|
59
|
+
const parsed = parseClaudeRateLimitPane(text);
|
|
60
|
+
if (!parsed) return null;
|
|
61
|
+
return {
|
|
62
|
+
status: "idle",
|
|
63
|
+
clear: ["subagent"],
|
|
64
|
+
providerState: buildRateLimitProviderState({
|
|
65
|
+
errorType: parsed.errorType ?? "session_limit", kind: parsed.kind ?? "usage_limit",
|
|
66
|
+
...(parsed.resetAt ? { resetAt: parsed.resetAt } : {}),
|
|
67
|
+
message: parsed.message,
|
|
68
|
+
source: sessionName ? `claude-pane:${sessionName}` : "claude-pane",
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// #769 — detect the API connection-retry banner in the live terminal footer and surface a
|
|
74
|
+
// transient blocked hold (non-terminal, clears on recovery). Three stable anchors from the
|
|
75
|
+
// banner text: "Unable to connect to API", "Retrying in <N>s", "attempt <N>/<M>".
|
|
76
|
+
// Gated on the LAST N lines so agent output that merely QUOTES these strings (e.g. quoting
|
|
77
|
+
// this comment, or a discussion of retry logic) doesn't false-positive — the live banner
|
|
78
|
+
// always lives in the footer; quoted strings appear in the scrollback body.
|
|
79
|
+
// Break condition: if CC renames/removes any anchor substring the detector stops firing and
|
|
80
|
+
// sessions return to idle/available — a fast fix once the new footer wording is known.
|
|
81
|
+
const CLAUDE_CONN_RETRY_FOOTER_LINES = 10;
|
|
82
|
+
const CLAUDE_CONN_RETRY_ANCHOR_RE = /Unable to connect to API/;
|
|
83
|
+
const CLAUDE_CONN_RETRY_RATE_RE = /Retrying in \d+s/;
|
|
84
|
+
const CLAUDE_CONN_RETRY_ATTEMPT_RE = /attempt \d+\/\d+/;
|
|
85
|
+
|
|
86
|
+
export function claudeConnectionRetryStatus(text: string, sessionName?: string): ProviderStatusUpdate | null {
|
|
87
|
+
const footer = text.split("\n").slice(-CLAUDE_CONN_RETRY_FOOTER_LINES).join("\n");
|
|
88
|
+
if (
|
|
89
|
+
!CLAUDE_CONN_RETRY_ANCHOR_RE.test(footer) ||
|
|
90
|
+
!CLAUDE_CONN_RETRY_RATE_RE.test(footer) ||
|
|
91
|
+
!CLAUDE_CONN_RETRY_ATTEMPT_RE.test(footer)
|
|
92
|
+
) return null;
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
return {
|
|
95
|
+
status: "idle",
|
|
96
|
+
clear: ["subagent"],
|
|
97
|
+
providerState: {
|
|
98
|
+
state: "blocked",
|
|
99
|
+
reason: "conn_retry",
|
|
100
|
+
kind: "conn_retry",
|
|
101
|
+
label: "connection degraded · retrying",
|
|
102
|
+
message: "Claude cannot reach the Anthropic API and is retrying the connection.",
|
|
103
|
+
source: sessionName ? `claude-pane:${sessionName}` : "claude-pane",
|
|
104
|
+
terminal: false,
|
|
105
|
+
recommendedAction: "Holding; agent-relay will clear this state once the session reconnects.",
|
|
106
|
+
enteredAt: now,
|
|
107
|
+
updatedAt: now,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function claudeModelUnavailableStatus(text: string, sessionName?: string): ProviderStatusUpdate | null {
|
|
113
|
+
const message = extractClaudeModelUnavailableMessage(text);
|
|
114
|
+
if (!message) return null;
|
|
115
|
+
return {
|
|
116
|
+
status: "error",
|
|
117
|
+
clear: ["provider-turn", "subagent"],
|
|
118
|
+
providerState: {
|
|
119
|
+
state: "failed",
|
|
120
|
+
reason: "model-unavailable",
|
|
121
|
+
message,
|
|
122
|
+
source: "claude-pane",
|
|
123
|
+
terminal: true,
|
|
124
|
+
...(sessionName ? { sessionName } : {}),
|
|
125
|
+
recommendedAction: "Choose a different Claude model before restarting this agent.",
|
|
126
|
+
},
|
|
127
|
+
metadata: {
|
|
128
|
+
terminalFailureReason: "model-unavailable",
|
|
129
|
+
terminalFailureMessage: message,
|
|
130
|
+
},
|
|
131
|
+
timeline: {
|
|
132
|
+
status: "provider.restart_decision",
|
|
133
|
+
id: `provider-model-unavailable-${Date.now()}`,
|
|
134
|
+
timestamp: Date.now(),
|
|
135
|
+
title: "Provider restart skipped",
|
|
136
|
+
body: message,
|
|
137
|
+
icon: "ti-player-stop",
|
|
138
|
+
metadata: {
|
|
139
|
+
eventType: "provider.restart_decision",
|
|
140
|
+
decision: "stop-surface",
|
|
141
|
+
reason: "model-unavailable",
|
|
142
|
+
modelUnavailable: true,
|
|
143
|
+
modelUnavailableMessage: message,
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|