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 +12 -4
- package/bin/agent-relay-codex.ts +108 -13
- package/codex/README.md +121 -0
- package/codex/hooks/session-start-lib.test.ts +39 -0
- package/codex/hooks/session-start-lib.ts +25 -0
- package/codex/hooks/session-start.ts +52 -30
- package/codex/smoke/fallback.ts +12 -9
- package/package.json +1 -1
- package/public/index.html +47 -6
- package/src/config.ts +1 -1
- package/src/index.ts +0 -0
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
|
|
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`
|
|
86
|
-
`--
|
|
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` | `
|
|
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 |
|
package/bin/agent-relay-codex.ts
CHANGED
|
@@ -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
|
|
340
|
-
const
|
|
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(
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
|
|
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
|
|
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.
|
|
631
|
-
console.
|
|
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
|
|
641
|
-
|
|
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
|
-
|
|
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;
|
package/codex/README.md
ADDED
|
@@ -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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
type HookHandshake = {
|
|
9
|
+
status: "ok" | "error";
|
|
10
|
+
code: string;
|
|
11
|
+
message: string;
|
|
12
|
+
pid?: number;
|
|
11
13
|
threadId?: string;
|
|
12
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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.`);
|
package/codex/smoke/fallback.ts
CHANGED
|
@@ -48,8 +48,8 @@ exit 64
|
|
|
48
48
|
chmodSync(path, 0o755);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function parseRuntimeDir(
|
|
52
|
-
const match =
|
|
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
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-
|
|
34
|
-
.status-dot.busy { background: var(--tblr-
|
|
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
|
|
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,
|
|
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
|
|
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: '#
|
|
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",
|
|
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
|