agent-relay-server 0.3.9 → 0.3.11
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 +2 -1
- package/bin/agent-relay-codex.ts +86 -10
- package/codex/README.md +5 -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 +8 -4
- package/package.json +1 -1
- package/public/index.html +58 -7
- package/src/config.ts +1 -1
package/README.md
CHANGED
|
@@ -201,7 +201,7 @@ export AGENT_RELAY_URL=http://100.x.y.z:4850
|
|
|
201
201
|
| `PORT` | `4850` | Server port |
|
|
202
202
|
| `HOST` | `127.0.0.1` | Bind address |
|
|
203
203
|
| `DB_PATH` | `agent-relay.db` | SQLite database path |
|
|
204
|
-
| `STALE_TTL_MS` | `
|
|
204
|
+
| `STALE_TTL_MS` | `120000` | Mark agents offline after this heartbeat gap |
|
|
205
205
|
| `OFFLINE_PRUNE_MS` | `86400000` | Delete offline agents older than this |
|
|
206
206
|
| `REAP_INTERVAL_MS` | `60000` | Reaper cadence for stale/offline cleanup |
|
|
207
207
|
| `RETENTION_DAYS` | `30` | Auto-prune messages older than this |
|
|
@@ -311,6 +311,7 @@ claude/ # Claude Code plugin
|
|
|
311
311
|
├── monitors/monitors.json # Auto-start inbox monitor
|
|
312
312
|
└── hooks/
|
|
313
313
|
├── relay-monitor.sh # Register, inject context, poll
|
|
314
|
+
├── set-status.sh # Turn hooks: busy/idle status updates
|
|
314
315
|
├── session-end.sh # Mark agent offline
|
|
315
316
|
└── poll-inbox.sh # Inbox poll loop
|
|
316
317
|
|
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 {
|
|
@@ -656,10 +703,39 @@ async function start(args: string[]): Promise<void> {
|
|
|
656
703
|
stderr: "inherit",
|
|
657
704
|
});
|
|
658
705
|
|
|
659
|
-
const
|
|
660
|
-
|
|
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) {
|
|
661
729
|
const pid = spawnFallbackSidecar(runDir, env);
|
|
662
|
-
|
|
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
|
+
);
|
|
663
739
|
}
|
|
664
740
|
|
|
665
741
|
const exitCode = await codex.exited;
|
package/codex/README.md
CHANGED
|
@@ -97,6 +97,7 @@ Useful environment variables:
|
|
|
97
97
|
|
|
98
98
|
- `AGENT_RELAY_URL`
|
|
99
99
|
- `AGENT_RELAY_CAPS`
|
|
100
|
+
- `AGENT_RELAY_CODEX_HOOK_TIMEOUT_MS` (launcher wait for SessionStart handshake, default `5000`)
|
|
100
101
|
- `CODEX_APP_SERVER_URL`
|
|
101
102
|
- `CODEX_THREAD_ID`
|
|
102
103
|
- `CODEX_THREAD_MODE=auto|resume|start`
|
|
@@ -107,6 +108,10 @@ Useful environment variables:
|
|
|
107
108
|
- `CODEX_LIVE_RIG`
|
|
108
109
|
- `CODEX_MODEL`
|
|
109
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
|
+
|
|
110
115
|
## Notes
|
|
111
116
|
|
|
112
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.
|
|
@@ -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
|
@@ -90,13 +90,10 @@ async function main(): Promise<void> {
|
|
|
90
90
|
throw new Error(`missing runtime directory in launcher output\nstdout:\n${stdout}\nstderr:\n${stderr}`);
|
|
91
91
|
}
|
|
92
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}`);
|
|
95
|
-
}
|
|
96
|
-
|
|
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)) {
|
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
|
|
|
@@ -260,7 +282,7 @@
|
|
|
260
282
|
<div class="card agent-card" :class="{ selected: selectedAgent === a.id }">
|
|
261
283
|
<div class="card-body">
|
|
262
284
|
<div class="d-flex align-items-start gap-2">
|
|
263
|
-
<span class="status-dot mt-1" :class="[a.status, a.status !== 'offline' && !a.ready ? 'not-ready' : '']" :title="a
|
|
285
|
+
<span class="status-dot mt-1" :class="[a.status, a.status !== 'offline' && !a.ready ? 'not-ready' : '']" :title="agentStatusTitle(a)"></span>
|
|
264
286
|
<div class="flex-grow-1 min-width-0">
|
|
265
287
|
<div class="d-flex align-items-center gap-2">
|
|
266
288
|
<template x-if="a.label">
|
|
@@ -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
|
}
|
|
@@ -907,9 +946,21 @@ function relay() {
|
|
|
907
946
|
return agent ? this.displayName(agent) : target.slice(-8);
|
|
908
947
|
},
|
|
909
948
|
|
|
949
|
+
agentStatusTitle(agent) {
|
|
950
|
+
if (!agent) return '';
|
|
951
|
+
if (agent.status === 'offline') return 'offline';
|
|
952
|
+
if (agent.ready) return agent.status;
|
|
953
|
+
const lastSeenMs = new Date(agent.lastSeen).getTime();
|
|
954
|
+
if (!Number.isFinite(lastSeenMs)) return 'Trying to reconnect…';
|
|
955
|
+
const ageSec = Math.max(0, (Date.now() - lastSeenMs) / 1000);
|
|
956
|
+
return ageSec <= 45 ? 'Starting up…' : 'Trying to reconnect…';
|
|
957
|
+
},
|
|
958
|
+
|
|
910
959
|
timeAgo(iso) {
|
|
911
960
|
if (!iso) return '';
|
|
912
|
-
const
|
|
961
|
+
const ts = new Date(iso).getTime();
|
|
962
|
+
if (!Number.isFinite(ts)) return '';
|
|
963
|
+
const diff = Math.max(0, (Date.now() - ts) / 1000);
|
|
913
964
|
if (diff < 60) return Math.floor(diff) + 's ago';
|
|
914
965
|
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
|
915
966
|
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
|
@@ -1084,7 +1135,7 @@ function relay() {
|
|
|
1084
1135
|
for (const a of this.agents) counts[a.status] = (counts[a.status] || 0) + 1;
|
|
1085
1136
|
const labels = Object.keys(counts).filter(k => counts[k] > 0);
|
|
1086
1137
|
const series = labels.map(k => counts[k]);
|
|
1087
|
-
const colorMap = { online: '#48bb78', idle: '#
|
|
1138
|
+
const colorMap = { online: '#48bb78', idle: '#48bb78', busy: '#ecc94b', offline: '#718096' };
|
|
1088
1139
|
|
|
1089
1140
|
this.chartInstances.status = new ApexCharts(el, {
|
|
1090
1141
|
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
|
|