codex-agent-view 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +20 -0
- package/.codex-plugin/plugin.json +34 -0
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +327 -0
- package/assets/logo-dark.svg +13 -0
- package/assets/logo.svg +13 -0
- package/bin/codex-agent-view.mjs +489 -0
- package/hooks/hooks.json +62 -0
- package/package.json +59 -0
- package/public/app.js +637 -0
- package/public/index.html +137 -0
- package/public/styles.css +821 -0
- package/scripts/capture-hook.mjs +143 -0
- package/scripts/send-hook.mjs +64 -0
- package/skills/codex-agent-view/SKILL.md +21 -0
- package/src/core/index.mjs +3 -0
- package/src/core/monitor-store.mjs +332 -0
- package/src/core/normalize-hook-payload.mjs +146 -0
- package/src/runtime/config.mjs +111 -0
- package/src/runtime/server.mjs +203 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
appendFileSync,
|
|
5
|
+
chmodSync,
|
|
6
|
+
closeSync,
|
|
7
|
+
constants,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
openSync,
|
|
10
|
+
realpathSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { resolve } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const MAX_STDIN_BYTES = 2 * 1024 * 1024;
|
|
16
|
+
const SAFE_METADATA_FIELDS = new Set([
|
|
17
|
+
"agent_id",
|
|
18
|
+
"agent_type",
|
|
19
|
+
"hook_event_name",
|
|
20
|
+
"model",
|
|
21
|
+
"permission_mode",
|
|
22
|
+
"reason",
|
|
23
|
+
"session_id",
|
|
24
|
+
"source",
|
|
25
|
+
"stop_hook_active",
|
|
26
|
+
"tool_name",
|
|
27
|
+
"tool_use_id",
|
|
28
|
+
"trigger",
|
|
29
|
+
"turn_id",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
function summarizePrivateValue(value) {
|
|
33
|
+
if (value === null) {
|
|
34
|
+
return { redacted: true, type: "null" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (Array.isArray(value)) {
|
|
38
|
+
return { redacted: true, type: "array", length: value.length };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (typeof value === "object") {
|
|
42
|
+
return {
|
|
43
|
+
redacted: true,
|
|
44
|
+
type: "object",
|
|
45
|
+
keys: Object.keys(value).sort(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
redacted: true,
|
|
51
|
+
type: typeof value,
|
|
52
|
+
length: typeof value === "string" ? value.length : undefined,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function minimizePayload(payload, captureFull = false) {
|
|
57
|
+
if (captureFull) {
|
|
58
|
+
return payload;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return Object.fromEntries(
|
|
62
|
+
Object.entries(payload).map(([key, value]) => [
|
|
63
|
+
key,
|
|
64
|
+
SAFE_METADATA_FIELDS.has(key) ? value : summarizePrivateValue(value),
|
|
65
|
+
]),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function capturePath(env = process.env, cwd = process.cwd()) {
|
|
70
|
+
const base = env.CODEX_AGENT_VIEW_CAPTURE_DIR
|
|
71
|
+
? resolve(env.CODEX_AGENT_VIEW_CAPTURE_DIR)
|
|
72
|
+
: env.PLUGIN_DATA
|
|
73
|
+
? resolve(env.PLUGIN_DATA, "captures")
|
|
74
|
+
: resolve(cwd, ".codex-agent-view", "captures");
|
|
75
|
+
|
|
76
|
+
return { directory: base, file: resolve(base, "events.jsonl") };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function readStdin() {
|
|
80
|
+
const chunks = [];
|
|
81
|
+
let bytes = 0;
|
|
82
|
+
|
|
83
|
+
for await (const chunk of process.stdin) {
|
|
84
|
+
bytes += chunk.length;
|
|
85
|
+
if (bytes > MAX_STDIN_BYTES) {
|
|
86
|
+
throw new Error(`hook payload exceeds ${MAX_STDIN_BYTES} bytes`);
|
|
87
|
+
}
|
|
88
|
+
chunks.push(chunk);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function main() {
|
|
95
|
+
const raw = await readStdin();
|
|
96
|
+
const payload = JSON.parse(raw);
|
|
97
|
+
if (payload === null || Array.isArray(payload) || typeof payload !== "object") {
|
|
98
|
+
throw new TypeError("hook payload must be a JSON object");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { directory, file } = capturePath();
|
|
102
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
103
|
+
const flags =
|
|
104
|
+
constants.O_APPEND |
|
|
105
|
+
constants.O_CREAT |
|
|
106
|
+
constants.O_WRONLY |
|
|
107
|
+
(constants.O_NOFOLLOW ?? 0);
|
|
108
|
+
const descriptor = openSync(file, flags, 0o600);
|
|
109
|
+
|
|
110
|
+
const record = {
|
|
111
|
+
schema_version: 1,
|
|
112
|
+
captured_at_ms: Date.now(),
|
|
113
|
+
payload: minimizePayload(
|
|
114
|
+
payload,
|
|
115
|
+
process.env.CODEX_AGENT_VIEW_CAPTURE_FULL === "1",
|
|
116
|
+
),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
chmodSync(file, 0o600);
|
|
121
|
+
appendFileSync(descriptor, `${JSON.stringify(record)}\n`, {
|
|
122
|
+
encoding: "utf8",
|
|
123
|
+
});
|
|
124
|
+
} finally {
|
|
125
|
+
closeSync(descriptor);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A neutral JSON object is valid for every captured event and is required by
|
|
129
|
+
// events such as SubagentStop when a successful hook writes to stdout.
|
|
130
|
+
process.stdout.write("{}\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const isDirectRun =
|
|
134
|
+
process.argv[1] &&
|
|
135
|
+
realpathSync(fileURLToPath(import.meta.url)) ===
|
|
136
|
+
realpathSync(resolve(process.argv[1]));
|
|
137
|
+
|
|
138
|
+
if (isDirectRun) {
|
|
139
|
+
main().catch((error) => {
|
|
140
|
+
process.stderr.write(`codex-agent-view hook capture failed: ${error.message}\n`);
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { minimizePayload } from "./capture-hook.mjs";
|
|
4
|
+
import { readRuntimeInfo } from "../src/runtime/config.mjs";
|
|
5
|
+
|
|
6
|
+
const MAX_STDIN_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
const SEND_TIMEOUT_MS = 750;
|
|
8
|
+
|
|
9
|
+
async function readStdin() {
|
|
10
|
+
const chunks = [];
|
|
11
|
+
let bytes = 0;
|
|
12
|
+
for await (const chunk of process.stdin) {
|
|
13
|
+
bytes += chunk.length;
|
|
14
|
+
if (bytes > MAX_STDIN_BYTES) {
|
|
15
|
+
throw new Error(`hook payload exceeds ${MAX_STDIN_BYTES} bytes`);
|
|
16
|
+
}
|
|
17
|
+
chunks.push(chunk);
|
|
18
|
+
}
|
|
19
|
+
const payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
20
|
+
if (payload === null || Array.isArray(payload) || typeof payload !== "object") {
|
|
21
|
+
throw new TypeError("hook payload must be a JSON object");
|
|
22
|
+
}
|
|
23
|
+
return payload;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function debug(code) {
|
|
27
|
+
if (process.env.CODEX_AGENT_VIEW_DEBUG === "1") {
|
|
28
|
+
process.stderr.write(`codex-agent-view hook sender: ${code}\n`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function send(payload) {
|
|
33
|
+
const runtime = await readRuntimeInfo();
|
|
34
|
+
const response = await fetch(
|
|
35
|
+
`http://${runtime.host}:${runtime.port}/api/events`,
|
|
36
|
+
{
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
authorization: `Bearer ${runtime.token}`,
|
|
40
|
+
"content-type": "application/json",
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify(minimizePayload(payload)),
|
|
43
|
+
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new Error(`monitor returned HTTP ${response.status}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function main() {
|
|
52
|
+
try {
|
|
53
|
+
await send(await readStdin());
|
|
54
|
+
} catch {
|
|
55
|
+
// Monitoring is fail-open: an unavailable companion must not block Codex.
|
|
56
|
+
debug("delivery_failed");
|
|
57
|
+
}
|
|
58
|
+
process.stdout.write("{}\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
main().catch(() => {
|
|
62
|
+
debug("unexpected_failure");
|
|
63
|
+
process.stdout.write("{}\n");
|
|
64
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: codex-agent-view
|
|
3
|
+
description: Inspect and diagnose the local Codex Agent View companion monitor for the current parent task and its subagents. Use when the user asks to see task or subagent status, check whether the read-only monitor is healthy or stale, diagnose why local lifecycle events are unavailable, or explicitly start, install, or remove Codex Agent View.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Codex Agent View
|
|
7
|
+
|
|
8
|
+
Use the packaged CLI as the authority for monitor health and observed hook state.
|
|
9
|
+
|
|
10
|
+
1. Run `codex-agent-view status --json` first.
|
|
11
|
+
2. If it succeeds, summarize the monitor update time, observed parent task/session, subagent states, permission wait state, and relevant diagnostics. Preserve `unknown`, missing, duplicate, and out-of-order states instead of guessing that work started or completed.
|
|
12
|
+
3. If status fails because the monitor or runtime file is unavailable, run `codex-agent-view doctor --json`. Report the Codex CLI, plugin, monitor, and runtime-directory findings before suggesting a change.
|
|
13
|
+
4. Start the monitor only when the user explicitly asks to start it. Run `codex-agent-view start --no-open`, keep the returned local URL private, and then retry `codex-agent-view status --json` when the monitor is reachable.
|
|
14
|
+
|
|
15
|
+
Treat an empty session list as “no hook events observed by this monitor,” not proof that no Codex task or subagent exists. Explain that restarting the in-memory monitor clears previously observed state.
|
|
16
|
+
|
|
17
|
+
Run `codex-agent-view install` or `codex-agent-view uninstall` only when the user explicitly requests that lifecycle action. Explain that install changes local Codex plugin registration and requires hook review/trust. Before uninstalling, distinguish the default command, which preserves runtime data, from `codex-agent-view uninstall --purge`, which removes the configured runtime directory.
|
|
18
|
+
|
|
19
|
+
Keep the workflow read-only with respect to Codex tasks. Never stop or restart a task or subagent, send a message to an agent, approve or deny a permission request, or change Codex approval, sandbox, hook-trust, or telemetry settings. Never enable full debug capture or upload a capture without a separate explicit request and a sensitive-data warning.
|
|
20
|
+
|
|
21
|
+
Do not expose the monitor bearer token, runtime file contents, prompts, transcripts, tool inputs, or tool outputs. Report only the minimum state needed to answer the user's question.
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { normalizeHookPayload } from "./normalize-hook-payload.mjs";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_LIMITS = Object.freeze({
|
|
4
|
+
maxActivitiesPerSession: 100,
|
|
5
|
+
maxAgentsPerSession: 100,
|
|
6
|
+
maxDiagnostics: 100,
|
|
7
|
+
maxSessions: 50,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
function positiveInteger(value, name) {
|
|
11
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
12
|
+
throw new TypeError(`${name} must be a positive safe integer`);
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function createSession(event) {
|
|
18
|
+
return {
|
|
19
|
+
session_id: event.session_id,
|
|
20
|
+
first_seen_at_ms: event.received_at_ms,
|
|
21
|
+
last_seen_at_ms: event.received_at_ms,
|
|
22
|
+
agents: new Map(),
|
|
23
|
+
tools: new Map(),
|
|
24
|
+
permission: { status: "idle" },
|
|
25
|
+
recent_activities: [],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function deriveSessionStatus(session) {
|
|
30
|
+
if (session.permission.status === "waiting_for_user") {
|
|
31
|
+
return "waiting_for_user";
|
|
32
|
+
}
|
|
33
|
+
if (
|
|
34
|
+
[...session.agents.values()].some(({ status }) => status === "running") ||
|
|
35
|
+
[...session.tools.values()].some(({ status }) => status === "running")
|
|
36
|
+
) {
|
|
37
|
+
return "running";
|
|
38
|
+
}
|
|
39
|
+
return "observed";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function touchMapEntry(map, key, value) {
|
|
43
|
+
map.delete(key);
|
|
44
|
+
map.set(key, value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function trimMap(map, limit) {
|
|
48
|
+
while (map.size > limit) {
|
|
49
|
+
map.delete(map.keys().next().value);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function addActivity(session, event, status, limit) {
|
|
54
|
+
const activity = {
|
|
55
|
+
type: event.type,
|
|
56
|
+
status,
|
|
57
|
+
turn_id: event.turn_id,
|
|
58
|
+
received_at_ms: event.received_at_ms,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
for (const field of ["agent_id", "agent_type", "tool_name", "tool_use_id"]) {
|
|
62
|
+
if (field in event) {
|
|
63
|
+
activity[field] = event[field];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
session.recent_activities.unshift(activity);
|
|
68
|
+
if (session.recent_activities.length > limit) {
|
|
69
|
+
session.recent_activities.length = limit;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function applySubagentEvent(session, event, limits) {
|
|
74
|
+
let agent = session.agents.get(event.agent_id);
|
|
75
|
+
if (!agent) {
|
|
76
|
+
agent = {
|
|
77
|
+
agent_id: event.agent_id,
|
|
78
|
+
agent_type: event.agent_type,
|
|
79
|
+
status: "unknown",
|
|
80
|
+
started_at_ms: null,
|
|
81
|
+
stopped_at_ms: null,
|
|
82
|
+
last_seen_at_ms: event.received_at_ms,
|
|
83
|
+
has_out_of_order_events: false,
|
|
84
|
+
start_observed: false,
|
|
85
|
+
stop_observed: false,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (event.type === "subagent_started") {
|
|
90
|
+
if (agent.start_observed) {
|
|
91
|
+
return "duplicate";
|
|
92
|
+
}
|
|
93
|
+
agent.start_observed = true;
|
|
94
|
+
agent.started_at_ms = event.received_at_ms;
|
|
95
|
+
if (agent.stop_observed) {
|
|
96
|
+
agent.status = "stopped";
|
|
97
|
+
agent.has_out_of_order_events = true;
|
|
98
|
+
} else {
|
|
99
|
+
agent.status = "running";
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
if (agent.stop_observed) {
|
|
103
|
+
return "duplicate";
|
|
104
|
+
}
|
|
105
|
+
agent.stop_observed = true;
|
|
106
|
+
agent.stopped_at_ms = event.received_at_ms;
|
|
107
|
+
agent.status = agent.start_observed ? "stopped" : "stopped_without_start";
|
|
108
|
+
agent.has_out_of_order_events = !agent.start_observed;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
agent.agent_type = event.agent_type;
|
|
112
|
+
agent.last_seen_at_ms = Math.max(agent.last_seen_at_ms, event.received_at_ms);
|
|
113
|
+
touchMapEntry(session.agents, event.agent_id, agent);
|
|
114
|
+
trimMap(session.agents, limits.maxAgentsPerSession);
|
|
115
|
+
addActivity(session, event, agent.status, limits.maxActivitiesPerSession);
|
|
116
|
+
return "applied";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function createTool(event) {
|
|
120
|
+
return {
|
|
121
|
+
tool_use_id: event.tool_use_id,
|
|
122
|
+
tool_name: event.tool_name,
|
|
123
|
+
turn_id: event.turn_id,
|
|
124
|
+
status: "unknown",
|
|
125
|
+
started_at_ms: null,
|
|
126
|
+
completed_at_ms: null,
|
|
127
|
+
last_seen_at_ms: event.received_at_ms,
|
|
128
|
+
start_observed: false,
|
|
129
|
+
completion_observed: false,
|
|
130
|
+
has_out_of_order_events: false,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function applyToolEvent(session, event, limits) {
|
|
135
|
+
const tool = session.tools.get(event.tool_use_id) ?? createTool(event);
|
|
136
|
+
let activityStatus;
|
|
137
|
+
|
|
138
|
+
if (event.type === "tool_started") {
|
|
139
|
+
if (tool.start_observed) {
|
|
140
|
+
return "duplicate";
|
|
141
|
+
}
|
|
142
|
+
tool.start_observed = true;
|
|
143
|
+
tool.started_at_ms = event.received_at_ms;
|
|
144
|
+
if (tool.completion_observed) {
|
|
145
|
+
tool.status = "completed";
|
|
146
|
+
tool.has_out_of_order_events = true;
|
|
147
|
+
activityStatus = "late_start_observed";
|
|
148
|
+
} else {
|
|
149
|
+
tool.status = "running";
|
|
150
|
+
activityStatus = "running";
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
if (tool.completion_observed) {
|
|
154
|
+
return "duplicate";
|
|
155
|
+
}
|
|
156
|
+
tool.completion_observed = true;
|
|
157
|
+
tool.completed_at_ms = event.received_at_ms;
|
|
158
|
+
tool.status = tool.start_observed ? "completed" : "completed_without_start";
|
|
159
|
+
tool.has_out_of_order_events = !tool.start_observed;
|
|
160
|
+
activityStatus = tool.status;
|
|
161
|
+
|
|
162
|
+
const permission = session.permission;
|
|
163
|
+
if (
|
|
164
|
+
permission.status === "waiting_for_user" &&
|
|
165
|
+
permission.tool_name === event.tool_name &&
|
|
166
|
+
permission.turn_id === event.turn_id &&
|
|
167
|
+
event.received_at_ms >= permission.requested_at_ms
|
|
168
|
+
) {
|
|
169
|
+
session.permission = { status: "idle" };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
tool.tool_name = event.tool_name;
|
|
174
|
+
tool.turn_id = event.turn_id;
|
|
175
|
+
tool.last_seen_at_ms = Math.max(tool.last_seen_at_ms, event.received_at_ms);
|
|
176
|
+
touchMapEntry(session.tools, event.tool_use_id, tool);
|
|
177
|
+
trimMap(session.tools, limits.maxActivitiesPerSession);
|
|
178
|
+
addActivity(session, event, activityStatus, limits.maxActivitiesPerSession);
|
|
179
|
+
return "applied";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function applyPermissionEvent(session, event, limits) {
|
|
183
|
+
const permission = session.permission;
|
|
184
|
+
if (
|
|
185
|
+
permission.status === "waiting_for_user" &&
|
|
186
|
+
permission.tool_name === event.tool_name &&
|
|
187
|
+
permission.turn_id === event.turn_id
|
|
188
|
+
) {
|
|
189
|
+
return "duplicate";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (
|
|
193
|
+
permission.status === "waiting_for_user" &&
|
|
194
|
+
event.received_at_ms < permission.requested_at_ms
|
|
195
|
+
) {
|
|
196
|
+
return "stale";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
session.permission = {
|
|
200
|
+
status: "waiting_for_user",
|
|
201
|
+
tool_name: event.tool_name,
|
|
202
|
+
turn_id: event.turn_id,
|
|
203
|
+
requested_at_ms: event.received_at_ms,
|
|
204
|
+
};
|
|
205
|
+
addActivity(
|
|
206
|
+
session,
|
|
207
|
+
event,
|
|
208
|
+
"waiting_for_user",
|
|
209
|
+
limits.maxActivitiesPerSession,
|
|
210
|
+
);
|
|
211
|
+
return "applied";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function applyEvent(session, event, limits) {
|
|
215
|
+
if (event.type === "subagent_started" || event.type === "subagent_stopped") {
|
|
216
|
+
return applySubagentEvent(session, event, limits);
|
|
217
|
+
}
|
|
218
|
+
if (event.type === "tool_started" || event.type === "tool_completed") {
|
|
219
|
+
return applyToolEvent(session, event, limits);
|
|
220
|
+
}
|
|
221
|
+
return applyPermissionEvent(session, event, limits);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function snapshotSession(session) {
|
|
225
|
+
return {
|
|
226
|
+
session_id: session.session_id,
|
|
227
|
+
status: deriveSessionStatus(session),
|
|
228
|
+
first_seen_at_ms: session.first_seen_at_ms,
|
|
229
|
+
last_seen_at_ms: session.last_seen_at_ms,
|
|
230
|
+
agents: [...session.agents.values()]
|
|
231
|
+
.map(({ start_observed, stop_observed, ...agent }) => ({ ...agent }))
|
|
232
|
+
.sort((left, right) => right.last_seen_at_ms - left.last_seen_at_ms),
|
|
233
|
+
recent_activities: session.recent_activities.map((activity) => ({
|
|
234
|
+
...activity,
|
|
235
|
+
})),
|
|
236
|
+
permission: { ...session.permission },
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Create a bounded, process-local monitor store whose only input is hook data. */
|
|
241
|
+
export function createMonitorStore(options = {}) {
|
|
242
|
+
const limits = {
|
|
243
|
+
maxActivitiesPerSession: positiveInteger(
|
|
244
|
+
options.maxActivitiesPerSession ?? DEFAULT_LIMITS.maxActivitiesPerSession,
|
|
245
|
+
"maxActivitiesPerSession",
|
|
246
|
+
),
|
|
247
|
+
maxAgentsPerSession: positiveInteger(
|
|
248
|
+
options.maxAgentsPerSession ?? DEFAULT_LIMITS.maxAgentsPerSession,
|
|
249
|
+
"maxAgentsPerSession",
|
|
250
|
+
),
|
|
251
|
+
maxDiagnostics: positiveInteger(
|
|
252
|
+
options.maxDiagnostics ?? DEFAULT_LIMITS.maxDiagnostics,
|
|
253
|
+
"maxDiagnostics",
|
|
254
|
+
),
|
|
255
|
+
maxSessions: positiveInteger(
|
|
256
|
+
options.maxSessions ?? DEFAULT_LIMITS.maxSessions,
|
|
257
|
+
"maxSessions",
|
|
258
|
+
),
|
|
259
|
+
};
|
|
260
|
+
const now = options.now ?? Date.now;
|
|
261
|
+
if (typeof now !== "function") {
|
|
262
|
+
throw new TypeError("now must be a function");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const sessions = new Map();
|
|
266
|
+
const diagnostics = [];
|
|
267
|
+
let updatedAtMs = 0;
|
|
268
|
+
|
|
269
|
+
function addDiagnostic(diagnostic) {
|
|
270
|
+
diagnostics.unshift({ ...diagnostic });
|
|
271
|
+
if (diagnostics.length > limits.maxDiagnostics) {
|
|
272
|
+
diagnostics.length = limits.maxDiagnostics;
|
|
273
|
+
}
|
|
274
|
+
updatedAtMs = Math.max(updatedAtMs, diagnostic.diagnosed_at_ms);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function ingest(payload, ingestOptions = {}) {
|
|
278
|
+
const receivedAtMs = ingestOptions.receivedAtMs ?? now();
|
|
279
|
+
const normalized = normalizeHookPayload(payload, { receivedAtMs });
|
|
280
|
+
if (normalized.status === "ignored") {
|
|
281
|
+
addDiagnostic(normalized.diagnostic);
|
|
282
|
+
return normalized;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const { event } = normalized;
|
|
286
|
+
let session = sessions.get(event.session_id);
|
|
287
|
+
if (!session) {
|
|
288
|
+
session = createSession(event);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const status = applyEvent(session, event, limits);
|
|
292
|
+
if (status === "duplicate") {
|
|
293
|
+
return { status, event };
|
|
294
|
+
}
|
|
295
|
+
if (status === "stale") {
|
|
296
|
+
const diagnostic = {
|
|
297
|
+
code: "stale_event_ignored",
|
|
298
|
+
diagnosed_at_ms: event.received_at_ms,
|
|
299
|
+
field: "received_at_ms",
|
|
300
|
+
};
|
|
301
|
+
addDiagnostic(diagnostic);
|
|
302
|
+
return { status, event, diagnostic };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
session.first_seen_at_ms = Math.min(
|
|
306
|
+
session.first_seen_at_ms,
|
|
307
|
+
event.received_at_ms,
|
|
308
|
+
);
|
|
309
|
+
session.last_seen_at_ms = Math.max(
|
|
310
|
+
session.last_seen_at_ms,
|
|
311
|
+
event.received_at_ms,
|
|
312
|
+
);
|
|
313
|
+
touchMapEntry(sessions, session.session_id, session);
|
|
314
|
+
trimMap(sessions, limits.maxSessions);
|
|
315
|
+
updatedAtMs = Math.max(updatedAtMs, event.received_at_ms);
|
|
316
|
+
return { status: "applied", event };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function getSnapshot() {
|
|
320
|
+
return {
|
|
321
|
+
schema_version: 1,
|
|
322
|
+
source_of_truth: "hook",
|
|
323
|
+
updated_at_ms: updatedAtMs,
|
|
324
|
+
sessions: [...sessions.values()]
|
|
325
|
+
.map(snapshotSession)
|
|
326
|
+
.sort((left, right) => right.last_seen_at_ms - left.last_seen_at_ms),
|
|
327
|
+
diagnostics: diagnostics.map((diagnostic) => ({ ...diagnostic })),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return Object.freeze({ ingest, getSnapshot });
|
|
332
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
const NORMALIZED_EVENT_TYPES = Object.freeze({
|
|
2
|
+
PermissionRequest: "permission_requested",
|
|
3
|
+
PostToolUse: "tool_completed",
|
|
4
|
+
PreToolUse: "tool_started",
|
|
5
|
+
SubagentStart: "subagent_started",
|
|
6
|
+
SubagentStop: "subagent_stopped",
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const MAX_IDENTIFIER_LENGTH = 512;
|
|
10
|
+
const MAX_LABEL_LENGTH = 256;
|
|
11
|
+
|
|
12
|
+
function isObject(value) {
|
|
13
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isBoundedString(value, maxLength = MAX_IDENTIFIER_LENGTH) {
|
|
17
|
+
return (
|
|
18
|
+
typeof value === "string" &&
|
|
19
|
+
value.length > 0 &&
|
|
20
|
+
value.length <= maxLength
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ignored(code, diagnosedAtMs, field) {
|
|
25
|
+
return {
|
|
26
|
+
status: "ignored",
|
|
27
|
+
diagnostic: {
|
|
28
|
+
code,
|
|
29
|
+
diagnosed_at_ms: diagnosedAtMs,
|
|
30
|
+
...(field ? { field } : {}),
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function requiredString(payload, field, diagnosedAtMs, maxLength) {
|
|
36
|
+
if (!(field in payload)) {
|
|
37
|
+
return ignored("missing_required_field", diagnosedAtMs, field);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!isBoundedString(payload[field], maxLength)) {
|
|
41
|
+
return ignored("invalid_field", diagnosedAtMs, field);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveReceivedAtMs(options) {
|
|
48
|
+
const receivedAtMs = options?.receivedAtMs ?? Date.now();
|
|
49
|
+
if (!Number.isSafeInteger(receivedAtMs) || receivedAtMs < 0) {
|
|
50
|
+
throw new TypeError("receivedAtMs must be a non-negative safe integer");
|
|
51
|
+
}
|
|
52
|
+
return receivedAtMs;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function commonEvent(payload, type, receivedAtMs) {
|
|
56
|
+
return {
|
|
57
|
+
schema_version: 1,
|
|
58
|
+
source: "hook",
|
|
59
|
+
type,
|
|
60
|
+
session_id: payload.session_id,
|
|
61
|
+
turn_id: payload.turn_id,
|
|
62
|
+
received_at_ms: receivedAtMs,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Validate an untrusted Codex hook payload and retain only monitor-safe fields.
|
|
68
|
+
* Raw prompts, tool input/output, paths, and assistant messages are never copied.
|
|
69
|
+
*/
|
|
70
|
+
export function normalizeHookPayload(payload, options = {}) {
|
|
71
|
+
const receivedAtMs = resolveReceivedAtMs(options);
|
|
72
|
+
if (!isObject(payload)) {
|
|
73
|
+
return ignored("malformed_payload", receivedAtMs);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const eventNameError = requiredString(
|
|
77
|
+
payload,
|
|
78
|
+
"hook_event_name",
|
|
79
|
+
receivedAtMs,
|
|
80
|
+
MAX_LABEL_LENGTH,
|
|
81
|
+
);
|
|
82
|
+
if (eventNameError) {
|
|
83
|
+
return eventNameError;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const type = NORMALIZED_EVENT_TYPES[payload.hook_event_name];
|
|
87
|
+
if (!type) {
|
|
88
|
+
return ignored("unsupported_hook_event", receivedAtMs, "hook_event_name");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const field of ["session_id", "turn_id"]) {
|
|
92
|
+
const error = requiredString(payload, field, receivedAtMs);
|
|
93
|
+
if (error) {
|
|
94
|
+
return error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const event = commonEvent(payload, type, receivedAtMs);
|
|
99
|
+
|
|
100
|
+
if (type === "subagent_started" || type === "subagent_stopped") {
|
|
101
|
+
for (const [field, maxLength] of [
|
|
102
|
+
["agent_id", MAX_IDENTIFIER_LENGTH],
|
|
103
|
+
["agent_type", MAX_LABEL_LENGTH],
|
|
104
|
+
]) {
|
|
105
|
+
const error = requiredString(payload, field, receivedAtMs, maxLength);
|
|
106
|
+
if (error) {
|
|
107
|
+
return error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
event.agent_id = payload.agent_id;
|
|
112
|
+
event.agent_type = payload.agent_type;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (type === "tool_started" || type === "tool_completed") {
|
|
116
|
+
for (const [field, maxLength] of [
|
|
117
|
+
["tool_name", MAX_LABEL_LENGTH],
|
|
118
|
+
["tool_use_id", MAX_IDENTIFIER_LENGTH],
|
|
119
|
+
]) {
|
|
120
|
+
const error = requiredString(payload, field, receivedAtMs, maxLength);
|
|
121
|
+
if (error) {
|
|
122
|
+
return error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
event.tool_name = payload.tool_name;
|
|
127
|
+
event.tool_use_id = payload.tool_use_id;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (type === "permission_requested") {
|
|
131
|
+
const error = requiredString(
|
|
132
|
+
payload,
|
|
133
|
+
"tool_name",
|
|
134
|
+
receivedAtMs,
|
|
135
|
+
MAX_LABEL_LENGTH,
|
|
136
|
+
);
|
|
137
|
+
if (error) {
|
|
138
|
+
return error;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
event.tool_name = payload.tool_name;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { status: "accepted", event };
|
|
145
|
+
}
|
|
146
|
+
|