agent-relay-server 0.8.1 → 0.10.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.
Files changed (44) hide show
  1. package/README.md +12 -14
  2. package/package.json +18 -1
  3. package/public/index.html +979 -2575
  4. package/public/manifest.webmanifest +6 -6
  5. package/public/sw.js +16 -10
  6. package/recipes/code-review.yaml +26 -0
  7. package/recipes/debug.yaml +20 -0
  8. package/recipes/feature.yaml +26 -0
  9. package/recipes/refactor.yaml +20 -0
  10. package/recipes/test.yaml +20 -0
  11. package/runner/src/adapter.ts +69 -0
  12. package/runner/src/config.ts +144 -0
  13. package/scripts/orchestrator-spawn-smoke.ts +2 -9
  14. package/src/agent-spawn.ts +2 -94
  15. package/src/automations.ts +774 -0
  16. package/src/bus-outbox.ts +75 -0
  17. package/src/bus.ts +439 -0
  18. package/src/cli.ts +251 -5
  19. package/src/commands-db.ts +160 -0
  20. package/src/config.ts +2 -1
  21. package/src/connectors.ts +29 -9
  22. package/src/daemon.ts +1 -0
  23. package/src/db.ts +363 -36
  24. package/src/events.ts +33 -0
  25. package/src/index.ts +100 -5
  26. package/src/recipe-db.ts +163 -0
  27. package/src/recipe-loader.ts +100 -0
  28. package/src/recipe-runner.ts +206 -0
  29. package/src/recipe-validator.ts +85 -0
  30. package/src/routes.ts +661 -158
  31. package/src/security.ts +128 -2
  32. package/src/sse.ts +45 -28
  33. package/src/token-db.ts +96 -0
  34. package/src/types.ts +1 -488
  35. package/src/upgrade.ts +14 -28
  36. package/public/dashboard/actions.js +0 -819
  37. package/public/dashboard/api.js +0 -336
  38. package/public/dashboard/app.js +0 -34
  39. package/public/dashboard/charts.js +0 -128
  40. package/public/dashboard/computed.js +0 -693
  41. package/public/dashboard/constants.js +0 -28
  42. package/public/dashboard/display.js +0 -345
  43. package/public/dashboard/state.js +0 -129
  44. package/public/dashboard/utils.js +0 -207
package/src/types.ts CHANGED
@@ -1,488 +1 @@
1
- export type AgentKind = "provider" | "channel" | "orchestrator" | "system" | "user";
2
-
3
- export interface AgentCard {
4
- id: string;
5
- name: string;
6
- kind: AgentKind;
7
- label?: string; // human-friendly alias; acts as a fan-out target ("label:foo")
8
- tags: string[];
9
- machine?: string;
10
- rig?: string;
11
- capabilities: string[];
12
- ready: boolean;
13
- status: "online" | "idle" | "busy" | "offline";
14
- instanceId?: string;
15
- epoch: number;
16
- meta?: Record<string, unknown>;
17
- lastSeen: number;
18
- createdAt: number;
19
- }
20
-
21
- export type MessageKind =
22
- | "chat"
23
- | "channel.event"
24
- | "task"
25
- | "pair"
26
- | "control"
27
- | "system";
28
-
29
- export interface Message {
30
- id: number;
31
- from: string;
32
- to: string; // agent-id | "tag:<name>" | "broadcast" | "cap:<name>"
33
- kind: MessageKind;
34
- channel?: string;
35
- subject?: string;
36
- body: string;
37
- threadId?: number;
38
- replyTo?: number;
39
- claimable?: boolean;
40
- claimedBy?: string;
41
- claimedAt?: number;
42
- claimExpiresAt?: number;
43
- idempotencyKey?: string;
44
- payload: Record<string, unknown>;
45
- meta?: Record<string, unknown>;
46
- readBy: string[];
47
- createdAt: number;
48
- }
49
-
50
- export interface SendMessageInput {
51
- from: string;
52
- to: string;
53
- kind?: MessageKind;
54
- channel?: string;
55
- subject?: string;
56
- body: string;
57
- replyTo?: number;
58
- claimable?: boolean;
59
- idempotencyKey?: string;
60
- payload?: Record<string, unknown>;
61
- meta?: Record<string, unknown>;
62
- }
63
-
64
- export type ConnectorKind = "channel" | "event" | "provider" | "orchestrator";
65
- export type ConnectorAction = "install" | "uninstall" | "enable" | "disable" | "start" | "stop" | "restart" | "status" | "doctor";
66
-
67
- export interface ConnectorManifest {
68
- schema: "agent-relay.connector.v1";
69
- id: string;
70
- kind: ConnectorKind;
71
- packageName?: string;
72
- binary: string;
73
- displayName: string;
74
- description?: string;
75
- version: string;
76
- capabilities: string[];
77
- commands: Partial<Record<ConnectorAction, string[]>>;
78
- configSchema?: Record<string, unknown>;
79
- }
80
-
81
- interface ConnectorRuntime {
82
- installed: boolean;
83
- enabled?: boolean;
84
- running?: boolean;
85
- status?: "ok" | "warn" | "error" | "unknown";
86
- detail?: string;
87
- updatedAt?: string;
88
- raw?: unknown;
89
- }
90
-
91
- export interface ConnectorSummary {
92
- id: string;
93
- kind: ConnectorKind;
94
- displayName: string;
95
- description?: string;
96
- version: string;
97
- packageName?: string;
98
- binary: string;
99
- capabilities: string[];
100
- registryPath: string;
101
- manifest: ConnectorManifest;
102
- config?: Record<string, unknown>;
103
- state?: Record<string, unknown>;
104
- runtime: ConnectorRuntime;
105
- }
106
-
107
- export interface ConnectorActionResult {
108
- connectorId: string;
109
- action: ConnectorAction;
110
- command?: string[];
111
- ok: boolean;
112
- exitCode?: number | null;
113
- stdout?: string;
114
- stderr?: string;
115
- parsed?: unknown;
116
- }
117
-
118
- export type PairStatus = "pending" | "active" | "ended" | "rejected" | "expired";
119
-
120
- export interface PairSession {
121
- id: string;
122
- requesterId: string;
123
- targetId: string;
124
- status: PairStatus;
125
- objective?: string;
126
- createdAt: number;
127
- updatedAt: number;
128
- expiresAt: number;
129
- acceptedAt?: number;
130
- endedAt?: number;
131
- endedBy?: string;
132
- lastMessageAt?: number;
133
- meta: Record<string, unknown>;
134
- }
135
-
136
- export interface CreatePairInput {
137
- from: string;
138
- target: string;
139
- objective?: string;
140
- ttlMs?: number;
141
- meta?: Record<string, unknown>;
142
- }
143
-
144
- export interface PairActionInput {
145
- agentId: string;
146
- reason?: string;
147
- }
148
-
149
- export interface PairMessageInput {
150
- from: string;
151
- body: string;
152
- subject?: string;
153
- }
154
-
155
- export interface PollQuery {
156
- for: string; // agent-id
157
- since?: number; // unix ms (createdAt cursor)
158
- sinceId?: number; // monotonic message id cursor (preferred — avoids same-ms collisions)
159
- unread?: boolean;
160
- channel?: string;
161
- limit?: number;
162
- }
163
-
164
- export interface RegisterAgentInput {
165
- id: string;
166
- name: string;
167
- kind?: AgentKind;
168
- label?: string | null;
169
- tags?: string[];
170
- machine?: string;
171
- rig?: string;
172
- capabilities?: string[];
173
- ready?: boolean;
174
- status?: AgentCard["status"];
175
- instanceId?: string;
176
- meta?: Record<string, unknown>;
177
- }
178
-
179
- export interface AgentSessionGuard {
180
- instanceId?: string;
181
- epoch?: number;
182
- }
183
-
184
- export type TaskSeverity = "info" | "warning" | "critical";
185
- export type TaskStatus =
186
- | "open"
187
- | "claimed"
188
- | "in_progress"
189
- | "blocked"
190
- | "done"
191
- | "failed"
192
- | "canceled";
193
-
194
- export interface Task {
195
- id: number;
196
- source: string;
197
- title: string;
198
- body: string;
199
- severity: TaskSeverity;
200
- status: TaskStatus;
201
- target: string;
202
- channel?: string;
203
- dedupeKey?: string;
204
- externalUrl?: string;
205
- occurrenceCount: number;
206
- claimedBy?: string;
207
- claimedAt?: number;
208
- claimExpiresAt?: number;
209
- messageId?: number;
210
- result?: string;
211
- metadata: Record<string, unknown>;
212
- createdAt: number;
213
- updatedAt: number;
214
- lastSeenAt: number;
215
- }
216
-
217
- export interface TaskEvent {
218
- id: number;
219
- taskId: number;
220
- source: string;
221
- type: string;
222
- severity: TaskSeverity;
223
- title: string;
224
- body: string;
225
- metadata: Record<string, unknown>;
226
- createdAt: number;
227
- }
228
-
229
- export interface IntegrationEventInput {
230
- source?: string;
231
- type?: string;
232
- severity?: TaskSeverity;
233
- status?: TaskStatus | "resolved";
234
- title: string;
235
- body: string;
236
- target: string;
237
- channel?: string;
238
- dedupeKey?: string;
239
- externalUrl?: string;
240
- metadata?: Record<string, unknown>;
241
- }
242
-
243
- export interface IntegrationTaskStats {
244
- source: string;
245
- tasks: number;
246
- openTasks: number;
247
- waitingTasks: number;
248
- failedTasks: number;
249
- lastSeenAt?: number;
250
- lastUpdatedAt?: number;
251
- }
252
-
253
- export interface IntegrationSummary {
254
- name: string;
255
- configured: boolean;
256
- observed: boolean;
257
- scopes: string[];
258
- targets: string[];
259
- channels: string[];
260
- callbackHost?: string;
261
- callbackConfigured: boolean;
262
- rateLimit: {
263
- limitPerMinute: number;
264
- currentWindowCount: number;
265
- windowStartedAt?: number;
266
- };
267
- taskStats: IntegrationTaskStats;
268
- }
269
-
270
- export type ChannelDirection = "inbound" | "outbound" | "bidirectional";
271
-
272
- export type ChannelRouteTarget =
273
- | { type: "agent"; id: string }
274
- | { type: "label"; id: string }
275
- | { type: "tag"; id: string }
276
- | { type: "capability"; id: string }
277
- | { type: "broadcast" }
278
- | { type: "orchestrator"; id: string };
279
-
280
- export type ChannelBindingMode = "exclusive" | "claimable" | "broadcast";
281
-
282
- export interface ChannelBinding {
283
- id: string;
284
- channelId: string;
285
- conversationId?: string;
286
- target: ChannelRouteTarget;
287
- mode: ChannelBindingMode;
288
- priority: number;
289
- createdAt: number;
290
- updatedAt: number;
291
- }
292
-
293
- export interface ChannelTargetHealth {
294
- status: "ok" | "warning" | "error";
295
- detail: string;
296
- target: ChannelRouteTarget;
297
- matches: Array<{
298
- id: string;
299
- name: string;
300
- status: AgentCard["status"];
301
- ready: boolean;
302
- lastSeen: number;
303
- label?: string;
304
- tags: string[];
305
- capabilities: string[];
306
- }>;
307
- }
308
-
309
- export interface ChannelSummary {
310
- id: string;
311
- name: string;
312
- type: string;
313
- transport: string;
314
- agentId: string;
315
- accountId: string;
316
- status: AgentCard["status"];
317
- ready: boolean;
318
- direction: ChannelDirection;
319
- target?: string;
320
- binding?: ChannelBinding;
321
- targetHealth?: ChannelTargetHealth;
322
- topicChannels: string[];
323
- capabilities: string[];
324
- tags: string[];
325
- lastSeen: number;
326
- meta?: Record<string, unknown>;
327
- }
328
-
329
- export interface TaskStatusInput {
330
- status: TaskStatus;
331
- agentId?: string;
332
- instanceId?: string;
333
- epoch?: number;
334
- result?: string;
335
- body?: string;
336
- metadata?: Record<string, unknown>;
337
- }
338
-
339
- export interface InboxThreadState {
340
- operatorId: string;
341
- peerId: string;
342
- readCursorMessageId?: number;
343
- archivedAtMessageId?: number;
344
- updatedAt: number;
345
- }
346
-
347
- export interface InboxDraft {
348
- operatorId: string;
349
- peerId: string;
350
- body: string;
351
- subject?: string;
352
- channel?: string;
353
- updatedAt: number;
354
- }
355
-
356
- export interface InboxState {
357
- operatorId: string;
358
- threads: InboxThreadState[];
359
- drafts: InboxDraft[];
360
- }
361
-
362
- export type ActivityKind = "message" | "reply" | "question" | "operator" | "pair" | "task" | "state";
363
-
364
- export interface ActivityEvent {
365
- id: number;
366
- operatorId?: string;
367
- clientId?: string;
368
- kind: ActivityKind;
369
- title: string;
370
- body?: string;
371
- meta?: string;
372
- icon?: string;
373
- view?: string;
374
- peer?: string;
375
- messageId?: number;
376
- pairId?: string;
377
- taskId?: number;
378
- agentId?: string;
379
- metadata: Record<string, unknown>;
380
- createdAt: number;
381
- }
382
-
383
- export interface ActivityEventInput {
384
- operatorId?: string;
385
- clientId?: string;
386
- kind: ActivityKind;
387
- title: string;
388
- body?: string;
389
- meta?: string;
390
- icon?: string;
391
- view?: string;
392
- peer?: string;
393
- messageId?: number;
394
- pairId?: string;
395
- taskId?: number;
396
- agentId?: string;
397
- metadata?: Record<string, unknown>;
398
- }
399
-
400
- // --- Orchestrators ---
401
-
402
- export type OrchestratorStatus = "online" | "offline";
403
- export type SpawnProvider = "claude" | "codex";
404
- export type SpawnApprovalMode = "open" | "guarded" | "read-only";
405
-
406
- export interface Orchestrator {
407
- id: string;
408
- hostname: string;
409
- status: OrchestratorStatus;
410
- agentId: string; // relay agent id for messaging
411
- providers: SpawnProvider[];
412
- baseDir: string;
413
- envKeys: string[]; // names only, never values
414
- version?: string;
415
- protocolVersion?: number;
416
- gitSha?: string;
417
- health?: OrchestratorHealth;
418
- meta: Record<string, unknown>;
419
- managedAgents: ManagedAgent[];
420
- lastSeen: number;
421
- createdAt: number;
422
- }
423
-
424
- export interface OrchestratorHealth {
425
- status: "ok" | "warn" | "error";
426
- restartRequired: boolean;
427
- issues: Array<{
428
- code: "missing-version" | "outdated" | "protocol-mismatch" | "restart-required";
429
- detail: string;
430
- }>;
431
- }
432
-
433
- export interface ManagedAgent {
434
- agentId: string;
435
- provider: SpawnProvider;
436
- tmuxSession: string;
437
- cwd: string;
438
- label?: string;
439
- approvalMode: SpawnApprovalMode;
440
- pid?: number;
441
- startedAt: number;
442
- }
443
-
444
- export interface RegisterOrchestratorInput {
445
- id: string;
446
- hostname: string;
447
- providers: SpawnProvider[];
448
- baseDir: string;
449
- envKeys?: string[];
450
- version?: string;
451
- protocolVersion?: number;
452
- gitSha?: string;
453
- meta?: Record<string, unknown>;
454
- }
455
-
456
- export interface OrchestratorSpawnInput {
457
- provider: SpawnProvider;
458
- cwd?: string;
459
- label?: string;
460
- approvalMode?: SpawnApprovalMode;
461
- prompt?: string;
462
- env?: Record<string, string>;
463
- }
464
-
465
- export interface OrchestratorSpawnResult {
466
- orchestratorId: string;
467
- provider: SpawnProvider;
468
- tmuxSession: string;
469
- cwd: string;
470
- label?: string;
471
- approvalMode: SpawnApprovalMode;
472
- pid?: number;
473
- startedAt: number;
474
- }
475
-
476
- export interface HealthCheck {
477
- name: string;
478
- status: "ok" | "warn" | "error";
479
- detail?: string;
480
- count?: number;
481
- }
482
-
483
- export interface HealthReport {
484
- status: "ok" | "degraded" | "error";
485
- version: string;
486
- generatedAt: number;
487
- checks: HealthCheck[];
488
- }
1
+ export * from "agent-relay-sdk/types";
package/src/upgrade.ts CHANGED
@@ -28,6 +28,7 @@ export type UpgradeSnapshot = {
28
28
  targetVersion: string;
29
29
  serverPackage?: InstalledPackage;
30
30
  codexPackage?: InstalledPackage;
31
+ runnerPackage?: InstalledPackage;
31
32
  orchestratorPackage?: InstalledPackage;
32
33
  codexCopiedPackage?: InstalledPackage;
33
34
  claudePluginInstalls: ClaudePluginInstall[];
@@ -71,9 +72,9 @@ export async function detectUpgradeSnapshot(options: UpgradeOptions = {}): Promi
71
72
  const codexCopiedPackage = readPackageVersion(join(homeDir(), ".agent-relay", "codex", "package", "package.json"));
72
73
  const claudePluginInstalls = readClaudePluginInstalls(join(homeDir(), ".claude", "plugins", "installed_plugins.json"));
73
74
 
74
- const packageManager = bunPackages.has("agent-relay-server") || bunPackages.has("agent-relay-codex") || bunPackages.has("agent-relay-orchestrator")
75
+ const packageManager = bunPackages.has("agent-relay-server") || bunPackages.has("agent-relay-runner") || bunPackages.has("agent-relay-codex") || bunPackages.has("agent-relay-orchestrator")
75
76
  ? "bun"
76
- : npmPackages.has("agent-relay-server") || npmPackages.has("agent-relay-codex") || npmPackages.has("agent-relay-orchestrator")
77
+ : npmPackages.has("agent-relay-server") || npmPackages.has("agent-relay-runner") || npmPackages.has("agent-relay-codex") || npmPackages.has("agent-relay-orchestrator")
77
78
  ? "npm"
78
79
  : commandExists("bun")
79
80
  ? "bun"
@@ -85,12 +86,13 @@ export async function detectUpgradeSnapshot(options: UpgradeOptions = {}): Promi
85
86
  targetVersion,
86
87
  serverPackage: installedPackage("agent-relay-server", bunPackages, npmPackages),
87
88
  codexPackage: installedPackage("agent-relay-codex", bunPackages, npmPackages),
89
+ runnerPackage: installedPackage("agent-relay-runner", bunPackages, npmPackages),
88
90
  orchestratorPackage: installedPackage("agent-relay-orchestrator", bunPackages, npmPackages),
89
91
  codexCopiedPackage: codexCopiedPackage
90
92
  ? { version: codexCopiedPackage, source: "copied", path: join(homeDir(), ".agent-relay", "codex", "package") }
91
93
  : undefined,
92
94
  claudePluginInstalls,
93
- hasCodexCommand: commandExists("agent-relay-codex") || commandExists("codex-relay") || existsSync(join(homeDir(), ".agent-relay", "codex", "package")),
95
+ hasCodexCommand: commandExists("codex-relay") || commandExists("agent-relay-runner"),
94
96
  hasOrchestratorCommand: commandExists("agent-relay-orchestrator"),
95
97
  hasClaudeCommand: commandExists("claude"),
96
98
  hasSystemdUserService: hasSystemdUserService("agent-relay.service"),
@@ -108,6 +110,7 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
108
110
  const codexRequested = providerSet.has("all") || providerSet.has("codex") || (providerSet.has("auto") && isCodexDetected(snapshot));
109
111
  const claudeRequested = providerSet.has("all") || providerSet.has("claude") || (providerSet.has("auto") && isClaudeRelayDetected(snapshot));
110
112
  const orchestratorRequested = providerSet.has("all") || providerSet.has("orchestrator") || (providerSet.has("auto") && isOrchestratorDetected(snapshot));
113
+ const runnerRequested = codexRequested || claudeRequested;
111
114
  const actions: UpgradeAction[] = [];
112
115
  const warnings: string[] = [];
113
116
 
@@ -115,7 +118,7 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
115
118
  warnings.push("No supported global package manager found. Install Bun or npm, then rerun `agent-relay upgrade`.");
116
119
  } else {
117
120
  const packages = [`agent-relay-server@${targetVersion}`];
118
- if (codexRequested) packages.push(`agent-relay-codex@${targetVersion}`);
121
+ if (runnerRequested) packages.push(`agent-relay-runner@${targetVersion}`);
119
122
  if (orchestratorRequested) packages.push(`agent-relay-orchestrator@${targetVersion}`);
120
123
  const command = snapshot.packageManager === "bun"
121
124
  ? ["bun", "add", "-g", ...packages]
@@ -123,39 +126,21 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
123
126
  actions.push({
124
127
  label: "Upgrade global packages",
125
128
  command,
126
- reason: `Update server${codexRequested ? ", Codex integration" : ""}${orchestratorRequested ? ", and orchestrator" : ""} packages to ${targetVersion}.`,
129
+ reason: `Update server${runnerRequested ? ", provider runner" : ""}${orchestratorRequested ? ", and orchestrator" : ""} packages to ${targetVersion}.`,
127
130
  mutates: true,
128
131
  });
129
132
  }
130
133
 
131
- if (codexRequested) {
132
- actions.push({
133
- label: "Refresh Codex relay install",
134
- command: ["agent-relay-codex", "install", "--alias"],
135
- reason: "Refresh copied Codex package, launcher shims, hooks, and plugin marketplace files.",
136
- mutates: true,
137
- });
138
- } else if (providerSet.has("auto") && !isCodexDetected(snapshot)) {
134
+ if (!codexRequested && providerSet.has("auto") && !isCodexDetected(snapshot)) {
139
135
  warnings.push("Codex provider not detected; skipping Codex integration upgrade.");
140
136
  }
141
137
 
142
138
  if (claudeRequested) {
143
- if (snapshot.hasClaudeCommand && snapshot.claudePluginInstalls.length > 0) {
144
- for (const scope of uniqueStrings(snapshot.claudePluginInstalls.map((install) => install.scope || "user"))) {
145
- actions.push({
146
- label: `Update Claude plugin (${scope})`,
147
- command: ["claude", "plugin", "update", "agent-relay@agent-relay", "--scope", scope],
148
- reason: "Update installed Claude Code Agent Relay plugin.",
149
- mutates: true,
150
- });
151
- }
152
- } else if (!snapshot.hasClaudeCommand) {
139
+ if (!snapshot.hasClaudeCommand) {
153
140
  warnings.push("Claude Code command not detected; skipping Claude plugin upgrade.");
154
- } else {
155
- warnings.push("Claude Code detected, but agent-relay@agent-relay plugin is not installed; skipping Claude plugin upgrade.");
156
141
  }
157
142
  } else if (providerSet.has("auto") && !isClaudeRelayDetected(snapshot)) {
158
- warnings.push("Claude Agent Relay plugin not detected; skipping Claude plugin upgrade.");
143
+ warnings.push("Claude runner not detected; skipping Claude runner upgrade.");
159
144
  }
160
145
 
161
146
  if (!orchestratorRequested && providerSet.has("auto") && !isOrchestratorDetected(snapshot)) {
@@ -248,6 +233,7 @@ export function formatUpgradePlan(plan: UpgradePlan, options: { dryRun?: boolean
248
233
  `- server package: ${formatPackage(plan.snapshot.serverPackage)}`,
249
234
  `- running server: ${plan.snapshot.runningServerVersion ?? "unknown"}`,
250
235
  `- codex package: ${formatPackage(plan.snapshot.codexPackage)}`,
236
+ `- runner package: ${formatPackage(plan.snapshot.runnerPackage)}`,
251
237
  `- codex copied package: ${formatPackage(plan.snapshot.codexCopiedPackage)}`,
252
238
  `- orchestrator package: ${formatPackage(plan.snapshot.orchestratorPackage)}`,
253
239
  `- running orchestrators: ${formatRunningOrchestrators(plan.snapshot.runningOrchestrators)}`,
@@ -273,11 +259,11 @@ export function formatUpgradePlan(plan: UpgradePlan, options: { dryRun?: boolean
273
259
  }
274
260
 
275
261
  function isCodexDetected(snapshot: UpgradeSnapshot): boolean {
276
- return Boolean(snapshot.codexPackage || snapshot.codexCopiedPackage || snapshot.hasCodexCommand);
262
+ return Boolean(snapshot.runnerPackage || snapshot.codexPackage || snapshot.hasCodexCommand);
277
263
  }
278
264
 
279
265
  function isClaudeRelayDetected(snapshot: UpgradeSnapshot): boolean {
280
- return snapshot.claudePluginInstalls.length > 0;
266
+ return Boolean(snapshot.runnerPackage || snapshot.hasClaudeCommand);
281
267
  }
282
268
 
283
269
  function isOrchestratorDetected(snapshot: UpgradeSnapshot): boolean {