agent-relay-server 0.32.4 → 0.33.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.
Files changed (125) hide show
  1. package/package.json +2 -2
  2. package/public/assets/{activity-DT1JGHnp.js → activity-B0_uE6Yh.js} +2 -2
  3. package/public/assets/{activity-DT1JGHnp.js.map → activity-B0_uE6Yh.js.map} +1 -1
  4. package/public/assets/{agent-profiles-CrMemMkZ.js → agent-profiles-Rwxrcf9F.js} +2 -2
  5. package/public/assets/{agent-profiles-CrMemMkZ.js.map → agent-profiles-Rwxrcf9F.js.map} +1 -1
  6. package/public/assets/{agents-Bl-rrgOy.js → agents-Dp1EXJc8.js} +2 -2
  7. package/public/assets/{agents-Bl-rrgOy.js.map → agents-Dp1EXJc8.js.map} +1 -1
  8. package/public/assets/{analytics-a663ak56.js → analytics-D5OT5ajj.js} +2 -2
  9. package/public/assets/{analytics-a663ak56.js.map → analytics-D5OT5ajj.js.map} +1 -1
  10. package/public/assets/automation-Dm6rXNxK.js +2 -0
  11. package/public/assets/{automation-CiaLThdO.js.map → automation-Dm6rXNxK.js.map} +1 -1
  12. package/public/assets/{branch-state-badge-D4ur3m3_.js → branch-state-badge-FX5Yww2s.js} +2 -2
  13. package/public/assets/{branch-state-badge-D4ur3m3_.js.map → branch-state-badge-FX5Yww2s.js.map} +1 -1
  14. package/public/assets/{channels-o9KLTHoK.js → channels--rdAiX17.js} +2 -2
  15. package/public/assets/{channels-o9KLTHoK.js.map → channels--rdAiX17.js.map} +1 -1
  16. package/public/assets/chat-JZAEDGfX.js +2 -0
  17. package/public/assets/chat-JZAEDGfX.js.map +1 -0
  18. package/public/assets/{connectors-CdC806mA.js → connectors-Bx4gzvNf.js} +2 -2
  19. package/public/assets/{connectors-CdC806mA.js.map → connectors-Bx4gzvNf.js.map} +1 -1
  20. package/public/assets/display-Bebqs1qu.js +3 -0
  21. package/public/assets/display-Bebqs1qu.js.map +1 -0
  22. package/public/assets/{formatted-body-impl-Ca74OAEH.js → formatted-body-impl-CVq4qHix.js} +2 -2
  23. package/public/assets/{formatted-body-impl-Ca74OAEH.js.map → formatted-body-impl-CVq4qHix.js.map} +1 -1
  24. package/public/assets/{index-C_33ymaw.js → index-BHRtR4q7.js} +8 -8
  25. package/public/assets/{index-C_33ymaw.js.map → index-BHRtR4q7.js.map} +1 -1
  26. package/public/assets/{insights-ClI68s39.js → insights-yJFgCa3o.js} +2 -2
  27. package/public/assets/{insights-ClI68s39.js.map → insights-yJFgCa3o.js.map} +1 -1
  28. package/public/assets/{integrations-1nxMizDY.js → integrations-k1HIONjo.js} +2 -2
  29. package/public/assets/{integrations-1nxMizDY.js.map → integrations-k1HIONjo.js.map} +1 -1
  30. package/public/assets/maintenance-CsoOFBXx.js +2 -0
  31. package/public/assets/{maintenance-DiFNzNPN.js.map → maintenance-CsoOFBXx.js.map} +1 -1
  32. package/public/assets/{managed-agents-Do3dKvfj.js → managed-agents-Q3HuVjGg.js} +2 -2
  33. package/public/assets/{managed-agents-Do3dKvfj.js.map → managed-agents-Q3HuVjGg.js.map} +1 -1
  34. package/public/assets/{markdown-preview-impl-CLA0J255.js → markdown-preview-impl-CnsMjrnu.js} +2 -2
  35. package/public/assets/{markdown-preview-impl-CLA0J255.js.map → markdown-preview-impl-CnsMjrnu.js.map} +1 -1
  36. package/public/assets/{memory-IjwqFzBd.js → memory-D3-K5eJS.js} +2 -2
  37. package/public/assets/{memory-IjwqFzBd.js.map → memory-D3-K5eJS.js.map} +1 -1
  38. package/public/assets/{messages-DjvWqHyn.js → messages-B4lCP5rS.js} +2 -2
  39. package/public/assets/{messages-DjvWqHyn.js.map → messages-B4lCP5rS.js.map} +1 -1
  40. package/public/assets/{orchestrators-D2IqDxDT.js → orchestrators-CRoZtLeQ.js} +2 -2
  41. package/public/assets/{orchestrators-D2IqDxDT.js.map → orchestrators-CRoZtLeQ.js.map} +1 -1
  42. package/public/assets/{overview-DKC3TbAh.js → overview-CxCU2fOF.js} +2 -2
  43. package/public/assets/{overview-DKC3TbAh.js.map → overview-CxCU2fOF.js.map} +1 -1
  44. package/public/assets/pairs-unqjPlmq.js +2 -0
  45. package/public/assets/{pairs-WpKCPE1n.js.map → pairs-unqjPlmq.js.map} +1 -1
  46. package/public/assets/{security-BF7ZtPQe.js → security-B7HhSYNy.js} +2 -2
  47. package/public/assets/{security-BF7ZtPQe.js.map → security-B7HhSYNy.js.map} +1 -1
  48. package/public/assets/{settings-CQnjrTa-.js → settings-B9NDhsAb.js} +2 -2
  49. package/public/assets/{settings-CQnjrTa-.js.map → settings-B9NDhsAb.js.map} +1 -1
  50. package/public/assets/store-DiSzYHj9.js +9 -0
  51. package/public/assets/{store-C9VcSo05.js.map → store-DiSzYHj9.js.map} +1 -1
  52. package/public/assets/{tasks-CbN_GSSb.js → tasks-CIQolvNm.js} +2 -2
  53. package/public/assets/{tasks-CbN_GSSb.js.map → tasks-CIQolvNm.js.map} +1 -1
  54. package/public/assets/{terminal-viewer-impl-BJRohThT.js → terminal-viewer-impl-DCifVqFR.js} +2 -2
  55. package/public/assets/{terminal-viewer-impl-BJRohThT.js.map → terminal-viewer-impl-DCifVqFR.js.map} +1 -1
  56. package/public/assets/{work-queue-C5xLBLmm.js → work-queue-Dr3c1V6O.js} +2 -2
  57. package/public/assets/{work-queue-C5xLBLmm.js.map → work-queue-Dr3c1V6O.js.map} +1 -1
  58. package/public/assets/{workspaces-D91H3wDX.js → workspaces-B1Jxop7h.js} +3 -3
  59. package/public/assets/{workspaces-D91H3wDX.js.map → workspaces-B1Jxop7h.js.map} +1 -1
  60. package/public/index.html +3 -3
  61. package/runner/src/adapter.ts +1 -1
  62. package/src/agent-lifecycle-events.ts +137 -0
  63. package/src/artifact-storage.ts +3 -5
  64. package/src/channel-target.ts +24 -0
  65. package/src/cli/_shared.ts +80 -0
  66. package/src/cli/agent-detect.ts +188 -0
  67. package/src/cli/agent-meta.ts +95 -0
  68. package/src/cli/context-probe.ts +88 -0
  69. package/src/cli/daemon.ts +111 -0
  70. package/src/cli/dev.ts +173 -0
  71. package/src/cli/index.ts +361 -0
  72. package/src/cli/introspect.ts +73 -0
  73. package/src/cli/memory.ts +37 -0
  74. package/src/cli/message.ts +201 -0
  75. package/src/cli/orchestrator.ts +227 -0
  76. package/src/cli/pair.ts +125 -0
  77. package/src/cli/provider.ts +209 -0
  78. package/src/cli/recipe.ts +110 -0
  79. package/src/cli/reply.ts +141 -0
  80. package/src/cli/setup.ts +57 -0
  81. package/src/cli/steward.ts +59 -0
  82. package/src/cli/token.ts +81 -0
  83. package/src/cli/upgrade.ts +193 -0
  84. package/src/cli/workspace.ts +215 -0
  85. package/src/cli.ts +4 -2718
  86. package/src/config-store.ts +10 -6
  87. package/src/db/activity.ts +194 -0
  88. package/src/db/agent-search.ts +174 -0
  89. package/src/db/agents.ts +551 -0
  90. package/src/db/artifacts.ts +342 -0
  91. package/src/db/channels.ts +576 -0
  92. package/src/db/connection.ts +71 -0
  93. package/src/db/delivery.ts +395 -0
  94. package/src/db/inbox.ts +249 -0
  95. package/src/db/index.ts +23 -0
  96. package/src/db/integrations.ts +339 -0
  97. package/src/db/mappers.ts +397 -0
  98. package/src/db/merge-lease.ts +160 -0
  99. package/src/db/message-reads.ts +304 -0
  100. package/src/db/messages.ts +434 -0
  101. package/src/db/migrations.ts +431 -0
  102. package/src/db/orchestrators.ts +358 -0
  103. package/src/db/pairs.ts +324 -0
  104. package/src/db/schema.ts +758 -0
  105. package/src/db/stats.ts +337 -0
  106. package/src/db/tasks.ts +407 -0
  107. package/src/db/workspaces.ts +440 -0
  108. package/src/db.ts +4 -5721
  109. package/src/maintenance.ts +4 -0
  110. package/src/mcp-errors.ts +7 -0
  111. package/src/mcp.ts +32 -34
  112. package/src/routes/agents-spawn.ts +9 -1
  113. package/src/routes/agents.ts +5 -0
  114. package/src/routes/commands.ts +15 -0
  115. package/src/routes/integrations.ts +6 -8
  116. package/src/spawn-targets.ts +159 -0
  117. package/src/utils.ts +16 -1
  118. package/public/assets/automation-CiaLThdO.js +0 -2
  119. package/public/assets/chat-5hvHZcAe.js +0 -2
  120. package/public/assets/chat-5hvHZcAe.js.map +0 -1
  121. package/public/assets/display-JI19Vc7L.js +0 -3
  122. package/public/assets/display-JI19Vc7L.js.map +0 -1
  123. package/public/assets/maintenance-DiFNzNPN.js +0 -2
  124. package/public/assets/pairs-WpKCPE1n.js +0 -2
  125. package/public/assets/store-C9VcSo05.js +0 -9
package/src/cli.ts CHANGED
@@ -1,2718 +1,4 @@
1
- import { createInterface } from "node:readline/promises";
2
- import { stdin as input, stdout as output } from "node:process";
3
- import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
- import { hostname as osHostname, homedir } from "node:os";
5
- import { dirname, join, resolve } from "node:path";
6
- import {
7
- createDaemonPlan,
8
- detectDaemonEnvironment,
9
- executeDaemonPlan,
10
- formatDaemonPlan,
11
- type DaemonAction,
12
- type DaemonScope,
13
- } from "./daemon";
14
- import {
15
- createSetupPlan,
16
- executeSetupPlan,
17
- formatSetupPlan,
18
- pathExists,
19
- renderEnvFile,
20
- } from "./setup";
21
- import { defaultRuntimePrefix, runtimeBinPath } from "./runtime-prefix";
22
- import {
23
- createDevInstallPlan,
24
- createDevPackPlan,
25
- createDevServicePlan,
26
- defaultDevRoot,
27
- executeDevInstallPlan,
28
- executeDevPackPlan,
29
- executeDevServicePlan,
30
- executeDevSmoke,
31
- formatDevInstallPlan,
32
- formatDevPackPlan,
33
- formatDevServicePlan,
34
- parseDevPackages,
35
- type DevServiceAction,
36
- } from "./dev";
37
- import {
38
- createUpgradePlan,
39
- detectUpgradeSnapshot,
40
- executeUpgradePlan,
41
- formatUpgradePlan,
42
- resolveLocalOrchestratorId,
43
- type UpgradeProvider,
44
- } from "./upgrade";
45
- import { formatMemoryBrokerSmokeResult, runMemoryBrokerSmoke } from "./memory-broker-smoke";
46
- import { MAX_BODY_BYTES, VERSION } from "./config";
47
- import { runContextProbe } from "agent-relay-sdk/context-probe";
48
- import { shellQuote } from "agent-relay-sdk/shell-utils";
49
- import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
50
- import type { WorkspaceDepsRefreshResult } from "agent-relay-sdk";
51
- import { describeWorkspacePhase, readyContract, type WorkspacePhaseView } from "./workspace-phase";
52
-
53
- export const WORKSPACE_USAGE = "Usage: agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--purpose TEXT] [--repo PATH] [--wait] [--timeout SECONDS] [--check] [--execute] [--json]";
54
-
55
- const HELP = `
56
- agent-relay ${VERSION}
57
-
58
- Usage:
59
- agent-relay [start]
60
- agent-relay setup [--yes] [--dry-run] [--force] [--env-file PATH] [--runtime-prefix DIR] [--host HOST] [--port PORT] [--db-path PATH] [--token TOKEN|--no-token]
61
- agent-relay upgrade [--dry-run] [--version VERSION] [--runtime-prefix DIR] [--providers auto|all|codex|claude|orchestrator] [--no-restart] [--yes]
62
- agent-relay upgrade --host ID [--host ID2 ...] [--version VERSION] [--providers ...] (upgrade remote orchestrator hosts over the relay)
63
- agent-relay upgrade --all-hosts [...] (upgrade this host, then every behind remote host)
64
- agent-relay setup upgrade [same options as upgrade]
65
- agent-relay daemon <install|uninstall|start|stop|restart|enable|disable|status|logs> [options]
66
- agent-relay orchestrator install [options]
67
- agent-relay dev <pack|install|service|smoke> [options]
68
- agent-relay memory broker smoke [options]
69
- agent-relay recipe <list|show|start|stop|status> [options]
70
- agent-relay provider <wrap|unwrap> <claude|codex>
71
- agent-relay context-probe [print-status-line] [--wrap COMMAND] [--agent-id ID] [--state-dir DIR] [--standalone]
72
- agent-relay token <create|list|revoke|verify> [options]
73
- agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
74
- agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--purpose TEXT] [--repo PATH] [--wait] [--timeout SECONDS] [--check] [--execute] [--json]
75
- agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]
76
- agent-relay message <target> <body> [options]
77
- agent-relay get-message <messageId> [--json|--body]
78
- agent-relay /pair <target|accept|reject|send|status> [...]
79
- agent-relay /message <target> <body>
80
- agent-relay /reply <messageId> <body|--stdin|--body-file PATH> [--format text|markdown|markdownv2]
81
- agent-relay /react <messageId> <emoji> [--remove]
82
- agent-relay /send-claimable <target> <body>
83
- agent-relay /disconnect [PAIR_ID]
84
- agent-relay /status
85
- agent-relay /guide
86
- agent-relay /label [LABEL]
87
- agent-relay /tags [TAG ...]
88
- agent-relay /introspect [--thin TEXT] [--worked-around TEXT] [--would-have-helped TEXT] [--stdin]
89
- agent-relay --help
90
-
91
- Pair examples:
92
- agent-relay pair codex --objective "Debug flaky tests"
93
- agent-relay /pair codex "Debug flaky tests"
94
- agent-relay pair status
95
- agent-relay pair accept PAIR_ID --agent AGENT_ID
96
- agent-relay pair send PAIR_ID --from AGENT_ID --body "What do you see?"
97
- agent-relay /message codex "Can you look at that failing action?"
98
- agent-relay /reply 206 "Sounds good, I'll take a look"
99
- agent-relay /react 206 👍
100
- agent-relay /reply 206 --stdin < result.md
101
- agent-relay get-message 206 --json
102
- agent-relay /reply 206 --format markdown "Here is **formatted** text"
103
- agent-relay /send-claimable tag:backend "Please claim and fix the failing API test"
104
- agent-relay /disconnect
105
- agent-relay /status
106
- agent-relay /guide
107
- agent-relay /label backend-fixer
108
- agent-relay /tags backend tests urgent
109
-
110
- Daemon options:
111
- --env-file PATH Env file sourced by the daemon (default: platform user config dir)
112
- --runtime-prefix DIR Isolated Agent Relay npm prefix (default: ~/.agent-relay/runtime)
113
- --binary PATH Stable agent-relay binary/script path for the service
114
- --path-prefix DIR Prepend a directory to daemon PATH; repeatable
115
- --name NAME Service name (default: agent-relay)
116
- --host HOST Display/listen host for generated plan (default: 127.0.0.1)
117
- --port PORT Display/listen port for generated plan (default: 4850)
118
- --user|--system User service by default, system service when explicitly requested
119
- --enable Enable service at login/boot during install
120
- --start Start service after install
121
- --dry-run Print the plan without writing files or running service commands
122
- --yes Skip confirmation prompts
123
- --force Overwrite/remove managed-file guardrails
124
- --json Print structured output
125
-
126
- Orchestrator install options:
127
- --relay-url URL Agent Relay server URL reachable from this host
128
- --token TOKEN Scoped runtime orchestrator token
129
- --bootstrap-token TOKEN Short-lived dashboard bootstrap token to exchange during install
130
- --id ID Orchestrator id (default: sanitized hostname)
131
- --base-dir PATH Base directory for agent working directories (default: ~/projects)
132
- --providers LIST Providers to probe/enable (default: claude,codex)
133
- --api-port PORT Orchestrator local API port (default: 4860)
134
- --runtime-prefix DIR Isolated npm prefix (default: ~/.agent-relay/runtime)
135
- --path-prefix DIR Prepend a directory to daemon PATH; repeatable
136
- --version VERSION Package version to install (default: this CLI version)
137
- --dry-run Print the plan without writing files, installing, or starting
138
- --yes Skip confirmation prompts
139
-
140
- Upgrade options:
141
- --version VERSION Target version (default: latest published server version)
142
- --runtime-prefix DIR Isolated Agent Relay npm prefix (default: ~/.agent-relay/runtime)
143
- --providers LIST Provider integrations to upgrade: auto, all, codex, claude, orchestrator
144
- --host ID Upgrade a remote orchestrator host over the relay (repeatable). Skips the local upgrade
145
- --all-hosts Upgrade this host, then fan out to every connected remote host that is behind
146
- --no-restart Do not restart agent-relay.service (warns you to restart it manually)
147
- --restart-deferred Like --no-restart, but the caller restarts the services itself; suppresses the manual-restart warning (used by the release script)
148
- --dry-run Print detected state and planned commands
149
- --yes Skip confirmation prompts
150
-
151
- Dev options:
152
- agent-relay dev pack [--packages LIST] [--out DIR]
153
- agent-relay dev install [--packages LIST] [--prefix DIR] [--out DIR] [--dry-run] [--json]
154
- agent-relay dev service <install|uninstall|start|stop|restart|status|logs> [--prefix DIR] [--root DIR] [--port PORT] [--api-port PORT] [--base-dir DIR] [--enable] [--start] [--dry-run] [--yes] [--force] [--json]
155
- agent-relay dev smoke [--root DIR] [--providers LIST] [--cwd DIR] [--timeout MS]
156
-
157
- Memory options:
158
- agent-relay memory broker smoke [--relay-url URL] [--token TOKEN] [--agent-id ID] [--scope SCOPE] [--tag TAG] [--no-cleanup] [--json]
159
-
160
- Recipe examples:
161
- agent-relay recipe list
162
- agent-relay recipe start code-review --cwd /repo --orchestrator local
163
- agent-relay recipe status
164
- agent-relay recipe stop INSTANCE_ID
165
-
166
- Token examples:
167
- agent-relay token create --role orchestrator --sub macmini --ttl 86400
168
- agent-relay token list
169
- agent-relay token revoke JTI
170
-
171
- Context probe examples:
172
- agent-relay context-probe print-status-line --wrap 'ccstatusline'
173
- agent-relay context-probe --wrap 'ccstatusline'
174
- agent-relay context-probe --standalone
175
- `.trim();
176
-
177
- const AGENT_GUIDE = `
178
- Agent Relay guide for coding agents
179
-
180
- Agent Relay is a message bus between local or managed agents. Use the
181
- agent-relay CLI for relay communication.
182
-
183
- Current session
184
- agent-relay /status --json
185
- Shows your relay agent id, label, tags, health, and active pair state.
186
-
187
- Replying
188
- agent-relay /reply <messageId> "<response>"
189
- agent-relay /reply <messageId> --stdin < response.md
190
- agent-relay /reply <messageId> --body-file response.md
191
- Reply to the sender of a relay message. Prefer this over /message when
192
- handling an incoming relay message because the server keeps the routing
193
- and channel context. Large replies are uploaded as an artifact
194
- automatically and sent as a concise attached reply.
195
-
196
- Reactions
197
- agent-relay /react <messageId> <emoji>
198
- agent-relay /react <messageId> <emoji> --remove
199
- Use reactions for lightweight acknowledgement, approval, thanks, or
200
- "good job" when no text response is needed. Do not use reactions when the
201
- sender asked a question, gave a new task, or needs a result in text.
202
-
203
- Reading full messages
204
- agent-relay get-message <messageId>
205
- agent-relay read-message <messageId> --json
206
- agent-relay get-message <messageId> --body
207
- Fetch the full message body, attachments, metadata, and thread context.
208
-
209
- Sending messages
210
- agent-relay /message <target> "<message>"
211
- Send a direct one-off relay message.
212
-
213
- agent-relay /send-claimable <target> "<work item>"
214
- Queue work that one matching agent can claim and handle.
215
-
216
- Targets
217
- <agent-id> A specific live agent id.
218
- <label> A registered agent label.
219
- tag:<tag> Any agent with that tag.
220
- cap:<capability> Any agent with that capability.
221
- policy:<name> A managed policy target that can survive restarts.
222
- broadcast All reachable agents.
223
-
224
- Pair sessions
225
- agent-relay /pair <target> "<objective>"
226
- agent-relay /pair status
227
- agent-relay /pair accept <pairId>
228
- agent-relay /pair reject <pairId>
229
- agent-relay /pair send <pairId> "<message>"
230
- agent-relay /disconnect [pairId]
231
-
232
- Labels and tags
233
- agent-relay /label [LABEL]
234
- agent-relay /tags [TAG ...]
235
-
236
- Isolated workspaces
237
- If you are working in an isolated workspace (a git worktree on an agent
238
- branch, not the main checkout), you do NOT rebase, merge, or push yourself —
239
- Relay does. Just commit your work in the worktree, then:
240
- agent-relay workspace ready Hand off: Relay rebases onto the latest base,
241
- lands your work, and pushes.
242
- agent-relay workspace status Show your workspace's state + what to do next.
243
- agent-relay workspace status --wait
244
- Block until your branch lands (returns the
245
- moment the auto-merge completes).
246
- After "ready", status is "review_requested" — this is NORMAL, not an
247
- escalation. Relay auto-merges clean rebases ~every 2 min; a steward agent is
248
- spawned only if it can't land deterministically, so no steward = healthy. On
249
- landing you move onto a fresh rebased branch (name gains a "--N" suffix).
250
- The base branch will move as other agents land in parallel — that is normal,
251
- let the merge handle it. Never push, merge, resolve conflicts, or touch the
252
- main checkout yourself; it is local-only and Relay (and the steward) own that.
253
- If typecheck/build fails on a missing module (a dep added to the base after
254
- your worktree was created), do NOT run a clean install — it mutates the shared
255
- node_modules. Instead refresh your worktree's deps in isolation:
256
- agent-relay workspace deps Re-provision deps that have gone stale.
257
- agent-relay workspace deps --check Report staleness without installing.
258
-
259
- Rules of thumb
260
- If you are handling relay message #123, reply with:
261
- agent-relay /reply 123 "<response>"
262
-
263
- If the delivered preview says it was truncated, fetch the full message with:
264
- agent-relay get-message 123
265
-
266
- If your reply is long, avoid shell quoting and use:
267
- agent-relay /reply 123 --stdin < response.md
268
-
269
- If you need to know who you are in Relay, run:
270
- agent-relay /status --json
271
-
272
- Recording session friction (optional, helps improve your standing context)
273
- agent-relay /introspect --thin "..." --worked-around "..." --would-have-helped "..."
274
- When a session involved real work, you can record a short 3-field note about
275
- where context was thin, what tooling/instruction gaps you routed around, and
276
- what would have saved the read-up. Keep each field to a sentence or two. This
277
- feeds the relay's self-improvement signal so the operator can close the gaps —
278
- it is not a reply and needs no message id. Skip it for trivial sessions.
279
-
280
- Use the HTTP API for integrations and debugging. For normal agent-to-agent
281
- communication, use the CLI commands above.
282
- `.trim();
283
-
284
- const DAEMON_ACTIONS = new Set<DaemonAction>([
285
- "install",
286
- "uninstall",
287
- "start",
288
- "stop",
289
- "restart",
290
- "enable",
291
- "disable",
292
- "status",
293
- "logs",
294
- ]);
295
-
296
- export async function handleCli(args: string[]): Promise<"start" | "handled"> {
297
- const command = args[0];
298
- if (!command || command === "start") return "start";
299
- if (command === "--help" || command === "-h" || command === "help") {
300
- console.log(HELP);
301
- return "handled";
302
- }
303
- if (command === "--version" || command === "-v") {
304
- console.log(VERSION);
305
- return "handled";
306
- }
307
- if (command === "guide" || command === "/guide" || command === "agent-guide" || command === "/agent-guide") {
308
- console.log(AGENT_GUIDE);
309
- return "handled";
310
- }
311
- if (command === "upgrade" || (command === "setup" && args[1] === "upgrade")) {
312
- await handleUpgradeCommand(command === "setup" ? args.slice(2) : args.slice(1));
313
- return "handled";
314
- }
315
- if (command === "setup" || command === "init") {
316
- await handleSetupCommand(args.slice(1));
317
- return "handled";
318
- }
319
- if (command === "daemon") {
320
- await handleDaemonCommand(args.slice(1));
321
- return "handled";
322
- }
323
- if (command === "orchestrator" || command === "orchestrators") {
324
- await handleOrchestratorCommand(args.slice(1));
325
- return "handled";
326
- }
327
- if (command === "dev") {
328
- await handleDevCommand(args.slice(1));
329
- return "handled";
330
- }
331
- if (command === "memory") {
332
- await handleMemoryCommand(args.slice(1));
333
- return "handled";
334
- }
335
- if (command === "recipe" || command === "recipes") {
336
- await handleRecipeCommand(args.slice(1));
337
- return "handled";
338
- }
339
- if (command === "provider" || command === "providers") {
340
- await handleProviderCommand(args.slice(1));
341
- return "handled";
342
- }
343
- if (command === "context-probe") {
344
- await handleContextProbeCommand(args.slice(1));
345
- return "handled";
346
- }
347
- if (command === "token" || command === "tokens") {
348
- await handleTokenCommand(args.slice(1));
349
- return "handled";
350
- }
351
- if (command === "pair" || command === "/pair" || command === "/disconnect") {
352
- await handleSlashOrPairCommand(command, args.slice(1));
353
- return "handled";
354
- }
355
- if (command === "message" || command === "send" || command === "/message" || command === "/send" || command === "/send-claimable") {
356
- await handleMessageCommand(args.slice(1), { claimable: command === "/send-claimable" });
357
- return "handled";
358
- }
359
- if (command === "get-message" || command === "read-message" || command === "/get-message" || command === "/read-message") {
360
- await handleGetMessageCommand(args.slice(1));
361
- return "handled";
362
- }
363
- if (command === "reply" || command === "/reply") {
364
- await handleReplyCommand(args.slice(1));
365
- return "handled";
366
- }
367
- if (command === "react" || command === "/react" || command === "reaction" || command === "/reaction") {
368
- await handleReactCommand(args.slice(1));
369
- return "handled";
370
- }
371
- if (command === "introspect" || command === "/introspect") {
372
- await handleIntrospectCommand(args.slice(1));
373
- return "handled";
374
- }
375
- if (command === "/status" || command === "status") {
376
- await handleStatusCommand(args.slice(1));
377
- return "handled";
378
- }
379
- if (command === "/label" || command === "label") {
380
- await handleLabelCommand(args.slice(1));
381
- return "handled";
382
- }
383
- if (command === "/tags" || command === "tags") {
384
- await handleTagsCommand(args.slice(1));
385
- return "handled";
386
- }
387
- if (command === "workspace" || command === "workspaces") {
388
- await handleWorkspaceCommand(args.slice(1));
389
- return "handled";
390
- }
391
- if (command === "steward" || command === "stewards") {
392
- await handleStewardCommand(args.slice(1));
393
- return "handled";
394
- }
395
- if (command === "/reconnect") {
396
- console.log("Reconnect is handled automatically by provider runners; use `agent-relay pair status` to inspect current pair state.");
397
- return "handled";
398
- }
399
- throw new Error(`Unknown command "${command}". Run agent-relay --help.`);
400
- }
401
-
402
- async function handleUpgradeCommand(args: string[]): Promise<void> {
403
- let targetVersion: string | undefined;
404
- let dryRun = false;
405
- let noRestart = false;
406
- let restartDeferred = false;
407
- let yes = false;
408
- let json = false;
409
- let runtimePrefix: string | undefined;
410
- const pathPrefix: string[] = [];
411
- const providers: UpgradeProvider[] = [];
412
- const hosts: string[] = [];
413
- let allHosts = false;
414
-
415
- for (let i = 0; i < args.length; i++) {
416
- const arg = args[i];
417
- if (arg === "--version" && i + 1 < args.length) targetVersion = args[++i];
418
- else if (arg === "--runtime-prefix" && i + 1 < args.length) runtimePrefix = args[++i];
419
- else if (arg === "--providers" && i + 1 < args.length) {
420
- for (const provider of args[++i]!.split(",")) providers.push(parseUpgradeProvider(provider));
421
- } else if (arg === "--provider" && i + 1 < args.length) providers.push(parseUpgradeProvider(args[++i]!));
422
- else if (arg === "--host" && i + 1 < args.length) hosts.push(args[++i]!);
423
- else if (arg === "--all-hosts") allHosts = true;
424
- else if (arg === "--codex") providers.push("codex");
425
- else if (arg === "--claude") providers.push("claude");
426
- else if (arg === "--orchestrator") providers.push("orchestrator");
427
- else if (arg === "--all") providers.push("all");
428
- else if (arg === "--dry-run") dryRun = true;
429
- else if (arg === "--no-restart") noRestart = true;
430
- else if (arg === "--restart-deferred") restartDeferred = true;
431
- else if (arg === "--yes" || arg === "-y") yes = true;
432
- else if (arg === "--json") json = true;
433
- else throw new Error(`Unknown upgrade option "${arg}"`);
434
- }
435
-
436
- // Remote-only: drive named hosts' self-upgrade over the relay command bus and
437
- // skip the local upgrade entirely (#210). `--all-hosts` instead upgrades this
438
- // host first, then fans out to every behind remote (handled after the local run).
439
- if (hosts.length && !allHosts) {
440
- await runRemoteOrchestratorUpgrades({ hosts, targetVersion, providers, json, dryRun, yes });
441
- return;
442
- }
443
-
444
- const snapshot = await detectUpgradeSnapshot({
445
- ...(targetVersion ? { targetVersion } : {}),
446
- ...(runtimePrefix ? { runtimePrefix } : {}),
447
- providers,
448
- noRestart,
449
- });
450
- const plan = createUpgradePlan(snapshot, {
451
- ...(targetVersion ? { targetVersion } : {}),
452
- ...(runtimePrefix ? { runtimePrefix } : {}),
453
- providers,
454
- noRestart,
455
- restartDeferred,
456
- });
457
-
458
- if (json) {
459
- console.log(JSON.stringify({ plan }, null, 2));
460
- if (allHosts) await runRemoteOrchestratorUpgrades({ allBehind: true, targetVersion: plan.targetVersion, providers, json, dryRun: true });
461
- return;
462
- }
463
-
464
- if (dryRun) {
465
- console.log(formatUpgradePlan(plan, { dryRun: true }));
466
- if (allHosts) await runRemoteOrchestratorUpgrades({ allBehind: true, targetVersion: plan.targetVersion, providers, dryRun: true });
467
- return;
468
- }
469
-
470
- if (!yes) {
471
- console.log(formatUpgradePlan(plan));
472
- const ok = await confirm("Run this upgrade plan?");
473
- if (!ok) {
474
- console.log("Upgrade cancelled.");
475
- return;
476
- }
477
- }
478
-
479
- console.log(await executeUpgradePlan(plan));
480
-
481
- if (allHosts) {
482
- await runRemoteOrchestratorUpgrades({ allBehind: true, targetVersion: plan.targetVersion, providers, json, yes: true });
483
- }
484
- }
485
-
486
- /**
487
- * Trigger orchestrator self-upgrade on remote hosts via the relay command bus
488
- * (#210). Each host installs its own runtime + self-restarts; the relay settles
489
- * the version when it re-registers. Replaces the manual ssh + npm install dance.
490
- */
491
- async function runRemoteOrchestratorUpgrades(opts: {
492
- hosts?: string[];
493
- allBehind?: boolean;
494
- targetVersion?: string;
495
- providers: UpgradeProvider[];
496
- json?: boolean;
497
- dryRun?: boolean;
498
- yes?: boolean;
499
- }): Promise<void> {
500
- const targetVersion = opts.targetVersion ?? VERSION;
501
- const orchestrators = (await apiRequest("GET", "/api/orchestrators")) as Array<{
502
- id: string;
503
- version?: string;
504
- }>;
505
- const byId = new Map(orchestrators.map((orch) => [orch.id, orch]));
506
- const localId = resolveLocalOrchestratorId();
507
- // Default to "all" so a remote host's provider runner is upgraded too, not just
508
- // the orchestrator package (matters for hosts running claude/codex agents).
509
- const remoteProviders: UpgradeProvider[] = opts.providers.length ? opts.providers : ["all"];
510
-
511
- let targets: string[];
512
- if (opts.allBehind) {
513
- targets = orchestrators
514
- .filter((orch) => orch.id !== localId && orch.version && orch.version !== targetVersion)
515
- .map((orch) => orch.id);
516
- if (!targets.length) {
517
- if (opts.json) console.log(JSON.stringify({ remoteUpgrades: [] }, null, 2));
518
- else console.log(`No remote orchestrators behind ${targetVersion}.`);
519
- return;
520
- }
521
- } else {
522
- targets = opts.hosts ?? [];
523
- }
524
-
525
- if (opts.dryRun) {
526
- const lines = targets.map((id) => {
527
- const orch = byId.get(id);
528
- const from = orch ? orch.version ?? "unknown" : "(not connected)";
529
- return ` ${id}: ${from} → ${targetVersion} (providers: ${remoteProviders.join(",")})`;
530
- });
531
- if (opts.json) console.log(JSON.stringify({ remoteUpgrades: targets.map((id) => ({ id, targetVersion, providers: remoteProviders, dryRun: true })) }, null, 2));
532
- else console.log(`Remote orchestrator upgrade plan → ${targetVersion}:\n${lines.join("\n")}`);
533
- return;
534
- }
535
-
536
- if (!opts.yes && !opts.json) {
537
- console.log(`Trigger remote orchestrator upgrade → ${targetVersion} for: ${targets.join(", ")}`);
538
- const ok = await confirm("Send remote upgrade command(s)?");
539
- if (!ok) {
540
- console.log("Remote upgrade cancelled.");
541
- return;
542
- }
543
- }
544
-
545
- const results: Array<{ id: string; ok: boolean; message: string }> = [];
546
- for (const id of targets) {
547
- if (!byId.has(id)) {
548
- results.push({ id, ok: false, message: "not connected to the relay" });
549
- continue;
550
- }
551
- try {
552
- const res = (await apiRequest("POST", `/api/orchestrators/${encodeURIComponent(id)}/actions`, {
553
- action: "upgrade",
554
- targetVersion,
555
- providers: remoteProviders,
556
- })) as { command?: { id?: string } };
557
- const from = byId.get(id)?.version;
558
- results.push({ id, ok: true, message: `queued ${from ?? "?"} → ${targetVersion} (command ${res?.command?.id ?? "?"})` });
559
- } catch (err) {
560
- results.push({ id, ok: false, message: errMessage(err) });
561
- }
562
- }
563
-
564
- if (opts.json) {
565
- console.log(JSON.stringify({ remoteUpgrades: results }, null, 2));
566
- return;
567
- }
568
- console.log(`\nRemote orchestrator upgrades → ${targetVersion}:`);
569
- for (const result of results) console.log(` ${result.ok ? "✓" : "✗"} ${result.id}: ${result.message}`);
570
- console.log("\nEach host installs and self-restarts; the relay reconciles the version when it re-registers.");
571
- console.log("Track progress in the dashboard Orchestrators view or via GET /api/orchestrators.");
572
- if (results.some((result) => !result.ok)) {
573
- process.exitCode = 1;
574
- }
575
- }
576
-
577
- function parseUpgradeProvider(value: string): UpgradeProvider {
578
- if (value === "auto" || value === "all" || value === "codex" || value === "claude" || value === "orchestrator") return value;
579
- throw new Error(`Unknown upgrade provider "${value}". Expected auto, all, codex, claude, or orchestrator.`);
580
- }
581
-
582
- async function handleProviderCommand(args: string[]): Promise<void> {
583
- const action = args[0];
584
- const provider = args[1];
585
- if ((action !== "wrap" && action !== "unwrap") || (provider !== "claude" && provider !== "codex")) {
586
- throw new Error("Usage: agent-relay provider <wrap|unwrap> <claude|codex>");
587
- }
588
- const dir = join(process.env.HOME || homedir(), ".agent-relay", "bin");
589
- const shims = providerShimPaths(provider);
590
- if (provider === "codex") cleanLegacyCodexSessionStartHook();
591
- if (action === "wrap") {
592
- for (const shim of shims) {
593
- mkdirSync(dirname(shim), { recursive: true });
594
- writeFileSync(shim, providerShimContent(provider), "utf8");
595
- chmodSync(shim, 0o755);
596
- console.log(`Wrapped ${provider}: ${shim}`);
597
- }
598
- console.log(`Ensure ${dir} is before the provider binary on PATH.`);
599
- return;
600
- }
601
- for (const shim of shims) {
602
- if (existsSync(shim)) unlinkSync(shim);
603
- console.log(`Unwrapped ${provider}: ${shim}`);
604
- }
605
- }
606
-
607
- function cleanLegacyCodexSessionStartHook(): void {
608
- const configPath = join(process.env.HOME || homedir(), ".codex", "config.toml");
609
- if (!existsSync(configPath)) return;
610
- const before = readFileSync(configPath, "utf8");
611
- const after = removeLegacyCodexSessionStartHookToml(before);
612
- if (after === before) return;
613
- writeFileSync(configPath, after, "utf8");
614
- console.log(`Removed legacy Agent Relay Codex hook entries from ${configPath}`);
615
- }
616
-
617
- export function removeLegacyCodexSessionStartHookToml(input: string): string {
618
- const blocks = tomlHookBlocks(input);
619
- let output = "";
620
- for (let index = 0; index < blocks.length;) {
621
- const block = blocks[index];
622
- if (isCodexHookGroupHeader(block?.header)) {
623
- const group = block!;
624
- const hooks: typeof blocks = [];
625
- const hookHeader = codexHookHandlerHeader(group.header!);
626
- index += 1;
627
- while (index < blocks.length && blocks[index]?.header?.trim() === hookHeader) {
628
- hooks.push(blocks[index]!);
629
- index += 1;
630
- }
631
-
632
- const keptHooks = hooks.filter((hook) => !isLegacyCodexSessionStartHook(hook.text));
633
- const groupIsLegacyOnly = isLegacyCodexSessionStartHook(group.text) || (hooks.length > 0 && keptHooks.length === 0);
634
- if (!groupIsLegacyOnly || keptHooks.length > 0) {
635
- output += `${group.text}\n`;
636
- for (const hook of keptHooks) output += `${hook.text}\n`;
637
- }
638
- continue;
639
- }
640
-
641
- if (isCodexHookHandlerHeader(block?.header) && isLegacyCodexSessionStartHook(block?.text ?? "")) {
642
- index += 1;
643
- continue;
644
- }
645
-
646
- if (block) output += `${block.text}\n`;
647
- index += 1;
648
- }
649
- return output.trimEnd() + "\n";
650
- }
651
-
652
- function tomlHookBlocks(input: string): Array<{ header: string | null; text: string }> {
653
- const lines = input.split(/\r?\n/);
654
- const blocks: Array<{ header: string | null; text: string }> = [];
655
- let current: { header: string | null; lines: string[] } = { header: null, lines: [] };
656
- for (const line of lines) {
657
- const header = line.match(/^\s*\[\[hooks\.[^\]]+\]\]\s*$/)?.[0] ?? null;
658
- if (header) {
659
- if (current.lines.length > 0) blocks.push({ header: current.header, text: current.lines.join("\n").trimEnd() });
660
- current = { header, lines: [line] };
661
- } else {
662
- current.lines.push(line);
663
- }
664
- }
665
- if (current.lines.length > 0) blocks.push({ header: current.header, text: current.lines.join("\n").trimEnd() });
666
- return blocks.filter((block) => block.text.trim().length > 0);
667
- }
668
-
669
- function isCodexHookGroupHeader(header: string | null | undefined): boolean {
670
- return /^\[\[hooks\.[^.]+\]\]$/.test(header?.trim() ?? "");
671
- }
672
-
673
- function isCodexHookHandlerHeader(header: string | null | undefined): boolean {
674
- return /^\[\[hooks\.[^.]+\.hooks\]\]$/.test(header?.trim() ?? "");
675
- }
676
-
677
- function codexHookHandlerHeader(groupHeader: string): string {
678
- return groupHeader.trim().replace(/\]\]$/, ".hooks]]");
679
- }
680
-
681
- function isLegacyCodexSessionStartHook(text: string): boolean {
682
- return /\.agent-relay\/codex\/package\/hooks\//.test(text) ||
683
- /agent-relay-codex.*hook/i.test(text) ||
684
- /agent-relay.*(SessionStart|UserPromptSubmit|Stop).*hook/i.test(text);
685
- }
686
-
687
- async function handleContextProbeCommand(args: string[]): Promise<void> {
688
- const printStatusLine = args[0] === "print-status-line";
689
- const inputArgs = printStatusLine ? args.slice(1) : args;
690
- let wrapCommand: string | undefined;
691
- let wrapRequested = false;
692
- let agentId: string | undefined;
693
- let stateDir: string | undefined;
694
- let standalone = false;
695
-
696
- for (let i = 0; i < inputArgs.length; i++) {
697
- const arg = inputArgs[i];
698
- if (arg === "--wrap") {
699
- wrapRequested = true;
700
- const next = inputArgs[i + 1];
701
- if (next && !next.startsWith("--")) {
702
- wrapCommand = next;
703
- i++;
704
- }
705
- } else if (arg === "--agent-id") {
706
- agentId = inputArgs[++i];
707
- } else if (arg === "--state-dir") {
708
- stateDir = inputArgs[++i] ?? stateDir;
709
- } else if (arg === "--standalone") {
710
- standalone = true;
711
- } else if (arg === "--help" || arg === "-h") {
712
- console.log("Usage: agent-relay context-probe [print-status-line] [--wrap COMMAND] [--agent-id ID] [--state-dir DIR] [--standalone]");
713
- return;
714
- } else {
715
- throw new Error(`Unknown context-probe option "${arg}"`);
716
- }
717
- }
718
-
719
- if (printStatusLine) {
720
- const command = [
721
- "agent-relay",
722
- "context-probe",
723
- ...(wrapRequested ? ["--wrap", ...(wrapCommand ? [shellQuote(wrapCommand)] : [])] : ["--standalone"]),
724
- ...(agentId ? ["--agent-id", shellQuote(agentId)] : []),
725
- ...(stateDir ? ["--state-dir", shellQuote(stateDir)] : []),
726
- ].join(" ");
727
- console.log(command);
728
- return;
729
- }
730
-
731
- if (wrapRequested && !wrapCommand) wrapCommand = currentClaudeStatusLineCommand();
732
- if (wrapCommand && commandLooksLikeContextProbe(wrapCommand)) {
733
- wrapCommand = undefined;
734
- standalone = true;
735
- }
736
- if (!wrapRequested && !standalone) {
737
- throw new Error("Usage: agent-relay context-probe [--wrap COMMAND|--standalone] [--agent-id ID] [--state-dir DIR]");
738
- }
739
-
740
- const input = await readStdin();
741
- const result = runContextProbe(input, { wrapCommand, agentId, stateDir, standalone });
742
- if (result.wrappedStderr) process.stderr.write(result.wrappedStderr);
743
- if (result.output) process.stdout.write(result.output);
744
- if (result.output && !result.output.endsWith("\n")) process.stdout.write("\n");
745
- if (typeof result.wrappedExitCode === "number" && result.wrappedExitCode !== 0) process.exit(result.wrappedExitCode);
746
- }
747
-
748
- function providerShimPaths(provider: "claude" | "codex"): string[] {
749
- const root = join(process.env.HOME || homedir(), ".agent-relay");
750
- const paths = [join(root, "bin", provider)];
751
- if (provider === "codex") paths.push(join(root, "codex", "bin", "codex"));
752
- return paths;
753
- }
754
-
755
- async function readStdin(): Promise<string> {
756
- if (typeof Bun !== "undefined" && Bun.stdin && typeof Bun.stdin.text === "function") {
757
- return Bun.stdin.text();
758
- }
759
-
760
- let value = "";
761
- for await (const chunk of process.stdin) value += String(chunk);
762
- return value;
763
- }
764
-
765
- function currentClaudeStatusLineCommand(): string | undefined {
766
- const settingsPath = join(process.env.HOME || homedir(), ".claude", "settings.json");
767
- try {
768
- const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as unknown;
769
- if (!settings || typeof settings !== "object" || Array.isArray(settings)) return undefined;
770
- const statusLine = (settings as Record<string, unknown>).statusLine;
771
- if (!statusLine || typeof statusLine !== "object" || Array.isArray(statusLine)) return undefined;
772
- const command = (statusLine as Record<string, unknown>).command;
773
- return typeof command === "string" && command.trim() ? command : undefined;
774
- } catch {
775
- return undefined;
776
- }
777
- }
778
-
779
- function commandLooksLikeContextProbe(command: string): boolean {
780
- return /\bagent-relay\s+context-probe\b/.test(command);
781
- }
782
-
783
- function providerShimContent(provider: "claude" | "codex"): string {
784
- if (provider === "claude") {
785
- return `#!/usr/bin/env bash\nexec claude-relay claude -- "$@"\n`;
786
- }
787
- return `#!/usr/bin/env bash
788
- set -e
789
-
790
- resolve_path() {
791
- if command -v realpath >/dev/null 2>&1; then
792
- realpath "$1" 2>/dev/null || printf '%s\\n' "$1"
793
- elif command -v readlink >/dev/null 2>&1; then
794
- readlink -f "$1" 2>/dev/null || printf '%s\\n' "$1"
795
- else
796
- printf '%s\\n' "$1"
797
- fi
798
- }
799
-
800
- find_real_codex() {
801
- local self resolved candidate
802
- self="$(resolve_path "$0")"
803
- IFS=: read -r -a path_parts <<< "$PATH"
804
- for dir in "\${path_parts[@]}"; do
805
- candidate="$dir/codex"
806
- [ -x "$candidate" ] || continue
807
- resolved="$(resolve_path "$candidate")"
808
- [ "$resolved" = "$self" ] && continue
809
- printf '%s\\n' "$candidate"
810
- return 0
811
- done
812
- return 1
813
- }
814
-
815
- first_command() {
816
- local skip_next=0
817
- for arg in "$@"; do
818
- if [ "$skip_next" = 1 ]; then
819
- skip_next=0
820
- continue
821
- fi
822
- case "$arg" in
823
- --) break ;;
824
- -c|--config|--enable|--disable|-i|--image|-m|--model|--local-provider|-p|--profile|-s|--sandbox|-C|--cd|--add-dir|-a|--ask-for-approval|--remote|--remote-auth-token-env)
825
- skip_next=1
826
- ;;
827
- --config=*|--enable=*|--disable=*|--image=*|--model=*|--local-provider=*|--profile=*|--sandbox=*|--cd=*|--add-dir=*|--ask-for-approval=*|--remote=*|--remote-auth-token-env=*)
828
- ;;
829
- -*)
830
- ;;
831
- *)
832
- printf '%s\\n' "$arg"
833
- return 0
834
- ;;
835
- esac
836
- done
837
- return 0
838
- }
839
-
840
- has_passthrough_option() {
841
- for arg in "$@"; do
842
- case "$arg" in
843
- -h|--help|-V|--version)
844
- return 0
845
- ;;
846
- esac
847
- done
848
- return 1
849
- }
850
-
851
- exec_real_codex() {
852
- real_codex="$(find_real_codex)" || {
853
- echo "agent-relay codex shim could not find the real codex binary on PATH" >&2
854
- exit 127
855
- }
856
- exec "$real_codex" "$@"
857
- }
858
-
859
- if has_passthrough_option "$@"; then
860
- exec_real_codex "$@"
861
- fi
862
-
863
- case "$(first_command "$@")" in
864
- exec|e|review|login|logout|mcp|plugin|mcp-server|app-server|remote-control|completion|update|sandbox|debug|apply|a|cloud|exec-server|features|help)
865
- exec_real_codex "$@"
866
- ;;
867
- esac
868
-
869
- exec codex-relay codex -- "$@"
870
- `;
871
- }
872
-
873
- async function handleSlashOrPairCommand(command: string, args: string[]): Promise<void> {
874
- if (command === "/disconnect") {
875
- await handlePairCommand(["hangup", ...args]);
876
- return;
877
- }
878
- if (command === "/pair") {
879
- await handlePairCommand(args);
880
- return;
881
- }
882
- await handlePairCommand(args);
883
- }
884
-
885
- async function handleSetupCommand(args: string[]): Promise<void> {
886
- let envFile: string | undefined;
887
- let host: string | undefined;
888
- let port: number | undefined;
889
- let dbPath: string | undefined;
890
- let runtimePrefix: string | undefined;
891
- let token: string | undefined;
892
- let generateToken = true;
893
- let dryRun = false;
894
- let force = false;
895
- let yes = false;
896
- let json = false;
897
-
898
- for (let i = 0; i < args.length; i++) {
899
- const arg = args[i];
900
- if (arg === "--env-file" && i + 1 < args.length) envFile = args[++i];
901
- else if (arg === "--host" && i + 1 < args.length) host = args[++i];
902
- else if (arg === "--port" && i + 1 < args.length) port = parseInt(args[++i]!, 10);
903
- else if (arg === "--db-path" && i + 1 < args.length) dbPath = args[++i];
904
- else if (arg === "--runtime-prefix" && i + 1 < args.length) runtimePrefix = args[++i];
905
- else if (arg === "--token" && i + 1 < args.length) token = args[++i];
906
- else if (arg === "--generate-token") generateToken = true;
907
- else if (arg === "--no-token") generateToken = false;
908
- else if (arg === "--dry-run") dryRun = true;
909
- else if (arg === "--force") force = true;
910
- else if (arg === "--yes") yes = true;
911
- else if (arg === "--json") json = true;
912
- else throw new Error(`Unknown setup option "${arg}"`);
913
- }
914
-
915
- const plan = createSetupPlan({
916
- ...(envFile ? { envFile } : {}),
917
- ...(host ? { host } : {}),
918
- ...(port !== undefined ? { port } : {}),
919
- ...(dbPath ? { dbPath } : {}),
920
- ...(runtimePrefix ? { runtimePrefix } : {}),
921
- ...(token ? { token } : {}),
922
- generateToken,
923
- force,
924
- });
925
-
926
- if (!dryRun && !yes && await pathExists(plan.envFile)) {
927
- const ok = await confirm(`Overwrite ${plan.envFile}?`);
928
- if (!ok) {
929
- console.log("Setup cancelled.");
930
- return;
931
- }
932
- }
933
-
934
- const result = await executeSetupPlan(plan, { dryRun, force });
935
- if (json) console.log(JSON.stringify({ plan, output: result }, null, 2));
936
- else console.log(dryRun ? formatSetupPlan(plan) : result);
937
- }
938
-
939
- async function handleDaemonCommand(args: string[]): Promise<void> {
940
- const action = parseDaemonAction(args[0]);
941
- let name: string | undefined;
942
- let envFile: string | undefined;
943
- let port: number | undefined;
944
- let host: string | undefined;
945
- let scope: DaemonScope | undefined;
946
- let binaryPath: string | undefined;
947
- let runtimePrefix: string | undefined;
948
- const pathPrefix: string[] = [];
949
- let start = false;
950
- let enable = false;
951
- let dryRun = false;
952
- let force = false;
953
- let yes = false;
954
- let json = false;
955
-
956
- for (let i = 1; i < args.length; i++) {
957
- const arg = args[i];
958
- if (arg === "--name" && i + 1 < args.length) name = args[++i];
959
- else if (arg === "--env-file" && i + 1 < args.length) envFile = args[++i];
960
- else if (arg === "--port" && i + 1 < args.length) port = parseInt(args[++i]!, 10);
961
- else if (arg === "--host" && i + 1 < args.length) host = args[++i];
962
- else if (arg === "--user") scope = "user";
963
- else if (arg === "--system") scope = "system";
964
- else if (arg === "--binary" && i + 1 < args.length) binaryPath = args[++i];
965
- else if (arg === "--runtime-prefix" && i + 1 < args.length) runtimePrefix = args[++i];
966
- else if (arg === "--path-prefix" && i + 1 < args.length) pathPrefix.push(args[++i]!);
967
- else if (arg === "--start") start = true;
968
- else if (arg === "--enable") enable = true;
969
- else if (arg === "--dry-run") dryRun = true;
970
- else if (arg === "--force") force = true;
971
- else if (arg === "--yes") yes = true;
972
- else if (arg === "--json") json = true;
973
- else throw new Error(`Unknown daemon option "${arg}"`);
974
- }
975
-
976
- if (action === "install" && !dryRun && !envFile && !(await pathExists(createSetupPlan().envFile))) {
977
- const setupPlan = createSetupPlan({
978
- ...(host ? { host } : {}),
979
- ...(port !== undefined ? { port } : {}),
980
- ...(runtimePrefix ? { runtimePrefix } : {}),
981
- });
982
- const ok = yes || await confirm(`Create daemon env file at ${setupPlan.envFile}?`);
983
- if (!ok) throw new Error("Daemon install needs an env file. Run `agent-relay setup` first.");
984
- console.log(await executeSetupPlan(setupPlan, { force }));
985
- }
986
-
987
- const env = await detectDaemonEnvironment();
988
- const plan = createDaemonPlan({
989
- action,
990
- ...(name ? { name } : {}),
991
- ...(envFile ? { envFile } : {}),
992
- ...(port !== undefined ? { port } : {}),
993
- ...(host ? { host } : {}),
994
- ...(scope ? { scope } : {}),
995
- ...(binaryPath ? { binaryPath } : {}),
996
- ...(runtimePrefix ? { runtimePrefix } : {}),
997
- ...(pathPrefix.length ? { pathPrefix } : {}),
998
- start,
999
- enable,
1000
- }, env);
1001
-
1002
- if (!dryRun && !json && (action === "install" || action === "uninstall") && !yes) {
1003
- const ok = await confirm(
1004
- action === "install"
1005
- ? `Install ${plan.kind} ${plan.scope} daemon "${plan.name}"?`
1006
- : `Uninstall daemon "${plan.name}"?`,
1007
- );
1008
- if (!ok) {
1009
- console.log("Daemon command cancelled.");
1010
- return;
1011
- }
1012
- }
1013
-
1014
- const result = await executeDaemonPlan(plan, { dryRun, force });
1015
- if (json) console.log(JSON.stringify({ plan: result.plan, output: result.output }, null, 2));
1016
- else console.log(dryRun ? formatDaemonPlan(plan) : result.output);
1017
- }
1018
-
1019
- async function handleOrchestratorCommand(args: string[]): Promise<void> {
1020
- const subcommand = args[0];
1021
- if (subcommand !== "install") {
1022
- throw new Error("Usage: agent-relay orchestrator install --relay-url URL --token TOKEN|--bootstrap-token TOKEN [options]");
1023
- }
1024
-
1025
- let relayUrl = "";
1026
- let token = "";
1027
- let bootstrapToken = "";
1028
- let id = sanitizeOrchestratorId(osHostname());
1029
- let baseDir = join(homedir(), "projects");
1030
- let providers = "claude,codex";
1031
- let apiPort = 4860;
1032
- let runtimePrefix = defaultRuntimePrefix();
1033
- let serviceName = "agent-relay-orchestrator";
1034
- let version = VERSION;
1035
- const pathPrefix: string[] = [];
1036
- let dryRun = false;
1037
- let yes = false;
1038
- let force = false;
1039
- let json = false;
1040
-
1041
- for (let i = 1; i < args.length; i++) {
1042
- const arg = args[i];
1043
- if (arg === "--relay-url" && i + 1 < args.length) relayUrl = args[++i]!;
1044
- else if (arg === "--token" && i + 1 < args.length) token = args[++i]!;
1045
- else if (arg === "--bootstrap-token" && i + 1 < args.length) bootstrapToken = args[++i]!;
1046
- else if (arg === "--id" && i + 1 < args.length) id = sanitizeOrchestratorId(args[++i]!);
1047
- else if (arg === "--base-dir" && i + 1 < args.length) baseDir = resolve(expandHomePath(args[++i]!));
1048
- else if (arg === "--providers" && i + 1 < args.length) providers = args[++i]!;
1049
- else if (arg === "--api-port" && i + 1 < args.length) apiPort = parseInt(args[++i]!, 10);
1050
- else if (arg === "--runtime-prefix" && i + 1 < args.length) runtimePrefix = resolve(expandHomePath(args[++i]!));
1051
- else if (arg === "--service-name" && i + 1 < args.length) serviceName = args[++i]!;
1052
- else if (arg === "--version" && i + 1 < args.length) version = args[++i]!;
1053
- else if (arg === "--path-prefix" && i + 1 < args.length) pathPrefix.push(resolve(expandHomePath(args[++i]!)));
1054
- else if (arg === "--dry-run") dryRun = true;
1055
- else if (arg === "--yes" || arg === "-y") yes = true;
1056
- else if (arg === "--force") force = true;
1057
- else if (arg === "--json") json = true;
1058
- else throw new Error(`Unknown orchestrator install option "${arg}"`);
1059
- }
1060
-
1061
- if (!relayUrl) throw new Error("--relay-url is required");
1062
- if (!token && !bootstrapToken) throw new Error("--token or --bootstrap-token is required");
1063
- if (!Number.isInteger(apiPort) || apiPort < 1 || apiPort > 65535) throw new Error("--api-port must be an integer from 1 to 65535");
1064
-
1065
- const providerList = providers.split(",").map((p) => p.trim()).filter(Boolean);
1066
- for (const provider of providerList) {
1067
- if (provider !== "claude" && provider !== "codex") throw new Error(`Unknown provider "${provider}". Expected claude or codex.`);
1068
- }
1069
- const detectedPathPrefix = [...pathPrefix, ...detectProviderPathPrefixes(providerList as Array<"claude" | "codex">)];
1070
- const uniquePathPrefix = [...new Set(detectedPathPrefix)];
1071
- const envToken = token || (dryRun || json ? "<runtime-token-from-bootstrap>" : await exchangeOrchestratorBootstrapToken(relayUrl, bootstrapToken, id, baseDir));
1072
- const envFile = defaultOrchestratorEnvFile();
1073
- const binaryPath = runtimeBinPath("agent-relay-orchestrator", runtimePrefix);
1074
- const envValues: Record<string, string> = {
1075
- AGENT_RELAY_URL: relayUrl,
1076
- AGENT_RELAY_TOKEN: envToken,
1077
- AGENT_RELAY_ORCHESTRATOR_ID: id,
1078
- AGENT_RELAY_ORCHESTRATOR_HOSTNAME: osHostname(),
1079
- AGENT_RELAY_ORCHESTRATOR_BASE_DIR: baseDir,
1080
- AGENT_RELAY_ORCHESTRATOR_PROVIDERS: providerList.join(","),
1081
- AGENT_RELAY_ORCHESTRATOR_API_PORT: String(apiPort),
1082
- };
1083
- if (uniquePathPrefix.length) envValues.AGENT_RELAY_ORCHESTRATOR_PATH_PREFIX = uniquePathPrefix.join(":");
1084
-
1085
- const daemonEnv = await detectDaemonEnvironment();
1086
- const daemonPlan = createDaemonPlan({
1087
- action: "install",
1088
- name: serviceName,
1089
- envFile,
1090
- binaryPath,
1091
- runtimePrefix,
1092
- pathPrefix: uniquePathPrefix,
1093
- enable: true,
1094
- start: true,
1095
- }, daemonEnv);
1096
- const packageCommand = ["npm", "install", "--prefix", runtimePrefix, `agent-relay-runner@${version}`, `agent-relay-orchestrator@${version}`];
1097
-
1098
- if (json || dryRun) {
1099
- const plan = {
1100
- id,
1101
- relayUrl,
1102
- baseDir,
1103
- providers: providerList,
1104
- apiPort,
1105
- runtimePrefix,
1106
- envFile,
1107
- binaryPath,
1108
- serviceName,
1109
- version,
1110
- pathPrefix: uniquePathPrefix,
1111
- packageCommand,
1112
- daemon: daemonPlan,
1113
- };
1114
- if (json) console.log(JSON.stringify({ plan }, null, 2));
1115
- else console.log(formatOrchestratorInstallPlan(plan));
1116
- return;
1117
- }
1118
-
1119
- if (!yes) {
1120
- const ok = await confirm(`Install orchestrator "${id}" as daemon "${serviceName}"?`);
1121
- if (!ok) {
1122
- console.log("Orchestrator install cancelled.");
1123
- return;
1124
- }
1125
- }
1126
-
1127
- mkdirSync(dirname(envFile), { recursive: true });
1128
- mkdirSync(baseDir, { recursive: true });
1129
- writeFileSync(envFile, renderEnvFile(envValues), { mode: 0o600 });
1130
- chmodSync(envFile, 0o600);
1131
- console.log(`Wrote ${envFile}`);
1132
-
1133
- runChecked(packageCommand);
1134
- const result = await executeDaemonPlan(daemonPlan, { force });
1135
- console.log(result.output);
1136
- console.log(`Orchestrator install complete. Verify in Relay: ${relayUrl}`);
1137
- }
1138
-
1139
- function defaultOrchestratorEnvFile(): string {
1140
- const platform = process.platform;
1141
- if (platform === "darwin") return join(homedir(), "Library", "Application Support", "agent-relay", "orchestrator.env");
1142
- return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "agent-relay", "orchestrator.env");
1143
- }
1144
-
1145
- async function exchangeOrchestratorBootstrapToken(relayUrl: string, bootstrapToken: string, id: string, baseDir: string): Promise<string> {
1146
- const response = await fetch(new URL("/api/orchestrators/bootstrap/exchange", relayUrl), {
1147
- method: "POST",
1148
- headers: {
1149
- "Content-Type": "application/json",
1150
- [RELAY_TOKEN_HEADER]: bootstrapToken,
1151
- },
1152
- body: JSON.stringify({ id, baseDir }),
1153
- });
1154
- const payload = await response.json().catch(() => null) as { token?: string; error?: string } | null;
1155
- if (!response.ok || !payload?.token) {
1156
- throw new Error(`bootstrap token exchange failed (${response.status}): ${payload?.error ?? response.statusText}`);
1157
- }
1158
- return payload.token;
1159
- }
1160
-
1161
- function sanitizeOrchestratorId(value: string): string {
1162
- const clean = value.trim().replace(/\./g, "-").replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
1163
- if (!clean) throw new Error("--id must contain at least one letter or number");
1164
- return clean.slice(0, 120);
1165
- }
1166
-
1167
- function expandHomePath(value: string): string {
1168
- if (value === "~") return homedir();
1169
- if (value.startsWith("~/")) return join(homedir(), value.slice(2));
1170
- return value;
1171
- }
1172
-
1173
- function detectProviderPathPrefixes(providers: Array<"claude" | "codex">): string[] {
1174
- const prefixes: string[] = [];
1175
- for (const provider of providers) {
1176
- const path = resolveExecutableOnPath(provider);
1177
- if (path) prefixes.push(dirname(path));
1178
- }
1179
- return prefixes;
1180
- }
1181
-
1182
- function resolveExecutableOnPath(command: string): string | undefined {
1183
- if (command.includes("/")) return existsSync(command) ? command : undefined;
1184
- for (const entry of (process.env.PATH ?? "").split(":")) {
1185
- if (!entry) continue;
1186
- const candidate = join(entry, command);
1187
- try {
1188
- if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
1189
- } catch {}
1190
- }
1191
- return undefined;
1192
- }
1193
-
1194
- function runChecked(command: string[]): void {
1195
- console.log(`$ ${command.map(shellQuote).join(" ")}`);
1196
- const result = Bun.spawnSync(command, { stdin: "ignore", stdout: "inherit", stderr: "inherit" });
1197
- if (result.exitCode !== 0) throw new Error(`${command[0]} exited with code ${result.exitCode}`);
1198
- }
1199
-
1200
- function formatOrchestratorInstallPlan(plan: {
1201
- id: string;
1202
- relayUrl: string;
1203
- baseDir: string;
1204
- providers: string[];
1205
- apiPort: number;
1206
- runtimePrefix: string;
1207
- envFile: string;
1208
- binaryPath: string;
1209
- serviceName: string;
1210
- version: string;
1211
- pathPrefix: string[];
1212
- packageCommand: string[];
1213
- daemon: ReturnType<typeof createDaemonPlan>;
1214
- }): string {
1215
- const lines = [
1216
- "Orchestrator install plan",
1217
- `ID: ${plan.id}`,
1218
- `Relay: ${plan.relayUrl}`,
1219
- `Base dir: ${plan.baseDir}`,
1220
- `Providers: ${plan.providers.join(", ") || "(none)"}`,
1221
- `API port: ${plan.apiPort}`,
1222
- `Runtime prefix: ${plan.runtimePrefix}`,
1223
- `Env file: ${plan.envFile}`,
1224
- `Binary: ${plan.binaryPath}`,
1225
- `Service: ${plan.serviceName}`,
1226
- `Package version: ${plan.version}`,
1227
- ];
1228
- if (plan.pathPrefix.length) lines.push(`PATH prefix: ${plan.pathPrefix.join(":")}`);
1229
- lines.push("", "Package command:", ` ${plan.packageCommand.join(" ")}`);
1230
- lines.push("", formatDaemonPlan(plan.daemon));
1231
- return lines.join("\n");
1232
- }
1233
-
1234
- function parseDaemonAction(value: string | undefined): DaemonAction {
1235
- if (!value || !DAEMON_ACTIONS.has(value as DaemonAction)) {
1236
- throw new Error("Usage: agent-relay daemon <install|uninstall|start|stop|restart|enable|disable|status|logs> [options]");
1237
- }
1238
- return value as DaemonAction;
1239
- }
1240
-
1241
- async function handleDevCommand(args: string[]): Promise<void> {
1242
- const action = args[0];
1243
- if (!action) throw new Error("Usage: agent-relay dev <pack|install|service|smoke> [options]");
1244
- if (action === "pack") {
1245
- let packages: string | undefined;
1246
- let outDir: string | undefined;
1247
- let dryRun = false;
1248
- let json = false;
1249
- for (let i = 1; i < args.length; i++) {
1250
- const arg = args[i];
1251
- if (arg === "--packages" && i + 1 < args.length) packages = args[++i];
1252
- else if (arg === "--out" && i + 1 < args.length) outDir = args[++i];
1253
- else if (arg === "--dry-run") dryRun = true;
1254
- else if (arg === "--json") json = true;
1255
- else throw new Error(`Unknown dev pack option "${arg}"`);
1256
- }
1257
- const plan = createDevPackPlan({
1258
- ...(packages ? { packages: parseDevPackages(packages) } : {}),
1259
- ...(outDir ? { outDir } : {}),
1260
- });
1261
- const result = await executeDevPackPlan(plan, { dryRun });
1262
- if (json) console.log(JSON.stringify(result, null, 2));
1263
- else console.log(dryRun ? formatDevPackPlan(plan) : result.output);
1264
- return;
1265
- }
1266
-
1267
- if (action === "install") {
1268
- let packages: string | undefined;
1269
- let outDir: string | undefined;
1270
- let prefix: string | undefined;
1271
- let dryRun = false;
1272
- let json = false;
1273
- for (let i = 1; i < args.length; i++) {
1274
- const arg = args[i];
1275
- if (arg === "--packages" && i + 1 < args.length) packages = args[++i];
1276
- else if (arg === "--out" && i + 1 < args.length) outDir = args[++i];
1277
- else if (arg === "--prefix" && i + 1 < args.length) prefix = args[++i];
1278
- else if (arg === "--dry-run") dryRun = true;
1279
- else if (arg === "--json") json = true;
1280
- else throw new Error(`Unknown dev install option "${arg}"`);
1281
- }
1282
- const plan = createDevInstallPlan({
1283
- ...(packages ? { packages: parseDevPackages(packages) } : {}),
1284
- ...(outDir ? { outDir } : {}),
1285
- ...(prefix ? { prefix } : {}),
1286
- });
1287
- const result = await executeDevInstallPlan(plan, { dryRun });
1288
- if (json) console.log(JSON.stringify(result, null, 2));
1289
- else console.log(dryRun ? formatDevInstallPlan(plan) : result.output);
1290
- return;
1291
- }
1292
-
1293
- if (action === "service") {
1294
- await handleDevServiceCommand(args.slice(1));
1295
- return;
1296
- }
1297
-
1298
- if (action === "smoke") {
1299
- let rootDir: string | undefined;
1300
- let providers: string | undefined;
1301
- let cwd: string | undefined;
1302
- let orchestratorId: string | undefined;
1303
- let timeoutMs: number | undefined;
1304
- for (let i = 1; i < args.length; i++) {
1305
- const arg = args[i];
1306
- if (arg === "--root" && i + 1 < args.length) rootDir = args[++i];
1307
- else if (arg === "--providers" && i + 1 < args.length) providers = args[++i];
1308
- else if (arg === "--cwd" && i + 1 < args.length) cwd = args[++i];
1309
- else if (arg === "--orchestrator" && i + 1 < args.length) orchestratorId = args[++i];
1310
- else if (arg === "--timeout" && i + 1 < args.length) timeoutMs = parseInt(args[++i]!, 10);
1311
- else throw new Error(`Unknown dev smoke option "${arg}"`);
1312
- }
1313
- const result = await executeDevSmoke({
1314
- ...(rootDir ? { rootDir } : {}),
1315
- ...(providers ? { providers } : {}),
1316
- ...(cwd ? { cwd } : {}),
1317
- ...(orchestratorId ? { orchestratorId } : {}),
1318
- ...(timeoutMs !== undefined ? { timeoutMs } : {}),
1319
- });
1320
- console.log(result.output);
1321
- return;
1322
- }
1323
-
1324
- throw new Error("Usage: agent-relay dev <pack|install|service|smoke> [options]");
1325
- }
1326
-
1327
- async function handleMemoryCommand(args: string[]): Promise<void> {
1328
- const section = args[0];
1329
- const action = args[1];
1330
- if (section !== "broker" || action !== "smoke") {
1331
- throw new Error("Usage: agent-relay memory broker smoke [--relay-url URL] [--token TOKEN] [--agent-id ID] [--scope SCOPE] [--tag TAG] [--no-cleanup] [--json]");
1332
- }
1333
- let baseUrl: string | undefined;
1334
- let token: string | undefined;
1335
- let agentId: string | undefined;
1336
- let scope: string | undefined;
1337
- let tag: string | undefined;
1338
- let cleanup = true;
1339
- let json = false;
1340
- for (let i = 2; i < args.length; i++) {
1341
- const arg = args[i];
1342
- if (arg === "--relay-url" && i + 1 < args.length) baseUrl = args[++i];
1343
- else if (arg === "--token" && i + 1 < args.length) token = args[++i];
1344
- else if (arg === "--agent-id" && i + 1 < args.length) agentId = args[++i];
1345
- else if (arg === "--scope" && i + 1 < args.length) scope = args[++i];
1346
- else if (arg === "--tag" && i + 1 < args.length) tag = args[++i];
1347
- else if (arg === "--no-cleanup") cleanup = false;
1348
- else if (arg === "--json") json = true;
1349
- else throw new Error(`Unknown memory broker smoke option "${arg}"`);
1350
- }
1351
- const result = await runMemoryBrokerSmoke({
1352
- ...(baseUrl ? { baseUrl } : {}),
1353
- ...(token ? { token } : {}),
1354
- ...(agentId ? { agentId } : {}),
1355
- ...(scope ? { scope } : {}),
1356
- ...(tag ? { tag } : {}),
1357
- cleanup,
1358
- });
1359
- console.log(json ? JSON.stringify(result, null, 2) : formatMemoryBrokerSmokeResult(result));
1360
- }
1361
-
1362
- async function handleDevServiceCommand(args: string[]): Promise<void> {
1363
- const action = parseDevServiceAction(args[0]);
1364
- let prefix: string | undefined;
1365
- let rootDir: string | undefined;
1366
- let port: number | undefined;
1367
- let apiPort: number | undefined;
1368
- let baseDir: string | undefined;
1369
- let orchestratorId: string | undefined;
1370
- let token: string | undefined;
1371
- let start = false;
1372
- let enable = false;
1373
- let dryRun = false;
1374
- let force = false;
1375
- let yes = false;
1376
- let json = false;
1377
-
1378
- for (let i = 1; i < args.length; i++) {
1379
- const arg = args[i];
1380
- if (arg === "--prefix" && i + 1 < args.length) prefix = args[++i];
1381
- else if (arg === "--root" && i + 1 < args.length) rootDir = args[++i];
1382
- else if (arg === "--port" && i + 1 < args.length) port = parseInt(args[++i]!, 10);
1383
- else if (arg === "--api-port" && i + 1 < args.length) apiPort = parseInt(args[++i]!, 10);
1384
- else if (arg === "--base-dir" && i + 1 < args.length) baseDir = args[++i];
1385
- else if (arg === "--orchestrator-id" && i + 1 < args.length) orchestratorId = args[++i];
1386
- else if (arg === "--token" && i + 1 < args.length) token = args[++i];
1387
- else if (arg === "--start") start = true;
1388
- else if (arg === "--enable") enable = true;
1389
- else if (arg === "--dry-run") dryRun = true;
1390
- else if (arg === "--force") force = true;
1391
- else if (arg === "--yes") yes = true;
1392
- else if (arg === "--json") json = true;
1393
- else throw new Error(`Unknown dev service option "${arg}"`);
1394
- }
1395
-
1396
- const root = rootDir ?? defaultDevRoot();
1397
- const plan = createDevServicePlan({
1398
- action,
1399
- ...(prefix ? { prefix } : {}),
1400
- rootDir: root,
1401
- ...(port !== undefined ? { port } : {}),
1402
- ...(apiPort !== undefined ? { apiPort } : {}),
1403
- ...(baseDir ? { baseDir } : {}),
1404
- ...(orchestratorId ? { orchestratorId } : {}),
1405
- ...(token ? { token } : {}),
1406
- start,
1407
- enable,
1408
- });
1409
-
1410
- if (!dryRun && !json && (action === "install" || action === "uninstall") && !yes) {
1411
- const ok = await confirm(action === "install" ? `Install dev service profile under ${root}?` : `Uninstall dev service profile under ${root}?`);
1412
- if (!ok) {
1413
- console.log("Dev service command cancelled.");
1414
- return;
1415
- }
1416
- }
1417
-
1418
- const result = await executeDevServicePlan(plan, { dryRun, force });
1419
- if (json) console.log(JSON.stringify({ plan: result.plan, output: result.output }, null, 2));
1420
- else console.log(dryRun ? formatDevServicePlan(plan) : result.output);
1421
- }
1422
-
1423
- function parseDevServiceAction(value: string | undefined): DevServiceAction {
1424
- const allowed = new Set<DevServiceAction>(["install", "uninstall", "start", "stop", "restart", "status", "logs"]);
1425
- if (!value || !allowed.has(value as DevServiceAction)) {
1426
- throw new Error("Usage: agent-relay dev service <install|uninstall|start|stop|restart|status|logs> [options]");
1427
- }
1428
- return value as DevServiceAction;
1429
- }
1430
-
1431
- async function handlePairCommand(args: string[]): Promise<void> {
1432
- if (!args.length) throw new Error("Usage: agent-relay pair <target|create|status|accept|reject|hangup|send> [options]");
1433
- const knownActions = new Set(["create", "status", "list", "accept", "reject", "hangup", "disconnect", "send"]);
1434
- const action = knownActions.has(args[0]!) ? args[0]! : "create";
1435
- const rest = action === "create" && args[0] !== "create" ? args : args.slice(1);
1436
-
1437
- if (action === "status" || action === "list") {
1438
- let agent: string | undefined = await detectAgentId();
1439
- let status: string | undefined;
1440
- let json = false;
1441
- for (let i = 0; i < rest.length; i++) {
1442
- const arg = rest[i];
1443
- if (arg === "--agent" && i + 1 < rest.length) agent = rest[++i];
1444
- else if (arg === "--status" && i + 1 < rest.length) status = rest[++i];
1445
- else if (arg === "--json") json = true;
1446
- else throw new Error(`Unknown pair status option "${arg}"`);
1447
- }
1448
- const query = new URLSearchParams();
1449
- if (agent) query.set("agent", agent);
1450
- if (status) query.set("status", status);
1451
- const pairs = await apiRequest("GET", `/api/pairs${query.size ? `?${query}` : ""}`);
1452
- if (json) console.log(JSON.stringify(pairs, null, 2));
1453
- else console.log(formatPairs(pairs as any[]));
1454
- return;
1455
- }
1456
-
1457
- if (action === "create") {
1458
- const target = rest[0];
1459
- if (!target || target.startsWith("--")) throw new Error("Usage: agent-relay pair <target> [--from AGENT_ID] [--objective TEXT]");
1460
- let from = await detectAgentId();
1461
- let objective: string | undefined;
1462
- let ttlMs: number | undefined;
1463
- let json = false;
1464
- const objectiveParts: string[] = [];
1465
- for (let i = 1; i < rest.length; i++) {
1466
- const arg = rest[i];
1467
- if (arg === "--from" && i + 1 < rest.length) from = rest[++i];
1468
- else if (arg === "--objective" && i + 1 < rest.length) objective = rest[++i];
1469
- else if (arg === "--ttl-ms" && i + 1 < rest.length) ttlMs = parseInt(rest[++i]!, 10);
1470
- else if (arg === "--json") json = true;
1471
- else objectiveParts.push(arg!);
1472
- }
1473
- objective ??= objectiveParts.length ? objectiveParts.join(" ") : undefined;
1474
- if (!from) throw new Error("Could not detect current Agent Relay ID. Pass --from AGENT_ID or set AGENT_RELAY_ID.");
1475
- const result = await apiRequest("POST", "/api/pairs", { from, target, objective, ttlMs });
1476
- if (json) console.log(JSON.stringify(result, null, 2));
1477
- else {
1478
- const pair = (result as any).pair;
1479
- console.log(`Pair invite ${pair.id} sent: ${pair.requesterId} -> ${pair.targetId}`);
1480
- }
1481
- return;
1482
- }
1483
-
1484
- if (action === "accept" || action === "reject" || action === "hangup" || action === "disconnect") {
1485
- const pairId = rest[0];
1486
- let agentId = await detectAgentId();
1487
- let reason: string | undefined;
1488
- let json = false;
1489
- let startIndex = 0;
1490
- if (pairId && !pairId.startsWith("--")) startIndex = 1;
1491
- for (let i = startIndex; i < rest.length; i++) {
1492
- const arg = rest[i];
1493
- if (arg === "--agent" && i + 1 < rest.length) agentId = rest[++i];
1494
- else if (arg === "--reason" && i + 1 < rest.length) reason = rest[++i];
1495
- else if (arg === "--json") json = true;
1496
- else throw new Error(`Unknown pair ${action} option "${arg}"`);
1497
- }
1498
- if (!agentId) throw new Error("Could not detect current Agent Relay ID. Pass --agent AGENT_ID or set AGENT_RELAY_ID.");
1499
- const resolvedPairId = pairId && !pairId.startsWith("--") ? pairId : await detectActivePairId(agentId);
1500
- if (!resolvedPairId) throw new Error(`Usage: agent-relay pair ${action} PAIR_ID --agent AGENT_ID`);
1501
- const endpoint = action === "disconnect" ? "hangup" : action;
1502
- const pair = await apiRequest("POST", `/api/pairs/${encodeURIComponent(resolvedPairId)}/${endpoint}`, { agentId, reason });
1503
- if (json) console.log(JSON.stringify(pair, null, 2));
1504
- else console.log(`Pair ${resolvedPairId}: ${(pair as any).status}`);
1505
- return;
1506
- }
1507
-
1508
- if (action === "send") {
1509
- const pairId = rest[0];
1510
- if (!pairId || pairId.startsWith("--")) throw new Error("Usage: agent-relay pair send PAIR_ID --from AGENT_ID --body TEXT");
1511
- let from = await detectAgentId();
1512
- let body: string | undefined;
1513
- let subject: string | undefined;
1514
- let json = false;
1515
- for (let i = 1; i < rest.length; i++) {
1516
- const arg = rest[i];
1517
- if (arg === "--from" && i + 1 < rest.length) from = rest[++i];
1518
- else if (arg === "--body" && i + 1 < rest.length) body = rest[++i];
1519
- else if (arg === "--subject" && i + 1 < rest.length) subject = rest[++i];
1520
- else if (arg === "--json") json = true;
1521
- else throw new Error(`Unknown pair send option "${arg}"`);
1522
- }
1523
- if (!from) throw new Error("Could not detect current Agent Relay ID. Pass --from AGENT_ID or set AGENT_RELAY_ID.");
1524
- if (!body) throw new Error("--body TEXT required");
1525
- const result = await apiRequest("POST", `/api/pairs/${encodeURIComponent(pairId)}/messages`, { from, body, subject });
1526
- if (json) console.log(JSON.stringify(result, null, 2));
1527
- else console.log(`Pair message sent: ${(result as any).message.id}`);
1528
- return;
1529
- }
1530
- }
1531
-
1532
- // The agent's own isolated-workspace id, published in AGENT_RELAY_WORKSPACE_JSON
1533
- // by the orchestrator at spawn. Undefined for shared-workspace / non-managed agents.
1534
- function currentWorkspaceId(): string | undefined {
1535
- const json = process.env.AGENT_RELAY_WORKSPACE_JSON;
1536
- if (!json) return undefined;
1537
- try {
1538
- const parsed = JSON.parse(json) as { id?: string };
1539
- return typeof parsed.id === "string" && parsed.id ? parsed.id : undefined;
1540
- } catch {
1541
- return undefined;
1542
- }
1543
- }
1544
-
1545
- function formatWorkspaceStatus(ws: any, extra?: { guidance?: WorkspacePhaseView; landed?: string | null }): string {
1546
- // Render the directive projection so the agent gets "what does this mean / what
1547
- // do I do next" inline, not a bare enum it has to decode (#235). Computed
1548
- // client-side from the record (the projection is pure) unless the wait response
1549
- // already carried it.
1550
- const guidance = extra?.guidance ?? describeWorkspacePhase(ws);
1551
- const lines = [
1552
- `Workspace ${ws.id}`,
1553
- ` status: ${ws.status} (${guidance.phase}${guidance.actionNeeded ? "" : " — no action needed"})`,
1554
- ` branch: ${ws.branch ?? "(none)"}`,
1555
- ` base: ${ws.baseRef ?? "(none)"}`,
1556
- ` worktree: ${ws.worktreePath ?? "(none)"}`,
1557
- "",
1558
- ` ${guidance.headline}`,
1559
- ` ${guidance.hint}`,
1560
- ];
1561
- if (guidance.blockers.length) {
1562
- lines.push("", " Blockers:");
1563
- for (const b of guidance.blockers) lines.push(` - ${b}`);
1564
- }
1565
- if (guidance.nextActions.length) {
1566
- lines.push("", " Next:");
1567
- for (const a of guidance.nextActions) lines.push(` - ${a.cli ?? a.tool} — ${a.when}`);
1568
- }
1569
- if (extra?.landed) lines.push("", ` ${extra.landed}`);
1570
- return lines.join("\n");
1571
- }
1572
-
1573
- // Poll a command to a terminal state (succeeded/failed). Returns undefined on
1574
- // timeout so the caller can degrade to "dispatched, check later".
1575
- async function pollCommand(id: string, timeoutMs: number): Promise<{ status?: string; result?: unknown; error?: string } | undefined> {
1576
- const deadline = Date.now() + timeoutMs;
1577
- while (Date.now() < deadline) {
1578
- const cmd = await apiRequest("GET", `/api/commands/${encodeURIComponent(id)}`) as { status?: string; result?: unknown; error?: string };
1579
- if (cmd.status === "succeeded" || cmd.status === "failed") return cmd;
1580
- await new Promise((r) => setTimeout(r, 1000));
1581
- }
1582
- return undefined;
1583
- }
1584
-
1585
- function formatDepsRefresh(result: WorkspaceDepsRefreshResult, checkOnly: boolean): string {
1586
- if (result.error && (!result.dirs || result.dirs.length === 0)) return `Deps ${checkOnly ? "check" : "refresh"}: ${result.error}`;
1587
- const lines: string[] = [];
1588
- for (const d of result.dirs) {
1589
- const icon = d.status === "installed" ? "↻" : d.status === "stale" ? "✗" : d.status === "failed" ? "!" : "✓";
1590
- const detail = d.status === "ok" ? "up to date"
1591
- : d.status === "installed" ? `reinstalled${d.wasSymlink ? " (was symlinked)" : ""}`
1592
- : d.status === "stale" ? `stale — missing ${d.missing?.join(", ") ?? "?"}`
1593
- : `failed — ${d.error ?? "unknown"}`;
1594
- lines.push(` ${icon} ${d.dir}: ${detail}`);
1595
- }
1596
- const header = checkOnly
1597
- ? (result.stale ? "Deps check: stale dirs found — run `agent-relay workspace deps` to refresh" : "Deps check: all dirs up to date")
1598
- : (result.refreshed ? "Deps refreshed" : result.error ? "Deps refresh hit errors" : "Deps already up to date");
1599
- return [header, ...lines].join("\n");
1600
- }
1601
-
1602
- // Self-service workspace lifecycle for agents in isolated worktrees (#205) plus
1603
- // steward coordination (#208).
1604
- // status — read your workspace row ready — hand off for review/landing
1605
- // land — request a base merge (operator) list — all workspaces
1606
- // diagnostics — joined briefing + recommended action
1607
- // claim/release — TTL'd steward lease auto-merge yields to
1608
- // cleanup-stale — guarded batch cleanup of stale worktrees (dry-run by default)
1609
- async function handleWorkspaceCommand(args: string[]): Promise<void> {
1610
- const action = args[0];
1611
- const valid = new Set(["status", "ready", "land", "list", "diagnostics", "diag", "claim", "release", "cleanup-stale", "deps"]);
1612
- if (action === "--help" || action === "-h" || action === "help") {
1613
- console.log(WORKSPACE_USAGE);
1614
- return;
1615
- }
1616
- if (!action || !valid.has(action)) {
1617
- throw new Error(WORKSPACE_USAGE);
1618
- }
1619
-
1620
- let id = currentWorkspaceId(), idExplicit = false; // idExplicit: --id was passed, not the ambient default (#307)
1621
- let strategy: string | undefined;
1622
- let purpose: string | undefined;
1623
- let repo: string | undefined;
1624
- let execute = false;
1625
- let check = false;
1626
- let json = false;
1627
- let wait = false;
1628
- let timeoutSeconds: number | undefined;
1629
- for (let i = 1; i < args.length; i++) {
1630
- const arg = args[i];
1631
- if (arg === "--id" && i + 1 < args.length) { id = args[++i]; idExplicit = true; }
1632
- else if (arg === "--strategy" && i + 1 < args.length) strategy = args[++i];
1633
- else if (arg === "--purpose" && i + 1 < args.length) purpose = args[++i];
1634
- else if (arg === "--repo" && i + 1 < args.length) repo = args[++i];
1635
- else if (arg === "--execute") execute = true;
1636
- else if (arg === "--check") check = true;
1637
- else if (arg === "--refresh") check = false; // explicit no-op default for clarity
1638
- else if (arg === "--wait") wait = true;
1639
- else if (arg === "--timeout" && i + 1 < args.length) {
1640
- const parsed = Number.parseInt(args[++i]!, 10);
1641
- if (!Number.isFinite(parsed) || parsed <= 0) throw new Error("--timeout must be a positive number of seconds");
1642
- timeoutSeconds = parsed;
1643
- }
1644
- else if (arg === "--json") json = true;
1645
- else throw new Error(`Unknown workspace option "${arg}".`);
1646
- }
1647
-
1648
- if (action === "list") {
1649
- console.log(JSON.stringify(await apiRequest("GET", "/api/workspaces"), null, 2));
1650
- return;
1651
- }
1652
-
1653
- if (action === "cleanup-stale") {
1654
- const result = await apiRequest("POST", "/api/workspaces/actions/cleanup-stale", { repoRoot: repo, dryRun: !execute, ...(idExplicit && id ? { workspaceId: id } : {}) });
1655
- console.log(JSON.stringify(result, null, 2));
1656
- return;
1657
- }
1658
-
1659
- if (!id) throw new Error("No current workspace detected (AGENT_RELAY_WORKSPACE_JSON unset). Pass --id WORKSPACE_ID — only isolated-workspace agents have one.");
1660
-
1661
- if (action === "status") {
1662
- // --wait long-polls via the action endpoint (server blocks until the status
1663
- // changes — the blessed way to wait for an auto-merge to land, #235), and the
1664
- // response carries the directive projection + land receipt. Plain status is a
1665
- // bare GET; the projection is computed client-side for rendering.
1666
- if (wait) {
1667
- const res = await apiRequest("POST", `/api/workspaces/${encodeURIComponent(id)}/actions`, {
1668
- action: "status",
1669
- wait: true,
1670
- ...(timeoutSeconds ? { timeoutSeconds } : {}),
1671
- }) as { workspace?: any; guidance?: WorkspacePhaseView; landed?: string | null };
1672
- if (json) { console.log(JSON.stringify(res, null, 2)); return; }
1673
- console.log(formatWorkspaceStatus(res.workspace ?? res, { guidance: res.guidance, landed: res.landed }));
1674
- return;
1675
- }
1676
- const ws = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}`);
1677
- if (json) console.log(JSON.stringify(ws, null, 2));
1678
- else console.log(formatWorkspaceStatus(ws));
1679
- return;
1680
- }
1681
-
1682
- if (action === "diagnostics" || action === "diag") {
1683
- console.log(JSON.stringify(await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diagnostics`), null, 2));
1684
- return;
1685
- }
1686
-
1687
- // Refresh (or --check) deps the shared symlinked node_modules has gone stale on
1688
- // (#51). Emits a host command; poll it to a terminal state so the agent gets a
1689
- // synchronous result and knows when to re-run typecheck.
1690
- if (action === "deps") {
1691
- const from = await detectAgentId();
1692
- const res = await apiRequest("POST", `/api/workspaces/${encodeURIComponent(id)}/actions`, { action: "deps-refresh", agentId: from, checkOnly: check }) as { command?: { id?: string } };
1693
- const commandId = res.command?.id;
1694
- const settled = commandId ? await pollCommand(commandId, 180_000) : undefined;
1695
- const result = (settled?.result ?? null) as WorkspaceDepsRefreshResult | null;
1696
- if (json) {
1697
- console.log(JSON.stringify(settled ?? res, null, 2));
1698
- return;
1699
- }
1700
- if (settled?.status === "failed") {
1701
- console.error(`Deps ${check ? "check" : "refresh"} failed: ${settled.error ?? "unknown error"}`);
1702
- process.exitCode = 1;
1703
- return;
1704
- }
1705
- if (!result) {
1706
- console.log(`Deps ${check ? "check" : "refresh"} dispatched (command ${commandId ?? "?"}) — host did not report back in time. Check \`agent-relay workspace deps --json\`.`);
1707
- return;
1708
- }
1709
- console.log(formatDepsRefresh(result, check));
1710
- return;
1711
- }
1712
-
1713
- const from = await detectAgentId();
1714
- const actionBody: Record<string, unknown> =
1715
- action === "ready" ? { action: "request-review", agentId: from }
1716
- : action === "claim" ? { action: "claim", agentId: from, purpose }
1717
- : action === "release" ? { action: "release-claim", agentId: from }
1718
- : { action: "merge", agentId: from, ...(strategy ? { strategy } : {}) };
1719
- const result = await apiRequest("POST", `/api/workspaces/${encodeURIComponent(id)}/actions`, actionBody);
1720
- if (json) {
1721
- console.log(JSON.stringify(result, null, 2));
1722
- return;
1723
- }
1724
- if (action === "ready") {
1725
- // Print the whole contract up front so the agent isn't left decoding status
1726
- // enums over the next minutes (#235). `result.workspace` is the post-ready row.
1727
- const ws = (result as { workspace?: any }).workspace ?? { status: "review_requested" };
1728
- console.log(`Workspace ${id} marked ready.\n\n${readyContract(ws)}`);
1729
- return;
1730
- }
1731
- console.log(
1732
- action === "claim" ? `Workspace ${id} claimed${purpose ? ` (${purpose})` : ""} — auto-merge will yield until released or the claim expires.`
1733
- : action === "release" ? `Workspace ${id} claim released.`
1734
- : `Workspace ${id} merge requested (${strategy ?? "auto"}).`,
1735
- );
1736
- }
1737
-
1738
- // Steward briefing commands (#208): queue of workspaces needing attention, a
1739
- // per-workspace diagnostics inspection, and a check-command suggestion.
1740
- async function handleStewardCommand(args: string[]): Promise<void> {
1741
- const action = args[0];
1742
- if (!action || !["queue", "inspect", "checks"].includes(action)) {
1743
- throw new Error("Usage: agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]");
1744
- }
1745
-
1746
- let repo: string | undefined;
1747
- let json = false;
1748
- const positional: string[] = [];
1749
- for (let i = 1; i < args.length; i++) {
1750
- const arg = args[i];
1751
- if (arg === "--repo" && i + 1 < args.length) repo = args[++i];
1752
- else if (arg === "--json") json = true;
1753
- else if (!arg!.startsWith("--")) positional.push(arg!);
1754
- else throw new Error(`Unknown steward option "${arg}".`);
1755
- }
1756
-
1757
- if (action === "queue") {
1758
- const all = await apiRequest("GET", "/api/workspaces") as any[];
1759
- const attention = new Set(["conflict", "review_requested", "merge_planned"]);
1760
- const queue = all.filter((ws) => attention.has(ws.status) && (!repo || ws.repoRoot === repo));
1761
- if (json) { console.log(JSON.stringify(queue, null, 2)); return; }
1762
- if (!queue.length) { console.log("Steward queue empty — no workspaces awaiting review, merge, or conflict resolution."); return; }
1763
- for (const ws of queue) console.log(`${ws.status.padEnd(16)} ${ws.branch ?? ws.id} (${ws.repoRoot})`);
1764
- return;
1765
- }
1766
-
1767
- const id = positional[0];
1768
- if (!id) throw new Error(`Usage: agent-relay steward ${action} WORKSPACE_ID [--json]`);
1769
-
1770
- if (action === "inspect") {
1771
- console.log(JSON.stringify(await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diagnostics`), null, 2));
1772
- return;
1773
- }
1774
-
1775
- // checks: suggest validation commands from the workspace's changed files.
1776
- const diff = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diff?patch=0`) as any;
1777
- const files: string[] = Array.isArray(diff?.files) ? diff.files.map((f: any) => f.path) : [];
1778
- const checks = suggestStewardChecks(files);
1779
- console.log(JSON.stringify({ workspaceId: id, changedFiles: files.length, checks }, null, 2));
1780
- }
1781
-
1782
- // Heuristic check suggestions from changed file paths. Repo-agnostic defaults a
1783
- // steward can refine; cheaper than re-deriving from project docs every run.
1784
- function suggestStewardChecks(files: string[]): Array<{ command: string; reason: string }> {
1785
- const checks: Array<{ command: string; reason: string }> = [];
1786
- const has = (re: RegExp) => files.some((f) => re.test(f));
1787
- if (has(/\.(ts|tsx|mts|cts)$/)) checks.push({ command: "bun run typecheck", reason: "TypeScript files changed" });
1788
- if (has(/\.test\.|(^|\/)tests?\//)) checks.push({ command: "bun test", reason: "test files changed" });
1789
- else if (files.length) checks.push({ command: "bun test", reason: "repo default" });
1790
- if (has(/(^|\/)dashboard\//)) checks.push({ command: "bun run build:dashboard", reason: "dashboard sources changed" });
1791
- return checks;
1792
- }
1793
-
1794
- async function handleMessageCommand(args: string[], defaults: { claimable?: boolean } = {}): Promise<void> {
1795
- const target = args[0];
1796
- if (!target || target.startsWith("--")) {
1797
- throw new Error("Usage: agent-relay message <target> <body> [--from AGENT_ID] [--subject TEXT] [--channel NAME] [--reply-to ID] [--claimable]");
1798
- }
1799
-
1800
- let from = await detectAgentId();
1801
- let subject: string | undefined;
1802
- let channel: string | undefined;
1803
- let replyTo: number | undefined;
1804
- let idempotencyKey: string | undefined;
1805
- let json = false;
1806
- let claimable = defaults.claimable ?? false;
1807
- const bodyParts: string[] = [];
1808
-
1809
- for (let i = 1; i < args.length; i++) {
1810
- const arg = args[i];
1811
- if (arg === "--from" && i + 1 < args.length) from = args[++i];
1812
- else if (arg === "--subject" && i + 1 < args.length) subject = args[++i];
1813
- else if (arg === "--channel" && i + 1 < args.length) channel = args[++i];
1814
- else if (arg === "--reply-to" && i + 1 < args.length) {
1815
- const parsed = Number.parseInt(args[++i]!, 10);
1816
- if (!Number.isFinite(parsed) || parsed <= 0) throw new Error("--reply-to must be a positive message id");
1817
- replyTo = parsed;
1818
- } else if (arg === "--idempotency-key" && i + 1 < args.length) idempotencyKey = args[++i];
1819
- else if (arg === "--claimable") claimable = true;
1820
- else if (arg === "--json") json = true;
1821
- else bodyParts.push(arg!);
1822
- }
1823
-
1824
- const body = bodyParts.join(" ").trim();
1825
- if (!from) throw new Error("Could not detect current Agent Relay ID. Pass --from AGENT_ID or set AGENT_RELAY_ID.");
1826
- if (!body) throw new Error("Message body required.");
1827
-
1828
- const message = await apiRequest("POST", "/api/messages", {
1829
- from,
1830
- to: target,
1831
- kind: claimable ? "task" : "chat",
1832
- subject,
1833
- channel,
1834
- body,
1835
- replyTo,
1836
- claimable,
1837
- payload: claimable ? { title: subject || "Claimable task" } : undefined,
1838
- idempotencyKey,
1839
- });
1840
- if (json) console.log(JSON.stringify(message, null, 2));
1841
- else console.log(`${claimable ? "Claimable message" : "Message"} sent: ${(message as any).id} -> ${target}`);
1842
- }
1843
-
1844
- async function handleGetMessageCommand(args: string[]): Promise<void> {
1845
- const msgIdRaw = args[0];
1846
- if (!msgIdRaw || msgIdRaw.startsWith("--")) {
1847
- throw new Error("Usage: agent-relay get-message <messageId> [--json] [--body]");
1848
- }
1849
- const messageId = Number.parseInt(msgIdRaw, 10);
1850
- if (!Number.isFinite(messageId) || messageId <= 0) throw new Error("messageId must be a positive integer");
1851
-
1852
- let json = false;
1853
- let bodyOnly = false;
1854
- for (let i = 1; i < args.length; i++) {
1855
- const arg = args[i];
1856
- if (arg === "--json") json = true;
1857
- else if (arg === "--body") bodyOnly = true;
1858
- else throw new Error(`Unknown get-message option "${arg}".`);
1859
- }
1860
-
1861
- const [message, artifacts, thread] = await Promise.all([
1862
- apiRequest("GET", `/api/messages/${messageId}`),
1863
- apiRequest("GET", `/api/messages/${messageId}/artifacts`).catch(() => []),
1864
- apiRequest("GET", `/api/messages/${messageId}/thread`).catch(() => []),
1865
- ]);
1866
- if (bodyOnly) {
1867
- console.log(String((message as any).body ?? ""));
1868
- return;
1869
- }
1870
-
1871
- const msg = message as any;
1872
- const attachments = Array.isArray(msg.payload?.attachments)
1873
- ? msg.payload.attachments
1874
- : Array.isArray(msg.attachments)
1875
- ? msg.attachments
1876
- : [];
1877
- const payload = {
1878
- id: msg.id,
1879
- from: msg.from,
1880
- to: msg.to,
1881
- kind: msg.kind,
1882
- body: msg.body,
1883
- subject: msg.subject,
1884
- channel: msg.channel,
1885
- replyToId: msg.replyTo,
1886
- threadId: msg.threadId,
1887
- createdAt: msg.createdAt,
1888
- payload: msg.payload,
1889
- meta: msg.meta,
1890
- attachments,
1891
- message,
1892
- artifacts,
1893
- thread,
1894
- replyTo: msg.replyTo ? (thread as any[]).find((item) => item.id === msg.replyTo) ?? null : null,
1895
- };
1896
- if (json) {
1897
- console.log(JSON.stringify(payload, null, 2));
1898
- return;
1899
- }
1900
- console.log(formatMessageDetails(payload));
1901
- }
1902
-
1903
- async function handleReplyCommand(args: string[]): Promise<void> {
1904
- const msgIdRaw = args[0];
1905
- if (!msgIdRaw || msgIdRaw.startsWith("--")) {
1906
- throw new Error("Usage: agent-relay /reply <messageId> <body|--stdin|--body-file PATH> [--from AGENT_ID] [--subject TEXT] [--format text|markdown|markdownv2] [--json]");
1907
- }
1908
- const replyTo = Number.parseInt(msgIdRaw, 10);
1909
- if (!Number.isFinite(replyTo) || replyTo <= 0) throw new Error("messageId must be a positive integer");
1910
-
1911
- let from = await detectAgentId();
1912
- let subject: string | undefined;
1913
- let format: string | undefined;
1914
- let json = false;
1915
- let stdinBody = false;
1916
- let bodyFile: string | undefined;
1917
- const bodyParts: string[] = [];
1918
-
1919
- for (let i = 1; i < args.length; i++) {
1920
- const arg = args[i];
1921
- if (arg === "--from" && i + 1 < args.length) from = args[++i];
1922
- else if (arg === "--subject" && i + 1 < args.length) subject = args[++i];
1923
- else if (arg === "--stdin") stdinBody = true;
1924
- else if ((arg === "--body-file" || arg === "--file") && i + 1 < args.length) bodyFile = args[++i];
1925
- else if (arg === "--format" && i + 1 < args.length) {
1926
- const parsed = parseReplyFormat(args[++i]!);
1927
- if (!parsed) throw new Error("--format must be text, markdown, or markdownv2");
1928
- format = parsed;
1929
- }
1930
- else if (arg === "--json") json = true;
1931
- else bodyParts.push(arg!);
1932
- }
1933
-
1934
- const body = (await resolveBodyInput({ bodyParts, stdinBody, bodyFile })).trim();
1935
- if (!from) throw new Error("Could not detect current Agent Relay ID. Pass --from AGENT_ID or set AGENT_RELAY_ID.");
1936
- if (!body) throw new Error("Reply body required.");
1937
-
1938
- const prepared = await prepareReplyBody({ body, from, replyTo, format, subject });
1939
- const message = await apiRequest("POST", "/api/messages", {
1940
- from,
1941
- body: prepared.body,
1942
- subject: prepared.subject,
1943
- replyTo,
1944
- attachments: prepared.attachments,
1945
- payload: prepared.payload,
1946
- });
1947
- const msg = message as any;
1948
- if (json) console.log(JSON.stringify(msg, null, 2));
1949
- else console.log(`Reply sent: ${msg.id} -> ${msg.to} (reply to #${replyTo})`);
1950
- }
1951
-
1952
- async function handleReactCommand(args: string[]): Promise<void> {
1953
- const msgIdRaw = args[0];
1954
- const emoji = args[1];
1955
- if (!msgIdRaw || msgIdRaw.startsWith("--") || !emoji || emoji.startsWith("--")) {
1956
- throw new Error("Usage: agent-relay /react <messageId> <emoji> [--from AGENT_ID] [--remove] [--json]");
1957
- }
1958
- const messageId = Number.parseInt(msgIdRaw, 10);
1959
- if (!Number.isFinite(messageId) || messageId <= 0) throw new Error("messageId must be a positive integer");
1960
-
1961
- let actorId = await detectAgentId();
1962
- let action: "add" | "remove" = "add";
1963
- let json = false;
1964
- for (let i = 2; i < args.length; i++) {
1965
- const arg = args[i];
1966
- if (arg === "--from" && i + 1 < args.length) actorId = args[++i];
1967
- else if (arg === "--remove") action = "remove";
1968
- else if (arg === "--json") json = true;
1969
- else throw new Error(`Unknown react option "${arg}"`);
1970
- }
1971
- if (!actorId) throw new Error("Could not detect current Agent Relay ID. Pass --from AGENT_ID or set AGENT_RELAY_ID.");
1972
-
1973
- const message = await apiRequest("POST", `/api/messages/${messageId}/reactions`, {
1974
- actorId,
1975
- emoji,
1976
- action,
1977
- });
1978
- if (json) console.log(JSON.stringify(message, null, 2));
1979
- else console.log(`${action === "remove" ? "Reaction removed" : "Reaction sent"}: ${emoji} on #${messageId}`);
1980
- }
1981
-
1982
- // Insights #185: capture the agent's end-of-session self-view as a bounded, structured
1983
- // artifact (epic #183, docs/self-improvement.md). Manual rail in v0 — the agent or
1984
- // operator runs this; the auto-trigger is chosen later on real data. The relay drops it
1985
- // (409) if Insights or the introspection feature is toggled off.
1986
- const INTROSPECT_FIELD_MAX = 600;
1987
-
1988
- async function handleIntrospectCommand(args: string[]): Promise<void> {
1989
- let from = await detectAgentId();
1990
- let project: string | undefined;
1991
- let sessionId: string | undefined;
1992
- let thin: string | undefined;
1993
- let workedAround: string | undefined;
1994
- let wouldHaveHelped: string | undefined;
1995
- let stdin = false;
1996
- let json = false;
1997
-
1998
- for (let i = 0; i < args.length; i++) {
1999
- const arg = args[i];
2000
- if (arg === "--from" && i + 1 < args.length) from = args[++i];
2001
- else if (arg === "--project" && i + 1 < args.length) project = args[++i];
2002
- else if (arg === "--session" && i + 1 < args.length) sessionId = args[++i];
2003
- else if (arg === "--thin" && i + 1 < args.length) thin = args[++i];
2004
- else if (arg === "--worked-around" && i + 1 < args.length) workedAround = args[++i];
2005
- else if (arg === "--would-have-helped" && i + 1 < args.length) wouldHaveHelped = args[++i];
2006
- else if (arg === "--stdin") stdin = true;
2007
- else if (arg === "--json") json = true;
2008
- else throw new Error(`Unknown introspect option "${arg}"`);
2009
- }
2010
-
2011
- // --stdin reads a JSON object { thin, workedAround, wouldHaveHelped } — lets agents
2012
- // pipe a file without shell-quoting three multi-line fields.
2013
- if (stdin) {
2014
- const raw = (await readStdin()).trim();
2015
- if (!raw) throw new Error("--stdin given but no input received.");
2016
- let parsed: unknown;
2017
- try {
2018
- parsed = JSON.parse(raw);
2019
- } catch {
2020
- throw new Error('--stdin must be JSON: { "thin": "...", "workedAround": "...", "wouldHaveHelped": "..." }');
2021
- }
2022
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("--stdin JSON must be an object");
2023
- const obj = parsed as Record<string, unknown>;
2024
- if (typeof obj.thin === "string") thin = obj.thin;
2025
- if (typeof obj.workedAround === "string") workedAround = obj.workedAround;
2026
- if (typeof obj.wouldHaveHelped === "string") wouldHaveHelped = obj.wouldHaveHelped;
2027
- }
2028
-
2029
- const clip = (v: string | undefined): string => (v ?? "").trim().slice(0, INTROSPECT_FIELD_MAX);
2030
- const value = { thin: clip(thin), workedAround: clip(workedAround), wouldHaveHelped: clip(wouldHaveHelped) };
2031
- if (!value.thin && !value.workedAround && !value.wouldHaveHelped) {
2032
- throw new Error(
2033
- "Usage: agent-relay /introspect [--thin TEXT] [--worked-around TEXT] [--would-have-helped TEXT] [--stdin] [--from AGENT_ID]\n" +
2034
- "At least one field is required. The three fields: what context was thin, what you worked around, what would've helped.",
2035
- );
2036
- }
2037
- if (!from) throw new Error("Could not detect current Agent Relay ID. Pass --from AGENT_ID or set AGENT_RELAY_ID.");
2038
-
2039
- const observation = await apiRequest("POST", "/api/insights/observations", {
2040
- sessionId: sessionId || process.env.AGENT_RELAY_PROVIDER_SESSION_ID || `manual-${from}`,
2041
- project: project || process.env.AGENT_RELAY_PROJECT || process.cwd().split("/").filter(Boolean).at(-1) || process.cwd(),
2042
- agentId: from,
2043
- signal: "introspection",
2044
- source: "agent",
2045
- value,
2046
- });
2047
- if (json) console.log(JSON.stringify(observation, null, 2));
2048
- else console.log("Introspection recorded.");
2049
- }
2050
-
2051
- function parseReplyFormat(value: string): "text" | "markdown" | "markdownv2" | undefined {
2052
- const normalized = value.trim().toLowerCase();
2053
- if (normalized === "text" || normalized === "markdown" || normalized === "markdownv2") return normalized;
2054
- return undefined;
2055
- }
2056
-
2057
- const MAX_DIRECT_REPLY_BODY_BYTES = Math.floor(MAX_BODY_BYTES * 0.75);
2058
-
2059
- async function resolveBodyInput(options: {
2060
- bodyParts: string[];
2061
- stdinBody: boolean;
2062
- bodyFile?: string;
2063
- }): Promise<string> {
2064
- const sources = [
2065
- options.bodyParts.length > 0 ? "argv" : "",
2066
- options.stdinBody ? "stdin" : "",
2067
- options.bodyFile ? "body file" : "",
2068
- ].filter(Boolean);
2069
- if (sources.length > 1) throw new Error("Reply body must come from exactly one source: arguments, --stdin, or --body-file.");
2070
- if (options.stdinBody) return readStdin();
2071
- if (options.bodyFile) return readFileSync(resolve(options.bodyFile), "utf8");
2072
- return options.bodyParts.join(" ");
2073
- }
2074
-
2075
- async function prepareReplyBody(options: {
2076
- body: string;
2077
- from: string;
2078
- replyTo: number;
2079
- format?: string;
2080
- subject?: string;
2081
- }): Promise<{
2082
- body: string;
2083
- subject?: string;
2084
- attachments?: Array<Record<string, unknown>>;
2085
- payload?: Record<string, unknown>;
2086
- }> {
2087
- const payload: Record<string, unknown> = options.format ? { message: { format: options.format } } : {};
2088
- if (encodedLength(options.body) <= MAX_DIRECT_REPLY_BODY_BYTES) {
2089
- return {
2090
- body: options.body,
2091
- subject: options.subject,
2092
- payload: Object.keys(payload).length ? payload : undefined,
2093
- };
2094
- }
2095
-
2096
- const filename = `relay-reply-${options.replyTo}-${Date.now()}.${options.format === "markdown" ? "md" : "txt"}`;
2097
- const artifact = await apiRawRequest("POST", "/api/artifacts", options.body, {
2098
- "Content-Type": options.format === "markdown" ? "text/markdown; charset=utf-8" : "text/plain; charset=utf-8",
2099
- "X-Artifact-Filename": filename,
2100
- "X-Artifact-Kind": "document",
2101
- "X-Artifact-Sensitivity": "normal",
2102
- }) as { id: string; size?: number };
2103
- const preview = truncateText(options.body, 1800);
2104
- const body = [
2105
- `Full reply attached as Agent Relay artifact ${artifact.id} (${filename}).`,
2106
- "",
2107
- "Preview:",
2108
- preview,
2109
- ].join("\n");
2110
- return {
2111
- body,
2112
- subject: options.subject ?? "Long reply attached",
2113
- attachments: [{
2114
- artifactId: artifact.id,
2115
- kind: "document",
2116
- role: "report",
2117
- title: "Full reply",
2118
- metadata: {
2119
- source: "agent-relay-cli",
2120
- replyTo: options.replyTo,
2121
- originalBytes: encodedLength(options.body),
2122
- },
2123
- }],
2124
- payload: {
2125
- ...payload,
2126
- relay: {
2127
- longReply: true,
2128
- artifactId: artifact.id,
2129
- originalBytes: encodedLength(options.body),
2130
- },
2131
- },
2132
- };
2133
- }
2134
-
2135
- function encodedLength(value: string): number {
2136
- return new TextEncoder().encode(value).byteLength;
2137
- }
2138
-
2139
- function truncateText(value: string, maxChars: number): string {
2140
- if (value.length <= maxChars) return value;
2141
- return `${value.slice(0, maxChars)}\n\n[truncated preview; full content is attached]`;
2142
- }
2143
-
2144
- function formatMessageDetails(payload: {
2145
- message: any;
2146
- artifacts: any;
2147
- thread: any;
2148
- replyTo: any;
2149
- }): string {
2150
- const message = payload.message;
2151
- const lines = [
2152
- `Message #${message.id}`,
2153
- `From: ${message.from}`,
2154
- `To: ${message.to}`,
2155
- `Kind: ${message.kind}`,
2156
- ];
2157
- if (message.subject) lines.push(`Subject: ${message.subject}`);
2158
- if (message.channel) lines.push(`Channel: ${message.channel}`);
2159
- if (message.replyTo) lines.push(`Reply-to: #${message.replyTo}`);
2160
- if (message.threadId) lines.push(`Thread: #${message.threadId}`);
2161
- if (message.createdAt) lines.push(`Created: ${new Date(message.createdAt).toISOString()}`);
2162
- if (message.deliveryStatus) lines.push(`Delivery: ${message.deliveryStatus}`);
2163
- if (message.resolvedToAgent) lines.push(`Resolved-to: ${message.resolvedToAgent}`);
2164
- lines.push("", "Body:", String(message.body ?? ""));
2165
-
2166
- if (message.payload && Object.keys(message.payload).length) {
2167
- lines.push("", "Payload:", JSON.stringify(message.payload, null, 2));
2168
- }
2169
- if (message.meta && Object.keys(message.meta).length) {
2170
- lines.push("", "Meta:", JSON.stringify(message.meta, null, 2));
2171
- }
2172
-
2173
- const artifacts = Array.isArray(payload.artifacts) ? payload.artifacts : [];
2174
- if (artifacts.length) {
2175
- lines.push("", "Attachments:");
2176
- for (const artifact of artifacts) {
2177
- lines.push(`- ${artifact.id ?? artifact.artifactId} ${artifact.filename ? `(${artifact.filename}) ` : ""}${artifact.mediaType ?? artifact.kind ?? ""}`.trim());
2178
- }
2179
- } else if (Array.isArray(message.payload?.attachments) && message.payload.attachments.length) {
2180
- lines.push("", "Attachments:");
2181
- for (const attachment of message.payload.attachments) {
2182
- lines.push(`- ${attachment.artifactId}${attachment.title ? ` (${attachment.title})` : ""}`);
2183
- }
2184
- }
2185
-
2186
- if (payload.replyTo) {
2187
- lines.push("", "Reply Context:", `Parent: #${payload.replyTo.id} from ${payload.replyTo.from} to ${payload.replyTo.to}`);
2188
- }
2189
-
2190
- const thread = Array.isArray(payload.thread) ? payload.thread : [];
2191
- if (thread.length) {
2192
- lines.push("", "Thread:");
2193
- for (const item of thread) {
2194
- const subject = item.subject ? ` ${item.subject}` : "";
2195
- lines.push(`- #${item.id} ${item.from} -> ${item.to}${subject}`);
2196
- }
2197
- }
2198
-
2199
- return lines.join("\n");
2200
- }
2201
-
2202
- async function handleStatusCommand(args: string[]): Promise<void> {
2203
- let agentId = await detectAgentId();
2204
- let json = false;
2205
- for (let i = 0; i < args.length; i++) {
2206
- const arg = args[i];
2207
- if (arg === "--agent" && i + 1 < args.length) agentId = args[++i];
2208
- else if (arg === "--json") json = true;
2209
- else throw new Error(`Unknown status option "${arg}"`);
2210
- }
2211
-
2212
- const [stats, health, pairs, agent] = await Promise.all([
2213
- apiRequest("GET", "/api/stats"),
2214
- apiRequest("GET", "/api/health"),
2215
- agentId ? apiRequest("GET", `/api/pairs?agent=${encodeURIComponent(agentId)}`) : Promise.resolve([]),
2216
- agentId ? apiRequest("GET", `/api/agents/${encodeURIComponent(agentId)}`).catch(() => null) : Promise.resolve(null),
2217
- ]);
2218
- const payload = { agent, stats, health, pairs };
2219
- if (json) console.log(JSON.stringify(payload, null, 2));
2220
- else console.log(formatStatus(payload));
2221
- }
2222
-
2223
- async function handleLabelCommand(args: string[]): Promise<void> {
2224
- let agentId = await detectAgentId();
2225
- let label: string | null | undefined;
2226
- let json = false;
2227
- for (let i = 0; i < args.length; i++) {
2228
- const arg = args[i];
2229
- if (arg === "--agent" && i + 1 < args.length) agentId = args[++i];
2230
- else if (arg === "--clear") label = null;
2231
- else if (arg === "--json") json = true;
2232
- else if (label === undefined) label = args.slice(i).join(" ");
2233
- }
2234
- if (!agentId) throw new Error("Could not detect current Agent Relay ID. Pass --agent AGENT_ID or set AGENT_RELAY_ID.");
2235
- if (label === undefined) {
2236
- const agent = await apiRequest("GET", `/api/agents/${encodeURIComponent(agentId)}`) as { label?: string };
2237
- console.log(agent.label ?? "(no label)");
2238
- return;
2239
- }
2240
- const result = await apiRequest("PATCH", `/api/agents/${encodeURIComponent(agentId)}/label`, { label });
2241
- if (json) console.log(JSON.stringify(result, null, 2));
2242
- else console.log(label ? `Label set: ${label}` : "Label cleared.");
2243
- }
2244
-
2245
- async function handleTagsCommand(args: string[]): Promise<void> {
2246
- let agentId = await detectAgentId();
2247
- let json = false;
2248
- let listOnly = false;
2249
- let add: string[] = [];
2250
- let remove: string[] = [];
2251
- const positional: string[] = [];
2252
- for (let i = 0; i < args.length; i++) {
2253
- const arg = args[i];
2254
- if (arg === "--agent" && i + 1 < args.length) agentId = args[++i];
2255
- else if (arg === "--json") json = true;
2256
- else if (arg === "--list") listOnly = true;
2257
- else if (arg === "--add" && i + 1 < args.length) add = add.concat(splitTagArgs(args[++i]!));
2258
- else if (arg === "--remove" && i + 1 < args.length) remove = remove.concat(splitTagArgs(args[++i]!));
2259
- else positional.push(...splitTagArgs(arg!));
2260
- }
2261
- if (!agentId) throw new Error("Could not detect current Agent Relay ID. Pass --agent AGENT_ID or set AGENT_RELAY_ID.");
2262
- const current = await apiRequest("GET", `/api/agents/${encodeURIComponent(agentId)}`) as { tags?: string[] };
2263
- if (listOnly || (positional.length === 0 && add.length === 0 && remove.length === 0)) {
2264
- console.log((current.tags ?? []).join(", ") || "(no tags)");
2265
- return;
2266
- }
2267
- const next = positional.length > 0
2268
- ? uniqueStrings(positional)
2269
- : uniqueStrings([...(current.tags ?? []), ...add]).filter((tag) => !remove.includes(tag));
2270
- const updated = await apiRequest("PATCH", `/api/agents/${encodeURIComponent(agentId)}/tags`, { tags: next });
2271
- if (json) console.log(JSON.stringify(updated, null, 2));
2272
- else console.log(`Tags: ${next.join(", ") || "(none)"}`);
2273
- }
2274
-
2275
- async function handleRecipeCommand(args: string[]): Promise<void> {
2276
- const action = args[0] ?? "list";
2277
- const rest = args.slice(1);
2278
- if (action === "list") {
2279
- const json = rest.includes("--json");
2280
- const recipes = await apiRequest("GET", "/api/recipes");
2281
- if (json) console.log(JSON.stringify(recipes, null, 2));
2282
- else console.log(formatRecipes(recipes as any[]));
2283
- return;
2284
- }
2285
- if (action === "show") {
2286
- const name = rest.find((arg) => !arg.startsWith("--"));
2287
- const json = rest.includes("--json");
2288
- if (!name) throw new Error("Usage: agent-relay recipe show NAME [--json]");
2289
- const recipe = await apiRequest("GET", `/api/recipes/${encodeURIComponent(name)}`);
2290
- if (json) console.log(JSON.stringify(recipe, null, 2));
2291
- else console.log(formatRecipe(recipe as any));
2292
- return;
2293
- }
2294
- if (action === "start") {
2295
- const name = rest[0];
2296
- if (!name || name.startsWith("--")) throw new Error("Usage: agent-relay recipe start NAME [--cwd PATH] [--orchestrator ID] [--by NAME] [--json]");
2297
- let cwd: string | undefined;
2298
- let orchestratorId: string | undefined;
2299
- let startedBy: string | undefined;
2300
- let json = false;
2301
- for (let i = 1; i < rest.length; i++) {
2302
- const arg = rest[i];
2303
- if (arg === "--cwd" && i + 1 < rest.length) cwd = rest[++i];
2304
- else if ((arg === "--orchestrator" || arg === "--orchestrator-id") && i + 1 < rest.length) orchestratorId = rest[++i];
2305
- else if (arg === "--by" && i + 1 < rest.length) startedBy = rest[++i];
2306
- else if (arg === "--json") json = true;
2307
- else throw new Error(`Unknown recipe start option "${arg}"`);
2308
- }
2309
- const result = await apiRequest("POST", "/api/recipes/start", { name, cwd, orchestratorId, startedBy });
2310
- if (json) console.log(JSON.stringify(result, null, 2));
2311
- else {
2312
- const payload = result as any;
2313
- console.log(`Recipe ${payload.instance.recipeName} started: ${payload.instance.id} (${payload.commands.length} command(s))`);
2314
- }
2315
- return;
2316
- }
2317
- if (action === "stop") {
2318
- const id = rest[0];
2319
- if (!id || id.startsWith("--")) throw new Error("Usage: agent-relay recipe stop INSTANCE_ID [--by NAME] [--json]");
2320
- let stoppedBy: string | undefined;
2321
- let json = false;
2322
- for (let i = 1; i < rest.length; i++) {
2323
- const arg = rest[i];
2324
- if (arg === "--by" && i + 1 < rest.length) stoppedBy = rest[++i];
2325
- else if (arg === "--json") json = true;
2326
- else throw new Error(`Unknown recipe stop option "${arg}"`);
2327
- }
2328
- const result = await apiRequest("POST", `/api/recipes/instances/${encodeURIComponent(id)}/stop`, { stoppedBy });
2329
- if (json) console.log(JSON.stringify(result, null, 2));
2330
- else {
2331
- const payload = result as any;
2332
- console.log(`Recipe ${payload.instance.recipeName} stopped: ${payload.instance.id} (${payload.commands.length} command(s))`);
2333
- }
2334
- return;
2335
- }
2336
- if (action === "status" || action === "instances") {
2337
- const json = rest.includes("--json");
2338
- const instances = await apiRequest("GET", "/api/recipes/instances");
2339
- if (json) console.log(JSON.stringify(instances, null, 2));
2340
- else console.log(formatRecipeInstances(instances as any[]));
2341
- return;
2342
- }
2343
- throw new Error("Usage: agent-relay recipe <list|show|start|stop|status> [options]");
2344
- }
2345
-
2346
- async function handleTokenCommand(args: string[]): Promise<void> {
2347
- const action = args[0] ?? "list";
2348
- const rest = args.slice(1);
2349
- if (action === "list") {
2350
- const json = rest.includes("--json");
2351
- const tokens = await apiRequest("GET", "/api/tokens");
2352
- if (json) console.log(JSON.stringify(tokens, null, 2));
2353
- else console.log(formatTokens(tokens as any[]));
2354
- return;
2355
- }
2356
- if (action === "create") {
2357
- let role: string | undefined;
2358
- let sub: string | undefined;
2359
- let ttlSeconds: number | undefined;
2360
- let json = false;
2361
- let scope: string[] | undefined;
2362
- for (let i = 0; i < rest.length; i++) {
2363
- const arg = rest[i];
2364
- if (arg === "--role" && i + 1 < rest.length) role = rest[++i];
2365
- else if (arg === "--sub" && i + 1 < rest.length) sub = rest[++i];
2366
- else if ((arg === "--scope" || arg === "--scopes") && i + 1 < rest.length) scope = splitTagArgs(rest[++i]!);
2367
- else if ((arg === "--ttl" || arg === "--ttl-seconds") && i + 1 < rest.length) ttlSeconds = parseInt(rest[++i]!, 10);
2368
- else if (arg === "--json") json = true;
2369
- else throw new Error(`Unknown token create option "${arg}"`);
2370
- }
2371
- if (!role) throw new Error("Usage: agent-relay token create --role ROLE [--sub SUBJECT] [--scope a,b] [--ttl SECONDS]");
2372
- const result = await apiRequest("POST", "/api/tokens", { role, sub, scope, ttlSeconds, createdBy: "cli" });
2373
- if (json) console.log(JSON.stringify(result, null, 2));
2374
- else {
2375
- const payload = result as any;
2376
- console.log(payload.token);
2377
- console.error(`Issued ${payload.record.role} token ${payload.record.jti} for ${payload.record.sub}`);
2378
- }
2379
- return;
2380
- }
2381
- if (action === "revoke") {
2382
- const jti = rest.find((arg) => !arg.startsWith("--"));
2383
- if (!jti) throw new Error("Usage: agent-relay token revoke JTI");
2384
- await apiRequest("POST", `/api/tokens/${encodeURIComponent(jti)}/revoke`, {});
2385
- console.log(`Token revoked: ${jti}`);
2386
- return;
2387
- }
2388
- if (action === "verify") {
2389
- const token = rest.find((arg) => !arg.startsWith("--")) ?? process.env.AGENT_RELAY_TOKEN;
2390
- if (!token) throw new Error("Usage: agent-relay token verify TOKEN");
2391
- const payload = decodeJwtPayload(token);
2392
- if (!payload) throw new Error("not a component JWT");
2393
- let record: unknown = null;
2394
- if (typeof payload.jti === "string") {
2395
- record = await apiRequest("GET", `/api/tokens/${encodeURIComponent(payload.jti)}`).catch(() => null);
2396
- }
2397
- console.log(JSON.stringify({ payload, record }, null, 2));
2398
- return;
2399
- }
2400
- throw new Error("Usage: agent-relay token <create|list|revoke|verify> [options]");
2401
- }
2402
-
2403
- async function apiRequest(method: string, path: string, body?: unknown): Promise<unknown> {
2404
- const baseUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
2405
- const headers: Record<string, string> = {};
2406
- const token = process.env.AGENT_RELAY_TOKEN;
2407
- if (token) headers[RELAY_TOKEN_HEADER] = token;
2408
- if (body !== undefined) headers["Content-Type"] = "application/json";
2409
- const response = await fetch(new URL(path, baseUrl), {
2410
- method,
2411
- headers,
2412
- body: body === undefined ? undefined : JSON.stringify(body),
2413
- });
2414
- const text = await response.text();
2415
- const payload = text ? JSON.parse(text) : null;
2416
- if (!response.ok) {
2417
- const message = payload && typeof payload === "object" && "error" in payload ? String((payload as any).error) : text;
2418
- throw new Error(`agent-relay ${method} ${path} failed (${response.status}): ${message}`);
2419
- }
2420
- return payload;
2421
- }
2422
-
2423
- async function apiRawRequest(method: string, path: string, body: BodyInit, extraHeaders: Record<string, string> = {}): Promise<unknown> {
2424
- const baseUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
2425
- const headers: Record<string, string> = { ...extraHeaders };
2426
- const token = process.env.AGENT_RELAY_TOKEN;
2427
- if (token) headers[RELAY_TOKEN_HEADER] = token;
2428
- const response = await fetch(new URL(path, baseUrl), { method, headers, body });
2429
- const text = await response.text();
2430
- const payload = text ? JSON.parse(text) : null;
2431
- if (!response.ok) {
2432
- const message = payload && typeof payload === "object" && "error" in payload ? String((payload as any).error) : text;
2433
- throw new Error(`agent-relay ${method} ${path} failed (${response.status}): ${message}`);
2434
- }
2435
- return payload;
2436
- }
2437
-
2438
- function splitTagArgs(raw: string): string[] {
2439
- return raw.split(",").map((tag) => tag.trim()).filter(Boolean);
2440
- }
2441
-
2442
- function uniqueStrings(values: string[]): string[] {
2443
- return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
2444
- }
2445
-
2446
- async function detectActivePairId(agentId: string): Promise<string | undefined> {
2447
- const pairs = await apiRequest("GET", `/api/pairs?agent=${encodeURIComponent(agentId)}&status=active`) as Array<{ id?: string }>;
2448
- return Array.isArray(pairs) && typeof pairs[0]?.id === "string" ? pairs[0].id : undefined;
2449
- }
2450
-
2451
- async function detectAgentId(): Promise<string | undefined> {
2452
- const explicit = process.env.AGENT_RELAY_ID;
2453
- if (explicit) return explicit;
2454
-
2455
- const contextMatch = currentAgentContextId();
2456
- if (contextMatch) return contextMatch;
2457
-
2458
- const cwd = process.cwd();
2459
- const explicitCodexState = process.env.AGENT_RELAY_CODEX_STATE_PATH
2460
- ? readCodexState(process.env.AGENT_RELAY_CODEX_STATE_PATH)
2461
- : null;
2462
- if (explicitCodexState?.agentId) return explicitCodexState.agentId;
2463
-
2464
- const stateCandidates = [
2465
- resolve(cwd, "codex/runtime/live-state.json"),
2466
- ...collectCodexStateFiles(),
2467
- ].filter((path): path is string => Boolean(path));
2468
-
2469
- const codexMatch = newestCodexAgentId(stateCandidates, cwd);
2470
- if (codexMatch) return codexMatch;
2471
-
2472
- const claudeMatch = currentClaudeAgentId();
2473
- if (claudeMatch) return claudeMatch;
2474
-
2475
- try {
2476
- const agents = await apiRequest("GET", "/api/agents") as Array<{ id?: string; status?: string; ready?: boolean; meta?: { cwd?: unknown }; lastSeen?: number }>;
2477
- const cwdAgents = agents
2478
- .filter((agent) => agent.status !== "offline" && agent.ready !== false && agent.meta?.cwd === cwd && typeof agent.id === "string")
2479
- .sort((a, b) => (b.lastSeen ?? 0) - (a.lastSeen ?? 0));
2480
- const uniqueAgentIds = uniqueStrings(cwdAgents.map((agent) => agent.id!));
2481
- return uniqueAgentIds.length === 1 ? uniqueAgentIds[0] : undefined;
2482
- } catch {
2483
- return undefined;
2484
- }
2485
- }
2486
-
2487
- function currentAgentContextId(): string | undefined {
2488
- const explicitPath = process.env.AGENT_RELAY_CONTEXT_PATH;
2489
- if (explicitPath) {
2490
- const explicit = readAgentContext(explicitPath);
2491
- if (explicit?.agentId && contextMatchesCurrentProcess(explicit)) return explicit.agentId;
2492
- }
2493
-
2494
- const candidates = collectAgentContextFiles();
2495
- const matches = candidates
2496
- .map((path) => readAgentContext(path))
2497
- .filter((context): context is AgentContextState => Boolean(context))
2498
- .filter((context) => contextMatchesCurrentProcess(context))
2499
- .filter((context) => context.matchEnv.some((match) => process.env[match.name] === match.value))
2500
- .sort((a, b) => b.updatedAtMs - a.updatedAtMs);
2501
-
2502
- const uniqueAgentIds = uniqueStrings(matches.map((context) => context.agentId));
2503
- return uniqueAgentIds.length === 1 ? uniqueAgentIds[0] : undefined;
2504
- }
2505
-
2506
- interface AgentContextState {
2507
- agentId: string;
2508
- cwd?: string;
2509
- updatedAtMs: number;
2510
- matchEnv: Array<{ name: string; value: string }>;
2511
- }
2512
-
2513
- function contextMatchesCurrentProcess(context: AgentContextState): boolean {
2514
- return !context.cwd || context.cwd === process.cwd();
2515
- }
2516
-
2517
- function readAgentContext(path: string): AgentContextState | null {
2518
- if (!existsSync(path)) return null;
2519
- try {
2520
- const parsed = JSON.parse(readFileSync(path, "utf8")) as {
2521
- agentId?: unknown;
2522
- cwd?: unknown;
2523
- updatedAt?: unknown;
2524
- matchEnv?: unknown;
2525
- };
2526
- if (typeof parsed.agentId !== "string" || !parsed.agentId) return null;
2527
- const matchEnv = Array.isArray(parsed.matchEnv)
2528
- ? parsed.matchEnv.flatMap((item) => {
2529
- if (!item || typeof item !== "object") return [];
2530
- const record = item as { name?: unknown; value?: unknown };
2531
- return typeof record.name === "string" && typeof record.value === "string"
2532
- ? [{ name: record.name, value: record.value }]
2533
- : [];
2534
- })
2535
- : [];
2536
- const stat = statSync(path);
2537
- const updatedAt = typeof parsed.updatedAt === "string" ? Date.parse(parsed.updatedAt) : Number.NaN;
2538
- return {
2539
- agentId: parsed.agentId,
2540
- cwd: typeof parsed.cwd === "string" ? parsed.cwd : undefined,
2541
- matchEnv,
2542
- updatedAtMs: Number.isFinite(updatedAt) ? updatedAt : stat.mtimeMs,
2543
- };
2544
- } catch {
2545
- return null;
2546
- }
2547
- }
2548
-
2549
- function collectAgentContextFiles(): string[] {
2550
- const roots = [
2551
- join(process.env.HOME || "", ".agent-relay", "contexts"),
2552
- ].filter((root) => root && existsSync(root));
2553
- const files: string[] = [];
2554
- for (const root of roots) collectFiles(root, ".json", files, 2);
2555
- return files;
2556
- }
2557
-
2558
- function newestCodexAgentId(paths: string[], cwd: string): string | undefined {
2559
- const states = paths
2560
- .map((path) => readCodexState(path))
2561
- .filter((state): state is { agentId: string; cwd?: string; updatedAtMs: number } => Boolean(state))
2562
- .sort((a, b) => b.updatedAtMs - a.updatedAtMs);
2563
- const cwdAgentIds = uniqueStrings(states.filter((state) => state.cwd === cwd).map((state) => state.agentId));
2564
- return cwdAgentIds.length === 1 ? cwdAgentIds[0] : undefined;
2565
- }
2566
-
2567
- function readCodexState(path: string): { agentId: string; cwd?: string; updatedAtMs: number } | null {
2568
- if (!existsSync(path)) return null;
2569
- try {
2570
- const parsed = JSON.parse(readFileSync(path, "utf8")) as { agentId?: unknown; cwd?: unknown; updatedAt?: unknown };
2571
- if (typeof parsed.agentId !== "string" || !parsed.agentId) return null;
2572
- const stat = statSync(path);
2573
- const updatedAt = typeof parsed.updatedAt === "string" ? Date.parse(parsed.updatedAt) : Number.NaN;
2574
- return {
2575
- agentId: parsed.agentId,
2576
- cwd: typeof parsed.cwd === "string" ? parsed.cwd : undefined,
2577
- updatedAtMs: Number.isFinite(updatedAt) ? updatedAt : stat.mtimeMs,
2578
- };
2579
- } catch {
2580
- return null;
2581
- }
2582
- }
2583
-
2584
- function collectCodexStateFiles(): string[] {
2585
- const roots = [
2586
- join(process.env.HOME || "", ".agent-relay", "codex", "runtime"),
2587
- resolve(process.cwd(), "codex", "runtime"),
2588
- ].filter((root) => root && existsSync(root));
2589
- const files: string[] = [];
2590
- for (const root of roots) collectFiles(root, "live-state.json", files, 4);
2591
- return files;
2592
- }
2593
-
2594
- function collectFiles(dir: string, name: string, output: string[], depth: number): void {
2595
- if (depth < 0) return;
2596
- let entries: string[];
2597
- try {
2598
- entries = readdirSync(dir);
2599
- } catch {
2600
- return;
2601
- }
2602
- for (const entry of entries) {
2603
- const path = join(dir, entry);
2604
- try {
2605
- const stat = statSync(path);
2606
- if (stat.isDirectory()) collectFiles(path, name, output, depth - 1);
2607
- else if (name.startsWith(".") ? entry.endsWith(name) : entry === name) output.push(path);
2608
- } catch {
2609
- // Ignore state files that disappear while scanning.
2610
- }
2611
- }
2612
- }
2613
-
2614
- function currentClaudeAgentId(): string | undefined {
2615
- const sessionKey = process.env.CLAUDE_CODE_SESSION_ID || String(process.ppid || "");
2616
- if (!sessionKey) return undefined;
2617
- const safeSessionKey = sessionKey.replace(/[^A-Za-z0-9_.:-]/g, "_");
2618
- const statePath = join("/tmp", `agent-relay-instance-${safeSessionKey}.state`);
2619
- if (!existsSync(statePath)) return undefined;
2620
- try {
2621
- return readFileSync(statePath, "utf8").split(/\r?\n/)[0]?.trim() || undefined;
2622
- } catch {
2623
- return undefined;
2624
- }
2625
- }
2626
-
2627
- function formatPairs(pairs: any[]): string {
2628
- if (!pairs.length) return "No pair sessions.";
2629
- return pairs
2630
- .map((pair) => `${pair.id} ${pair.status} ${pair.requesterId} <-> ${pair.targetId}${pair.objective ? ` ${pair.objective}` : ""}`)
2631
- .join("\n");
2632
- }
2633
-
2634
- function formatStatus(payload: any): string {
2635
- const agent = payload.agent;
2636
- const stats = payload.stats ?? {};
2637
- const health = payload.health ?? {};
2638
- const pairs = Array.isArray(payload.pairs) ? payload.pairs : [];
2639
- const activePair = pairs.find((pair: any) => pair.status === "active") ?? pairs.find((pair: any) => pair.status === "pending");
2640
- return [
2641
- `Relay: ${health.status ?? "unknown"} version=${stats.version ?? "unknown"}`,
2642
- `Agents: ${stats.online ?? "?"}/${stats.agents ?? "?"} online Messages: ${stats.messages ?? "?"} Tasks: ${stats.openTasks ?? "?"}/${stats.tasks ?? "?"} open`,
2643
- agent
2644
- ? `Current: ${agent.id} status=${agent.status} ready=${agent.ready ? "yes" : "no"} label=${agent.label ?? "(none)"} tags=${(agent.tags ?? []).join(", ") || "(none)"}`
2645
- : "Current: unknown",
2646
- activePair
2647
- ? `Pair: ${activePair.id} ${activePair.status} ${activePair.requesterId} <-> ${activePair.targetId}`
2648
- : "Pair: none active",
2649
- ].join("\n");
2650
- }
2651
-
2652
- function formatRecipes(recipes: any[]): string {
2653
- if (!recipes.length) return "No recipes.";
2654
- return recipes
2655
- .map((entry) => {
2656
- const recipe = entry.recipe ?? {};
2657
- const agents = Array.isArray(recipe.agents)
2658
- ? recipe.agents.map((agent: any) => `${agent.count ?? 1}x ${agent.provider}:${agent.role}`).join(", ")
2659
- : "no agents";
2660
- return `${entry.name} ${entry.source} ${agents} ${recipe.description ?? ""}`.trim();
2661
- })
2662
- .join("\n");
2663
- }
2664
-
2665
- function formatRecipe(entry: any): string {
2666
- const recipe = entry.recipe ?? {};
2667
- const agents = Array.isArray(recipe.agents)
2668
- ? recipe.agents.map((agent: any) => ` - ${agent.count ?? 1}x ${agent.provider}:${agent.role}`).join("\n")
2669
- : " (none)";
2670
- return [
2671
- `${entry.name} (${entry.source})`,
2672
- recipe.description,
2673
- "Agents:",
2674
- agents,
2675
- ].filter(Boolean).join("\n");
2676
- }
2677
-
2678
- function formatRecipeInstances(instances: any[]): string {
2679
- if (!instances.length) return "No recipe instances.";
2680
- return instances
2681
- .map((instance) => {
2682
- const agents = Array.isArray(instance.agents) ? instance.agents.length : 0;
2683
- return `${instance.id} ${instance.status} ${instance.recipeName} agents=${agents} cwd=${instance.cwd}`;
2684
- })
2685
- .join("\n");
2686
- }
2687
-
2688
- function formatTokens(tokens: any[]): string {
2689
- if (!tokens.length) return "No component tokens.";
2690
- return tokens
2691
- .map((token) => {
2692
- const state = token.revokedAt ? "revoked" : token.expiresAt && token.expiresAt <= Math.floor(Date.now() / 1000) ? "expired" : "active";
2693
- return `${token.jti} ${state} ${token.role} ${token.sub} ${(token.scope ?? []).join(",")}`;
2694
- })
2695
- .join("\n");
2696
- }
2697
-
2698
- function decodeJwtPayload(token: string): Record<string, unknown> | null {
2699
- const payload = token.split(".")[1];
2700
- if (!payload) return null;
2701
- try {
2702
- const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
2703
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
2704
- } catch {
2705
- return null;
2706
- }
2707
- }
2708
-
2709
- async function confirm(message: string): Promise<boolean> {
2710
- if (!input.isTTY) return false;
2711
- const rl = createInterface({ input, output });
2712
- try {
2713
- const answer = await rl.question(`${message} [y/N] `);
2714
- return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
2715
- } finally {
2716
- rl.close();
2717
- }
2718
- }
1
+ // Barrel shim cli.ts split into src/cli/ per-domain command modules (#294, epic
2
+ // #291). Keeps the import sites (src/index.ts + cli.test.ts importing handleCli /
3
+ // WORKSPACE_USAGE / removeLegacyCodexSessionStartHookToml) untouched.
4
+ export * from "./cli/index";