agent-relay-server 0.3.8 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -77,16 +77,24 @@ codex-relay
77
77
  incoming messages as live turns, and cleans up sidecar processes when Codex
78
78
  exits.
79
79
 
80
- ### Codex approval prompts
80
+ ### Codex approval mode
81
81
 
82
82
  Replying to relay messages is usually done with a shell command (`curl` to
83
83
  `/api/messages`), so Codex may prompt for approval in stricter modes.
84
84
 
85
- `codex-relay` now forwards your Codex runtime mode to the sidecar, including
86
- `--ask-for-approval`, `--sandbox`, `--full-auto`, and `--yolo`.
85
+ By default, `codex-relay` starts Codex with
86
+ `--dangerously-bypass-approvals-and-sandbox` so relay turns do not get stuck on
87
+ approval prompts. If you pass an explicit Codex runtime mode, `codex-relay`
88
+ leaves it alone and forwards it to the sidecar, including `--ask-for-approval`,
89
+ `--sandbox`, `--full-auto`, and `--yolo`.
87
90
 
88
91
  Useful setups:
89
92
 
93
+ ```bash
94
+ # default: no approval prompts, no Codex sandbox
95
+ codex-relay
96
+ ```
97
+
90
98
  ```bash
91
99
  # no approval prompts, still sandboxed to workspace boundaries
92
100
  codex-relay -- --ask-for-approval never --sandbox workspace-write
@@ -193,7 +201,7 @@ export AGENT_RELAY_URL=http://100.x.y.z:4850
193
201
  | `PORT` | `4850` | Server port |
194
202
  | `HOST` | `127.0.0.1` | Bind address |
195
203
  | `DB_PATH` | `agent-relay.db` | SQLite database path |
196
- | `STALE_TTL_MS` | `600000` | Mark agents offline after this heartbeat gap |
204
+ | `STALE_TTL_MS` | `120000` | Mark agents offline after this heartbeat gap |
197
205
  | `OFFLINE_PRUNE_MS` | `86400000` | Delete offline agents older than this |
198
206
  | `REAP_INTERVAL_MS` | `60000` | Reaper cadence for stale/offline cleanup |
199
207
  | `RETENTION_DAYS` | `30` | Auto-prune messages older than this |
@@ -14,6 +14,24 @@ type RelayStats = {
14
14
  version?: string;
15
15
  };
16
16
 
17
+ type HookHandshake = {
18
+ status: "ok" | "error";
19
+ code: string;
20
+ message?: string;
21
+ pid?: number;
22
+ threadId?: string;
23
+ timestamp?: string;
24
+ };
25
+
26
+ type HookWaitResult = {
27
+ ok: boolean;
28
+ code: string;
29
+ message?: string;
30
+ pid?: number;
31
+ };
32
+
33
+ const DEFAULT_HOOK_HANDSHAKE_TIMEOUT_MS = 5000;
34
+
17
35
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
36
  const packageRoot = resolve(__dirname, "..");
19
37
  const home = process.env.HOME || process.env.USERPROFILE || homedir();
@@ -76,6 +94,21 @@ function readJsonFile<T>(path: string, fallback: T): T {
76
94
  return JSON.parse(readFileSync(path, "utf8")) as T;
77
95
  }
78
96
 
97
+ function errorMessage(error: unknown): string {
98
+ return error instanceof Error ? error.message : String(error);
99
+ }
100
+
101
+ function envPositiveInt(name: string, fallback: number): number {
102
+ const raw = process.env[name];
103
+ if (!raw) return fallback;
104
+ const parsed = Number.parseInt(raw, 10);
105
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
106
+ }
107
+
108
+ function appendLauncherLog(runDir: string, line: string): void {
109
+ appendFileSync(join(runDir, "launcher.log"), `${new Date().toISOString()} ${line}\n`);
110
+ }
111
+
79
112
  function isAgentRelaySessionStartCommand(command: string): boolean {
80
113
  return /agent-relay.*codex\/hooks\/session-start\.ts/.test(command);
81
114
  }
@@ -336,21 +369,35 @@ async function waitForPort(url: string, child: ReturnType<typeof Bun.spawn>): Pr
336
369
  throw new Error(`timed out waiting for ${url}`);
337
370
  }
338
371
 
339
- async function waitForSidecarPid(runDir: string, timeoutMs: number): Promise<boolean> {
340
- const pidsPath = join(runDir, "sidecar-pids.txt");
372
+ async function waitForHookHandshake(runDir: string, timeoutMs: number): Promise<HookWaitResult> {
373
+ const handshakePath = join(runDir, "session-start-handshake.json");
341
374
  const deadline = Date.now() + timeoutMs;
342
375
 
343
376
  while (Date.now() < deadline) {
344
- if (existsSync(pidsPath)) {
345
- for (const line of readFileSync(pidsPath, "utf8").split("\n")) {
346
- const pid = Number(line.trim());
347
- if (Number.isFinite(pid) && pid > 0 && isAlive(pid)) return true;
377
+ if (existsSync(handshakePath)) {
378
+ try {
379
+ const parsed = JSON.parse(readFileSync(handshakePath, "utf8")) as HookHandshake;
380
+ const code = typeof parsed.code === "string" && parsed.code.trim() ? parsed.code.trim() : "HOOK_HANDSHAKE_INVALID";
381
+ const message = typeof parsed.message === "string" ? parsed.message : undefined;
382
+ const pid = typeof parsed.pid === "number" && Number.isFinite(parsed.pid) && parsed.pid > 0
383
+ ? parsed.pid
384
+ : undefined;
385
+
386
+ if (parsed.status === "ok") return { ok: true, code, message, pid };
387
+ if (parsed.status === "error") return { ok: false, code, message, pid };
388
+ return { ok: false, code: "HOOK_HANDSHAKE_INVALID", message: `unexpected status ${String((parsed as any).status)}` };
389
+ } catch (error) {
390
+ return { ok: false, code: "HOOK_HANDSHAKE_INVALID", message: errorMessage(error) };
348
391
  }
349
392
  }
350
393
  await Bun.sleep(100);
351
394
  }
352
395
 
353
- return false;
396
+ return {
397
+ ok: false,
398
+ code: "HOOK_HANDSHAKE_TIMEOUT",
399
+ message: `no hook handshake observed within ${timeoutMs}ms`,
400
+ };
354
401
  }
355
402
 
356
403
  function spawnFallbackSidecar(runDir: string, env: Record<string, string | undefined>): number {
@@ -383,6 +430,25 @@ type SessionPermissions = {
383
430
  sandbox?: string;
384
431
  };
385
432
 
433
+ function hasCodexPermissionMode(codexArgs: string[]): boolean {
434
+ for (const arg of codexArgs) {
435
+ if (
436
+ arg === "--yolo" ||
437
+ arg === "--dangerously-bypass-approvals-and-sandbox" ||
438
+ arg === "--full-auto" ||
439
+ arg === "--ask-for-approval" ||
440
+ arg === "-a" ||
441
+ arg.startsWith("--ask-for-approval=") ||
442
+ arg === "--sandbox" ||
443
+ arg === "-s" ||
444
+ arg.startsWith("--sandbox=")
445
+ ) {
446
+ return true;
447
+ }
448
+ }
449
+ return false;
450
+ }
451
+
386
452
  function resolveSessionPermissions(codexArgs: string[]): SessionPermissions {
387
453
  let approvalPolicy: string | undefined;
388
454
  let sandbox: string | undefined;
@@ -488,7 +554,6 @@ function installLauncherShims(includeCodexAlias: boolean): void {
488
554
  mkdirSync(aliasBinDir, { recursive: true });
489
555
  writeLauncherShim("codex-relay");
490
556
  if (includeCodexAlias) writeLauncherShim("codex");
491
- else removeLauncherShim("codex");
492
557
  }
493
558
 
494
559
  function isAliasBinOnPath(): boolean {
@@ -589,6 +654,7 @@ async function start(args: string[]): Promise<void> {
589
654
  }
590
655
 
591
656
  if (!listenUrl) listenUrl = await pickLoopbackUrl();
657
+ if (!hasCodexPermissionMode(codexArgs)) codexArgs.unshift("--dangerously-bypass-approvals-and-sandbox");
592
658
  const permissions = resolveSessionPermissions(codexArgs);
593
659
 
594
660
  mkdirSync(runtimeRoot, { recursive: true });
@@ -627,8 +693,8 @@ async function start(args: string[]): Promise<void> {
627
693
  process.once("exit", shutdown);
628
694
 
629
695
  await waitForPort(listenUrl, appServer);
630
- console.error(`Agent Relay Codex session: ${listenUrl}`);
631
- console.error(`Runtime: ${runDir}`);
696
+ console.log(`Agent Relay Codex session: ${listenUrl}`);
697
+ console.log(`Runtime: ${runDir}`);
632
698
 
633
699
  const codex = Bun.spawn([codexBinary, "--remote", listenUrl, ...codexArgs], {
634
700
  env,
@@ -637,10 +703,39 @@ async function start(args: string[]): Promise<void> {
637
703
  stderr: "inherit",
638
704
  });
639
705
 
640
- const sidecarDetected = await waitForSidecarPid(runDir, 2500);
641
- if (!sidecarDetected && codex.exitCode === null) {
706
+ const hookTimeoutMs = envPositiveInt("AGENT_RELAY_CODEX_HOOK_TIMEOUT_MS", DEFAULT_HOOK_HANDSHAKE_TIMEOUT_MS);
707
+ const handshake = await waitForHookHandshake(runDir, hookTimeoutMs);
708
+ let fallbackReason: HookWaitResult | null = null;
709
+
710
+ if (!handshake.ok) {
711
+ fallbackReason = handshake;
712
+ } else if (handshake.pid === undefined) {
713
+ fallbackReason = {
714
+ ok: false,
715
+ code: "HOOK_HANDSHAKE_NO_PID",
716
+ message: "hook reported success without a sidecar pid",
717
+ };
718
+ } else if (!isAlive(handshake.pid)) {
719
+ fallbackReason = {
720
+ ok: false,
721
+ code: "HOOK_HANDSHAKE_PID_NOT_ALIVE",
722
+ message: `hook reported pid ${handshake.pid} but it is not running`,
723
+ };
724
+ } else {
725
+ appendLauncherLog(runDir, `HOOK_HANDSHAKE_OK code=${handshake.code} pid=${handshake.pid}`);
726
+ }
727
+
728
+ if (fallbackReason && codex.exitCode === null) {
642
729
  const pid = spawnFallbackSidecar(runDir, env);
643
- console.error(`Agent Relay: SessionStart hook did not start sidecar; started fallback sidecar pid ${pid}`);
730
+ appendFileSync(
731
+ join(runDir, "launcher.log"),
732
+ `${new Date().toISOString()} HOOK_FALLBACK_STARTED reason=${fallbackReason.code} fallbackPid=${pid}${fallbackReason.message ? ` detail=${fallbackReason.message}` : ""}\n`,
733
+ );
734
+ } else if (fallbackReason) {
735
+ appendLauncherLog(
736
+ runDir,
737
+ `HOOK_FALLBACK_SKIPPED reason=${fallbackReason.code} codexExit=${codex.exitCode}${fallbackReason.message ? ` detail=${fallbackReason.message}` : ""}`,
738
+ );
644
739
  }
645
740
 
646
741
  const exitCode = await codex.exited;
@@ -0,0 +1,121 @@
1
+ # Codex Live Sidecar
2
+
3
+ Codex integration for Agent Relay.
4
+
5
+ ## Purpose
6
+
7
+ This sidecar connects to a Codex app-server session and to Agent Relay, then delivers incoming relay messages into the active Codex thread using:
8
+
9
+ - `turn/start`
10
+ - `turn/steer`
11
+ - `turn/interrupt`
12
+
13
+ ## Current behavior
14
+
15
+ - attaches to a loaded thread for the current `cwd` when one exists
16
+ - otherwise resumes the newest thread for the current `cwd`
17
+ - otherwise creates a new thread
18
+ - registers a relay agent with `client: codex-live`
19
+ - marks the relay agent `ready=true` once app-server + thread are attached
20
+ - polls relay inbox and delivers messages into the live thread
21
+ - coalesces ordinary relay bursts into one delivery turn
22
+ - reconnects to the app-server with exponential backoff after disconnects
23
+ - writes runtime state to `codex/runtime/live-state.json`
24
+
25
+ ## Delivery behavior
26
+
27
+ - idle thread: `turn/start`
28
+ - active thread: `turn/steer`
29
+ - urgent or `meta.delivery = "interrupt"`: `turn/interrupt` then `turn/start`
30
+
31
+ ## Run
32
+
33
+ ```bash
34
+ codex/start-live.sh
35
+ ```
36
+
37
+ ## Installable workflow
38
+
39
+ The packaged Codex path is:
40
+
41
+ ```bash
42
+ bunx agent-relay-server@latest
43
+ curl -fsSL https://unpkg.com/agent-relay-server@latest/codex/install-codex.sh | bash
44
+ # after restarting your shell
45
+ codex-relay
46
+ ```
47
+
48
+ The installer always adds a `codex-relay` launcher and asks whether plain
49
+ `codex` should also route through Agent Relay. `codex-relay` idempotently
50
+ installs or refreshes the Codex hook/plugin, then launches `codex app-server`,
51
+ starts Codex with
52
+ `--remote`, lets the SessionStart hook attach a sidecar to the actual thread,
53
+ and kills sidecars plus the app-server when Codex exits.
54
+
55
+ ## Approval mode
56
+
57
+ Relay replies are usually sent with a shell command (`curl` to
58
+ `/api/messages`), so Codex can prompt for approval in stricter modes.
59
+
60
+ By default, `codex-relay` starts Codex with
61
+ `--dangerously-bypass-approvals-and-sandbox` so relay turns do not get stuck on
62
+ approval prompts. If you pass an explicit Codex runtime mode, `codex-relay`
63
+ leaves it alone and forwards it to the sidecar, including `--ask-for-approval`,
64
+ `--sandbox`, `--full-auto`, and `--yolo`.
65
+
66
+ Default:
67
+
68
+ ```bash
69
+ codex-relay
70
+ ```
71
+
72
+ Example: no prompt loop, still workspace sandboxing:
73
+
74
+ ```bash
75
+ codex-relay -- --ask-for-approval never --sandbox workspace-write
76
+ ```
77
+
78
+ If you prefer prompts for everything else but want relay sends auto-approved,
79
+ add a rule in `~/.codex/rules/default.rules` (adjust URL when using a remote relay):
80
+
81
+ ```python
82
+ prefix_rule(
83
+ pattern = ["curl", "-sS", "-X", "POST", "http://127.0.0.1:4850/api/messages"],
84
+ decision = "allow",
85
+ justification = "Allow local Agent Relay message posts",
86
+ )
87
+ ```
88
+
89
+ For local development from this repo:
90
+
91
+ ```bash
92
+ bun run bin/agent-relay-codex.ts
93
+ bun run codex:smoke:fallback
94
+ ```
95
+
96
+ Useful environment variables:
97
+
98
+ - `AGENT_RELAY_URL`
99
+ - `AGENT_RELAY_CAPS`
100
+ - `AGENT_RELAY_CODEX_HOOK_TIMEOUT_MS` (launcher wait for SessionStart handshake, default `5000`)
101
+ - `CODEX_APP_SERVER_URL`
102
+ - `CODEX_THREAD_ID`
103
+ - `CODEX_THREAD_MODE=auto|resume|start`
104
+ - `CODEX_LIVE_STATE_PATH`
105
+ - `CODEX_LIVE_COALESCE_WINDOW_MS`
106
+ - `CODEX_LIVE_RECONNECT_INITIAL_MS`
107
+ - `CODEX_LIVE_RECONNECT_MAX_MS`
108
+ - `CODEX_LIVE_RIG`
109
+ - `CODEX_MODEL`
110
+
111
+ Fallback reason codes are written to `runtime/<run>/launcher.log` when the
112
+ launcher has to start a sidecar because the SessionStart hook did not confirm
113
+ startup in time.
114
+
115
+ ## Notes
116
+
117
+ Current sidecar behavior is stable for live delivery. Remaining gaps are advanced policies such as batching by sender, message prioritization queues, and more nuanced retry/backoff behavior.
118
+
119
+ - `CODEX_THREAD_MODE=auto` will attach to an already loaded thread for the same `cwd`. That is what you want for real live control, but it also means the sidecar can attach to your current interactive Codex session if one is already open.
120
+ - For isolated testing, set `CODEX_THREAD_MODE=start` so the sidecar always creates its own thread.
121
+ - A brand-new thread is not materialized for `includeTurns` reads until the first turn starts. That is an app-server behavior, not a relay bug.
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { pickThreadId } from "./session-start-lib.ts";
3
+
4
+ describe("pickThreadId", () => {
5
+ it("prefers explicit thread ids over session ids", () => {
6
+ const threadId = pickThreadId({
7
+ session_id: "session-123",
8
+ thread_id: "thread-456",
9
+ });
10
+
11
+ expect(threadId).toBe("thread-456");
12
+ });
13
+
14
+ it("uses nested thread id when present", () => {
15
+ const threadId = pickThreadId({
16
+ session: { id: "session-123" },
17
+ thread: { id: "thread-789" },
18
+ });
19
+
20
+ expect(threadId).toBe("thread-789");
21
+ });
22
+
23
+ it("falls back to session id when thread id is missing", () => {
24
+ const threadId = pickThreadId({
25
+ sessionId: "session-abc",
26
+ });
27
+
28
+ expect(threadId).toBe("session-abc");
29
+ });
30
+
31
+ it("ignores blank candidates", () => {
32
+ const threadId = pickThreadId({
33
+ threadId: " ",
34
+ session_id: " session-trimmed ",
35
+ });
36
+
37
+ expect(threadId).toBe("session-trimmed");
38
+ });
39
+ });
@@ -0,0 +1,25 @@
1
+ export type HookInput = {
2
+ session_id?: string;
3
+ sessionId?: string;
4
+ thread_id?: string;
5
+ threadId?: string;
6
+ session?: { id?: string };
7
+ thread?: { id?: string };
8
+ cwd?: string;
9
+ model?: string;
10
+ };
11
+
12
+ export function pickThreadId(input: HookInput): string {
13
+ const candidates = [
14
+ input.thread_id,
15
+ input.threadId,
16
+ input.thread?.id,
17
+ input.session_id,
18
+ input.sessionId,
19
+ input.session?.id,
20
+ ];
21
+ for (const value of candidates) {
22
+ if (typeof value === "string" && value.trim()) return value.trim();
23
+ }
24
+ return "";
25
+ }
@@ -3,16 +3,15 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } fr
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { buildAgentIdentity } from "../relay.ts";
6
+ import { pickThreadId, type HookInput } from "./session-start-lib.ts";
6
7
 
7
- type HookInput = {
8
- session_id?: string;
9
- sessionId?: string;
10
- thread_id?: string;
8
+ type HookHandshake = {
9
+ status: "ok" | "error";
10
+ code: string;
11
+ message: string;
12
+ pid?: number;
11
13
  threadId?: string;
12
- session?: { id?: string };
13
- thread?: { id?: string };
14
- cwd?: string;
15
- model?: string;
14
+ timestamp: string;
16
15
  };
17
16
 
18
17
  function readStdin(): string {
@@ -45,19 +44,12 @@ function outputContext(context: string): never {
45
44
  process.exit(0);
46
45
  }
47
46
 
48
- function pickThreadId(input: HookInput): string {
49
- const candidates = [
50
- input.session_id,
51
- input.sessionId,
52
- input.thread_id,
53
- input.threadId,
54
- input.session?.id,
55
- input.thread?.id,
56
- ];
57
- for (const value of candidates) {
58
- if (typeof value === "string" && value.trim()) return value.trim();
59
- }
60
- return "";
47
+ function handshakePath(runtimeDir: string): string {
48
+ return join(runtimeDir, "session-start-handshake.json");
49
+ }
50
+
51
+ function writeHandshake(runtimeDir: string, payload: HookHandshake): void {
52
+ writeFileSync(handshakePath(runtimeDir), `${JSON.stringify(payload, null, 2)}\n`);
61
53
  }
62
54
 
63
55
  function existingAlivePid(pidPath: string): number | null {
@@ -90,10 +82,19 @@ const pidPath = join(sessionDir, "sidecar.pid");
90
82
  const statePath = join(sessionDir, "live-state.json");
91
83
  const logPath = join(sessionDir, "sidecar.log");
92
84
  mkdirSync(sessionDir, { recursive: true });
85
+ mkdirSync(runtimeDir, { recursive: true });
93
86
 
94
87
  const autoPidPath = join(runtimeDir, "auto", "sidecar.pid");
95
88
  const activePid = existingAlivePid(pidPath) ?? (threadId ? existingAlivePid(autoPidPath) : null);
96
89
  if (activePid !== null) {
90
+ writeHandshake(runtimeDir, {
91
+ status: "ok",
92
+ code: "HOOK_SIDECAR_REUSED",
93
+ message: `using existing sidecar pid ${activePid}`,
94
+ pid: activePid,
95
+ threadId: threadId || undefined,
96
+ timestamp: new Date().toISOString(),
97
+ });
97
98
  if (threadId) {
98
99
  const identity = buildAgentIdentity({
99
100
  relayUrl,
@@ -126,15 +127,36 @@ if (threadId) {
126
127
  }
127
128
 
128
129
  const logFile = Bun.file(logPath);
129
- const sidecar = Bun.spawn(["bun", "run", join(packageRoot, "codex", "live-sidecar.ts")], {
130
- env: spawnEnv,
131
- stdout: logFile,
132
- stderr: logFile,
133
- });
134
- sidecar.unref();
135
-
136
- writeFileSync(pidPath, String(sidecar.pid));
137
- appendFileSync(join(runtimeDir, "sidecar-pids.txt"), `${sidecar.pid}\n`);
130
+ let sidecarPid = 0;
131
+ try {
132
+ const sidecar = Bun.spawn(["bun", "run", join(packageRoot, "codex", "live-sidecar.ts")], {
133
+ env: spawnEnv,
134
+ stdout: logFile,
135
+ stderr: logFile,
136
+ });
137
+ sidecar.unref();
138
+ sidecarPid = sidecar.pid;
139
+
140
+ writeFileSync(pidPath, String(sidecarPid));
141
+ appendFileSync(join(runtimeDir, "sidecar-pids.txt"), `${sidecarPid}\n`);
142
+ writeHandshake(runtimeDir, {
143
+ status: "ok",
144
+ code: "HOOK_SIDECAR_STARTED",
145
+ message: `spawned sidecar pid ${sidecarPid}`,
146
+ pid: sidecarPid,
147
+ threadId: threadId || undefined,
148
+ timestamp: new Date().toISOString(),
149
+ });
150
+ } catch (error) {
151
+ writeHandshake(runtimeDir, {
152
+ status: "error",
153
+ code: "HOOK_SIDECAR_SPAWN_FAILED",
154
+ message: error instanceof Error ? error.message : String(error),
155
+ threadId: threadId || undefined,
156
+ timestamp: new Date().toISOString(),
157
+ });
158
+ throw error;
159
+ }
138
160
 
139
161
  if (!threadId) {
140
162
  outputContext(`Agent Relay sidecar started in auto-thread mode. Relay URL: ${relayUrl}. Incoming messages will arrive as live user turns once the sidecar resolves the active thread.`);
@@ -48,8 +48,8 @@ exit 64
48
48
  chmodSync(path, 0o755);
49
49
  }
50
50
 
51
- function parseRuntimeDir(stderr: string): string | null {
52
- const match = stderr.match(/^Runtime:\s+(.+)$/m);
51
+ function parseRuntimeDir(output: string): string | null {
52
+ const match = output.match(/^Runtime:\s+(.+)$/m);
53
53
  return match?.[1]?.trim() || null;
54
54
  }
55
55
 
@@ -85,18 +85,15 @@ async function main(): Promise<void> {
85
85
  throw new Error(`launcher exited ${exitCode}\nstdout:\n${stdout}\nstderr:\n${stderr}`);
86
86
  }
87
87
 
88
- const runtimeDir = parseRuntimeDir(stderr);
88
+ const runtimeDir = parseRuntimeDir(`${stdout}\n${stderr}`);
89
89
  if (!runtimeDir) {
90
- throw new Error(`missing runtime directory in launcher output\nstderr:\n${stderr}`);
91
- }
92
-
93
- if (!stderr.includes("SessionStart hook did not start sidecar; started fallback sidecar pid")) {
94
- throw new Error(`fallback start message not found\nstderr:\n${stderr}`);
90
+ throw new Error(`missing runtime directory in launcher output\nstdout:\n${stdout}\nstderr:\n${stderr}`);
95
91
  }
96
92
 
97
93
  const fallbackPidPath = join(runtimeDir, "auto", "sidecar.pid");
98
94
  const fallbackLogPath = join(runtimeDir, "auto", "sidecar.log");
99
95
  const sidecarPidsPath = join(runtimeDir, "sidecar-pids.txt");
96
+ const launcherLogPath = join(runtimeDir, "launcher.log");
100
97
 
101
98
  if (!existsSync(fallbackPidPath)) {
102
99
  throw new Error(`fallback pid file missing: ${fallbackPidPath}`);
@@ -104,12 +101,19 @@ async function main(): Promise<void> {
104
101
  if (!existsSync(sidecarPidsPath)) {
105
102
  throw new Error(`sidecar-pids missing: ${sidecarPidsPath}`);
106
103
  }
104
+ if (!existsSync(launcherLogPath)) {
105
+ throw new Error(`launcher log missing: ${launcherLogPath}`);
106
+ }
107
107
 
108
108
  const pid = readFileSync(fallbackPidPath, "utf8").trim();
109
109
  const pidsFile = readFileSync(sidecarPidsPath, "utf8");
110
+ const launcherLog = readFileSync(launcherLogPath, "utf8");
110
111
  if (!pid || !pidsFile.includes(pid)) {
111
112
  throw new Error(`fallback pid ${pid || "<empty>"} not recorded in sidecar-pids.txt`);
112
113
  }
114
+ if (!launcherLog.includes("HOOK_FALLBACK_STARTED reason=HOOK_HANDSHAKE_TIMEOUT")) {
115
+ throw new Error(`fallback reason code missing in launcher log\nlog:\n${launcherLog}`);
116
+ }
113
117
 
114
118
  log(`fallback sidecar created pid ${pid}`);
115
119
  if (existsSync(fallbackLogPath)) {
@@ -122,4 +126,3 @@ main().catch((error) => {
122
126
  log(error instanceof Error ? error.stack || error.message : String(error));
123
127
  process.exitCode = 1;
124
128
  });
125
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
package/public/index.html CHANGED
@@ -30,8 +30,8 @@
30
30
  .status-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
31
31
  .status-dot.online { background: var(--tblr-success); box-shadow: 0 0 6px var(--tblr-success); }
32
32
  .status-dot.online.not-ready { animation: pulse-dot 1.5s ease-in-out infinite; }
33
- .status-dot.idle { background: var(--tblr-warning); }
34
- .status-dot.busy { background: var(--tblr-danger); }
33
+ .status-dot.idle { background: var(--tblr-success); }
34
+ .status-dot.busy { background: var(--tblr-warning); }
35
35
  .status-dot.offline { background: var(--tblr-secondary); opacity: 0.5; }
36
36
 
37
37
  @keyframes pulse-dot {
@@ -242,6 +242,20 @@
242
242
  <div class="d-flex align-items-center mb-3 gap-3 flex-wrap">
243
243
  <h2 class="page-title mb-0">Agents</h2>
244
244
  <div class="ms-auto d-flex gap-2 align-items-center">
245
+ <select class="form-select form-select-sm" style="width:auto; min-width: 140px" x-model="agentStatusFilter">
246
+ <option value="">Status: All</option>
247
+ <option value="starting">Status: Starting</option>
248
+ <option value="online">Status: Online</option>
249
+ <option value="idle">Status: Idle</option>
250
+ <option value="busy">Status: Busy</option>
251
+ <option value="offline">Status: Offline</option>
252
+ </select>
253
+ <select class="form-select form-select-sm" style="width:auto; min-width: 140px" x-model="agentTagFilter">
254
+ <option value="">Tag: All</option>
255
+ <template x-for="tag in uniqueTags" :key="tag">
256
+ <option :value="tag" x-text="'Tag: ' + tag"></option>
257
+ </template>
258
+ </select>
245
259
  <select class="form-select form-select-sm" style="width:auto" x-model="agentSort">
246
260
  <option value="status">Sort: Status</option>
247
261
  <option value="name">Sort: Name</option>
@@ -251,6 +265,14 @@
251
265
  <button class="btn btn-sm btn-ghost-secondary" @click="agentSortDir = agentSortDir === 'asc' ? 'desc' : 'asc'">
252
266
  <i class="ti" :class="agentSortDir === 'asc' ? 'ti-sort-ascending' : 'ti-sort-descending'"></i>
253
267
  </button>
268
+ <button
269
+ class="btn btn-sm btn-ghost-secondary"
270
+ x-show="agentStatusFilter || agentTagFilter"
271
+ @click="agentStatusFilter = ''; agentTagFilter = ''"
272
+ title="Clear filters"
273
+ >
274
+ <i class="ti ti-x me-1"></i>Clear
275
+ </button>
254
276
  </div>
255
277
  </div>
256
278
 
@@ -319,7 +341,10 @@
319
341
  <div class="card">
320
342
  <div class="card-body text-center text-secondary py-5">
321
343
  <i class="ti ti-robot-off" style="font-size:48px; opacity:0.3"></i>
322
- <p class="mt-2" x-text="showOffline ? 'No agents registered' : 'No active agents — enable Show Offline'"></p>
344
+ <p
345
+ class="mt-2"
346
+ x-text="(agentStatusFilter || agentTagFilter) ? 'No agents match the current filters' : (showOffline ? 'No agents registered' : 'No active agents — enable Show Offline')"
347
+ ></p>
323
348
  </div>
324
349
  </div>
325
350
  </template>
@@ -699,6 +724,8 @@ function relay() {
699
724
  // Filters
700
725
  channelFilter: '',
701
726
  tagFilter: '',
727
+ agentStatusFilter: load('agentStatusFilter', ''),
728
+ agentTagFilter: load('agentTagFilter', ''),
702
729
 
703
730
  // Charts
704
731
  chartInstances: {},
@@ -716,6 +743,8 @@ function relay() {
716
743
  this.$watch('showOffline', v => this.save('showOffline', v));
717
744
  this.$watch('agentSort', v => this.save('agentSort', v));
718
745
  this.$watch('agentSortDir', v => this.save('agentSortDir', v));
746
+ this.$watch('agentStatusFilter', v => this.save('agentStatusFilter', v));
747
+ this.$watch('agentTagFilter', v => this.save('agentTagFilter', v));
719
748
  this.$watch('view', v => {
720
749
  this.save('view', v);
721
750
  if (v === 'analytics') this.$nextTick(() => this.renderCharts());
@@ -827,13 +856,23 @@ function relay() {
827
856
 
828
857
  get sortedAgents() {
829
858
  let list = this.showOffline ? [...this.agents] : this.agents.filter(a => a.status !== 'offline');
859
+ if (this.agentStatusFilter) {
860
+ if (this.agentStatusFilter === 'starting') {
861
+ list = list.filter(a => a.status !== 'offline' && !a.ready);
862
+ } else {
863
+ list = list.filter(a => a.status === this.agentStatusFilter);
864
+ }
865
+ }
866
+ if (this.agentTagFilter) {
867
+ list = list.filter(a => (a.tags || []).includes(this.agentTagFilter));
868
+ }
830
869
  const dir = this.agentSortDir === 'desc' ? -1 : 1;
831
870
  return list.sort((a, b) => {
832
871
  let cmp = 0;
833
872
  switch (this.agentSort) {
834
873
  case 'name': cmp = this.displayName(a).localeCompare(this.displayName(b)); break;
835
874
  case 'status': {
836
- const order = { online: 0, busy: 1, idle: 2, offline: 3 };
875
+ const order = { online: 0, idle: 1, busy: 2, offline: 3 };
837
876
  cmp = (order[a.status] ?? 9) - (order[b.status] ?? 9);
838
877
  break;
839
878
  }
@@ -909,7 +948,9 @@ function relay() {
909
948
 
910
949
  timeAgo(iso) {
911
950
  if (!iso) return '';
912
- const diff = (Date.now() - new Date(iso).getTime()) / 1000;
951
+ const ts = new Date(iso).getTime();
952
+ if (!Number.isFinite(ts)) return '';
953
+ const diff = Math.max(0, (Date.now() - ts) / 1000);
913
954
  if (diff < 60) return Math.floor(diff) + 's ago';
914
955
  if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
915
956
  if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
@@ -1084,7 +1125,7 @@ function relay() {
1084
1125
  for (const a of this.agents) counts[a.status] = (counts[a.status] || 0) + 1;
1085
1126
  const labels = Object.keys(counts).filter(k => counts[k] > 0);
1086
1127
  const series = labels.map(k => counts[k]);
1087
- const colorMap = { online: '#48bb78', idle: '#ecc94b', busy: '#f56565', offline: '#718096' };
1128
+ const colorMap = { online: '#48bb78', idle: '#48bb78', busy: '#ecc94b', offline: '#718096' };
1088
1129
 
1089
1130
  this.chartInstances.status = new ApexCharts(el, {
1090
1131
  chart: { type: 'donut', height: 280, background: 'transparent' },
package/src/config.ts CHANGED
@@ -17,7 +17,7 @@ function envPositiveInt(name: string, fallback: number): number {
17
17
  return Math.floor(parsed);
18
18
  }
19
19
 
20
- export const STALE_TTL_MS = envPositiveInt("STALE_TTL_MS", 600_000); // 10min without heartbeat → offline
20
+ export const STALE_TTL_MS = envPositiveInt("STALE_TTL_MS", 120_000); // 2min without heartbeat → offline
21
21
  export const OFFLINE_PRUNE_MS = envPositiveInt("OFFLINE_PRUNE_MS", DAY_MS); // 24h offline → delete
22
22
  export const REAP_INTERVAL_MS = envPositiveInt("REAP_INTERVAL_MS", 60_000); // reaper cadence
23
23
 
package/src/index.ts CHANGED
File without changes