negotium 0.1.6 → 0.1.8
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 -2
- package/dist/chunk-0ay6cjdy.js +1331 -0
- package/dist/chunk-0ay6cjdy.js.map +16 -0
- package/dist/chunk-1k7t7bnx.js +97 -0
- package/dist/chunk-1k7t7bnx.js.map +10 -0
- package/dist/chunk-5ahkew9c.js +806 -0
- package/dist/chunk-5ahkew9c.js.map +17 -0
- package/dist/chunk-6cpn7ht2.js +287 -0
- package/dist/chunk-6cpn7ht2.js.map +13 -0
- package/dist/chunk-6kctn20e.js +3529 -0
- package/dist/chunk-6kctn20e.js.map +20 -0
- package/dist/chunk-7xqj7aex.js +77 -0
- package/dist/chunk-7xqj7aex.js.map +11 -0
- package/dist/chunk-9p0ww3tk.js +41 -0
- package/dist/chunk-9p0ww3tk.js.map +10 -0
- package/dist/chunk-a92a7nrk.js +252 -0
- package/dist/chunk-a92a7nrk.js.map +10 -0
- package/dist/chunk-c500xe9z.js +33 -0
- package/dist/chunk-c500xe9z.js.map +10 -0
- package/dist/chunk-cds1qxsf.js +1644 -0
- package/dist/chunk-cds1qxsf.js.map +17 -0
- package/dist/chunk-e4407g8x.js +274 -0
- package/dist/chunk-e4407g8x.js.map +10 -0
- package/dist/chunk-e5pbggsh.js +363 -0
- package/dist/chunk-e5pbggsh.js.map +11 -0
- package/dist/chunk-edb07sjp.js +567 -0
- package/dist/chunk-edb07sjp.js.map +14 -0
- package/dist/chunk-exd0v74a.js +3012 -0
- package/dist/chunk-exd0v74a.js.map +32 -0
- package/dist/chunk-fgrme9tr.js +4398 -0
- package/dist/chunk-fgrme9tr.js.map +47 -0
- package/dist/chunk-fka9eqe1.js +581 -0
- package/dist/chunk-fka9eqe1.js.map +11 -0
- package/dist/chunk-g82jygez.js +108 -0
- package/dist/chunk-g82jygez.js.map +10 -0
- package/dist/chunk-ghs2j1fr.js +312 -0
- package/dist/chunk-ghs2j1fr.js.map +10 -0
- package/dist/chunk-gmy11efb.js +125 -0
- package/dist/chunk-gmy11efb.js.map +10 -0
- package/dist/chunk-gxma4c70.js +110 -0
- package/dist/chunk-gxma4c70.js.map +10 -0
- package/dist/chunk-hkdqhm34.js +61 -0
- package/dist/chunk-hkdqhm34.js.map +10 -0
- package/dist/chunk-hpa0cpzh.js +134 -0
- package/dist/chunk-hpa0cpzh.js.map +10 -0
- package/dist/chunk-hwy3n31w.js +21 -0
- package/dist/chunk-hwy3n31w.js.map +9 -0
- package/dist/chunk-j2behpy7.js +214 -0
- package/dist/chunk-j2behpy7.js.map +10 -0
- package/dist/chunk-jc0at0tc.js +102 -0
- package/dist/chunk-jc0at0tc.js.map +10 -0
- package/dist/chunk-jkqq5ef6.js +243 -0
- package/dist/chunk-jkqq5ef6.js.map +11 -0
- package/dist/chunk-jzkw2f01.js +51 -0
- package/dist/chunk-jzkw2f01.js.map +10 -0
- package/dist/chunk-jzz91jwh.js +70 -0
- package/dist/chunk-jzz91jwh.js.map +10 -0
- package/dist/chunk-k9z47mab.js +247 -0
- package/dist/chunk-k9z47mab.js.map +12 -0
- package/dist/chunk-n17yxpa8.js +23 -0
- package/dist/chunk-n17yxpa8.js.map +10 -0
- package/dist/chunk-naa09ags.js +2143 -0
- package/dist/chunk-naa09ags.js.map +15 -0
- package/dist/chunk-ns3ke88n.js +142 -0
- package/dist/chunk-ns3ke88n.js.map +10 -0
- package/dist/chunk-p3cr2ejg.js +1626 -0
- package/dist/chunk-p3cr2ejg.js.map +15 -0
- package/dist/chunk-q5bcpf23.js +2925 -0
- package/dist/chunk-q5bcpf23.js.map +20 -0
- package/dist/chunk-rafc55bs.js +424 -0
- package/dist/chunk-rafc55bs.js.map +11 -0
- package/dist/chunk-rbrxjvbj.js +2830 -0
- package/dist/chunk-rbrxjvbj.js.map +23 -0
- package/dist/chunk-sa874j5c.js +9 -0
- package/dist/chunk-sa874j5c.js.map +10 -0
- package/dist/chunk-sgsz2fpq.js +289 -0
- package/dist/chunk-sgsz2fpq.js.map +10 -0
- package/dist/chunk-sm6fh5c5.js +569 -0
- package/dist/chunk-sm6fh5c5.js.map +14 -0
- package/dist/chunk-t9zcg8f1.js +499 -0
- package/dist/chunk-t9zcg8f1.js.map +13 -0
- package/dist/chunk-vp2db42d.js +121 -0
- package/dist/chunk-vp2db42d.js.map +10 -0
- package/dist/chunk-w13jjf4a.js +46 -0
- package/dist/chunk-w13jjf4a.js.map +10 -0
- package/dist/chunk-x1arr7ze.js +63 -0
- package/dist/chunk-x1arr7ze.js.map +10 -0
- package/dist/chunk-xfq68rch.js +583 -0
- package/dist/chunk-xfq68rch.js.map +11 -0
- package/dist/main.js +132 -20
- package/dist/main.js.map +4 -4
- package/dist/runtime/scripts/faster-whisper-wrapper.py +87 -0
- package/dist/runtime/scripts/mcp-patchright-http.mjs +349 -0
- package/dist/runtime/src/agents/api-topic-agent-switch.ts +178 -0
- package/dist/runtime/src/agents/archiver.ts +396 -0
- package/dist/runtime/src/agents/auth-check.ts +64 -0
- package/dist/runtime/src/agents/claude-provider.ts +737 -0
- package/dist/runtime/src/agents/claude-registry.ts +115 -0
- package/dist/runtime/src/agents/codex-native-multi-agent.ts +177 -0
- package/dist/runtime/src/agents/codex-provider.ts +785 -0
- package/dist/runtime/src/agents/codex-registry.ts +115 -0
- package/dist/runtime/src/agents/codex-tree-kill.ts +365 -0
- package/dist/runtime/src/agents/contracts.ts +69 -0
- package/dist/runtime/src/agents/deep-map.ts +13 -0
- package/dist/runtime/src/agents/execution-host.ts +91 -0
- package/dist/runtime/src/agents/fixtures/claude-attachments.jsonl +2 -0
- package/dist/runtime/src/agents/fixtures/codex-shell.jsonl +5 -0
- package/dist/runtime/src/agents/fork.ts +65 -0
- package/dist/runtime/src/agents/hosted-agent.ts +48 -0
- package/dist/runtime/src/agents/idle-archiver.ts +128 -0
- package/dist/runtime/src/agents/index.ts +205 -0
- package/dist/runtime/src/agents/maestro-provider.ts +206 -0
- package/dist/runtime/src/agents/maestro-registry.ts +31 -0
- package/dist/runtime/src/agents/mcp-tools/ask-user.ts +280 -0
- package/dist/runtime/src/agents/mcp-tools/common.ts +21 -0
- package/dist/runtime/src/agents/mcp-tools/self-config.ts +220 -0
- package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +525 -0
- package/dist/runtime/src/agents/mcp-tools/visuals.ts +88 -0
- package/dist/runtime/src/agents/model-catalog.ts +151 -0
- package/dist/runtime/src/agents/registry.ts +24 -0
- package/dist/runtime/src/agents/rollout/claude.ts +331 -0
- package/dist/runtime/src/agents/rollout/codex.ts +569 -0
- package/dist/runtime/src/agents/rollout/shared.ts +205 -0
- package/dist/runtime/src/agents/self-config-core.ts +415 -0
- package/dist/runtime/src/agents/task-events.ts +44 -0
- package/dist/runtime/src/agents/tool-format.ts +352 -0
- package/dist/runtime/src/agents/topic-agent-switch.ts +193 -0
- package/dist/runtime/src/agents/topic-cleanup.ts +231 -0
- package/dist/runtime/src/agents/vault-tool-policy.ts +33 -0
- package/dist/runtime/src/application/execute-external-user-turn.ts +23 -0
- package/dist/runtime/src/application/submit-user-message.ts +47 -0
- package/dist/runtime/src/application/switch-topic-effort.ts +48 -0
- package/dist/runtime/src/application/switch-topic-model.ts +79 -0
- package/dist/runtime/src/application/topic-service.ts +139 -0
- package/dist/runtime/src/application/vault-command.ts +170 -0
- package/dist/runtime/src/bus.ts +319 -0
- package/dist/runtime/src/index.ts +333 -0
- package/dist/runtime/src/mcp/agent-health-server.ts +309 -0
- package/dist/runtime/src/mcp/background-bash-server.ts +346 -0
- package/dist/runtime/src/mcp/canonical-bridge-config.ts +84 -0
- package/dist/runtime/src/mcp/canonical-proxy-server.ts +171 -0
- package/dist/runtime/src/mcp/dm-ipc.ts +60 -0
- package/dist/runtime/src/mcp/mcp-helpers.ts +40 -0
- package/dist/runtime/src/mcp/paddleocr-server.ts +105 -0
- package/dist/runtime/src/mcp/peer-bridge.ts +114 -0
- package/dist/runtime/src/mcp/runtime-spec.ts +134 -0
- package/dist/runtime/src/mcp/session-comm/bridge-ipc-config.ts +27 -0
- package/dist/runtime/src/mcp/session-comm/peer-forward.ts +165 -0
- package/dist/runtime/src/mcp/session-comm/runtime.ts +115 -0
- package/dist/runtime/src/mcp/session-comm/server.ts +649 -0
- package/dist/runtime/src/mcp/session-comm/topic-config.ts +131 -0
- package/dist/runtime/src/mcp/session-comm/topics.ts +229 -0
- package/dist/runtime/src/mcp/stdio-protect.ts +84 -0
- package/dist/runtime/src/mcp/system-health-server.ts +130 -0
- package/dist/runtime/src/mcp/task-server.ts +173 -0
- package/dist/runtime/src/mcp/token-stats-server.ts +98 -0
- package/dist/runtime/src/mcp/vault-http.ts +219 -0
- package/dist/runtime/src/mcp/vault-run.ts +153 -0
- package/dist/runtime/src/mcp/vault-server.ts +104 -0
- package/dist/runtime/src/mcp/wiki-server.ts +755 -0
- package/dist/runtime/src/media/file-events.ts +33 -0
- package/dist/runtime/src/media/text-extractor.ts +312 -0
- package/dist/runtime/src/media/video.ts +92 -0
- package/dist/runtime/src/outbox/file-ops.ts +210 -0
- package/dist/runtime/src/outbox/index.ts +8 -0
- package/dist/runtime/src/outbox/utils.ts +51 -0
- package/dist/runtime/src/platform/background-bash/manager.ts +279 -0
- package/dist/runtime/src/platform/config.ts +371 -0
- package/dist/runtime/src/platform/constants.ts +18 -0
- package/dist/runtime/src/platform/debug-users.ts +14 -0
- package/dist/runtime/src/platform/delay.ts +1 -0
- package/dist/runtime/src/platform/error.ts +9 -0
- package/dist/runtime/src/platform/file-utils.ts +16 -0
- package/dist/runtime/src/platform/jsonl.ts +202 -0
- package/dist/runtime/src/platform/lifecycle.ts +174 -0
- package/dist/runtime/src/platform/logger.ts +31 -0
- package/dist/runtime/src/platform/maestro-bootstrap-env.ts +3 -0
- package/dist/runtime/src/platform/mcp-config.ts +865 -0
- package/dist/runtime/src/platform/modules.ts +145 -0
- package/dist/runtime/src/platform/node-plugins.ts +46 -0
- package/dist/runtime/src/platform/playwright/manager.ts +1233 -0
- package/dist/runtime/src/prompts/agents/wiki-archiver.md +261 -0
- package/dist/runtime/src/prompts/builders.ts +357 -0
- package/dist/runtime/src/prompts/sessions/channel-system.md +48 -0
- package/dist/runtime/src/prompts/sessions/manager-system.md +37 -0
- package/dist/runtime/src/prompts/sessions/topic-system.md +47 -0
- package/dist/runtime/src/prompts/sessions/visual-design.md +86 -0
- package/dist/runtime/src/query/active-rooms.ts +532 -0
- package/dist/runtime/src/query/control.ts +313 -0
- package/dist/runtime/src/query/index.ts +24 -0
- package/dist/runtime/src/query/session-inbox-cleanup.ts +44 -0
- package/dist/runtime/src/query/session-inbox-path.ts +42 -0
- package/dist/runtime/src/query/state.ts +94 -0
- package/dist/runtime/src/query/types.ts +83 -0
- package/dist/runtime/src/runtime/ask-callbacks.ts +153 -0
- package/dist/runtime/src/runtime/attachments.ts +151 -0
- package/dist/runtime/src/runtime/background-sessions.ts +83 -0
- package/dist/runtime/src/runtime/channel-context.ts +107 -0
- package/dist/runtime/src/runtime/errors.ts +69 -0
- package/dist/runtime/src/runtime/event-heartbeat.ts +77 -0
- package/dist/runtime/src/runtime/file-hooks.ts +81 -0
- package/dist/runtime/src/runtime/footer.ts +23 -0
- package/dist/runtime/src/runtime/inbox.ts +946 -0
- package/dist/runtime/src/runtime/tasks.ts +56 -0
- package/dist/runtime/src/runtime/topic-config.ts +29 -0
- package/dist/runtime/src/runtime/turn-runner.ts +2286 -0
- package/dist/runtime/src/runtime/usage-alert.ts +84 -0
- package/dist/runtime/src/runtime/visual-html.ts +109 -0
- package/dist/runtime/src/runtime/visual-store.ts +430 -0
- package/dist/runtime/src/runtime/visuals.ts +171 -0
- package/dist/runtime/src/security/sanitize.ts +27 -0
- package/dist/runtime/src/security/sensitive-path.ts +34 -0
- package/dist/runtime/src/storage/activity-log.ts +152 -0
- package/dist/runtime/src/storage/api-messages.ts +617 -0
- package/dist/runtime/src/storage/api-topic-brief.ts +127 -0
- package/dist/runtime/src/storage/api-topic-config.ts +192 -0
- package/dist/runtime/src/storage/api-topics.ts +844 -0
- package/dist/runtime/src/storage/app-settings.ts +44 -0
- package/dist/runtime/src/storage/browser-profiles.ts +133 -0
- package/dist/runtime/src/storage/conversations.ts +268 -0
- package/dist/runtime/src/storage/forum/agent-settings.ts +20 -0
- package/dist/runtime/src/storage/forum/index.ts +4 -0
- package/dist/runtime/src/storage/forum/repository.ts +283 -0
- package/dist/runtime/src/storage/forum/schema.ts +300 -0
- package/dist/runtime/src/storage/forum-db.ts +23 -0
- package/dist/runtime/src/storage/runtime-events.ts +130 -0
- package/dist/runtime/src/storage/runtime-leases.ts +189 -0
- package/dist/runtime/src/storage/runtime-process-leases.ts +216 -0
- package/dist/runtime/src/storage/runtime-topic-state.ts +148 -0
- package/dist/runtime/src/storage/runtime-turn-requests.ts +300 -0
- package/dist/runtime/src/storage/self-schedules.ts +298 -0
- package/dist/runtime/src/storage/session-asks.ts +347 -0
- package/dist/runtime/src/storage/sqlite.ts +129 -0
- package/dist/runtime/src/storage/tasks.ts +179 -0
- package/dist/runtime/src/storage/token-stats.ts +144 -0
- package/dist/runtime/src/storage/topic-archive-state.ts +59 -0
- package/dist/runtime/src/storage/topic-archive.ts +74 -0
- package/dist/runtime/src/storage/topic-settings.ts +67 -0
- package/dist/runtime/src/storage/topic-transcript.ts +98 -0
- package/dist/runtime/src/storage/vault-crypto.ts +65 -0
- package/dist/runtime/src/storage/vault.ts +225 -0
- package/dist/runtime/src/storage/wiki-summary-names.ts +33 -0
- package/dist/runtime/src/storage/wiki.ts +85 -0
- package/dist/runtime/src/topics/create.ts +123 -0
- package/dist/runtime/src/topics/derive.ts +398 -0
- package/dist/runtime/src/topics/lifecycle.ts +311 -0
- package/dist/runtime/src/topics/links.ts +7 -0
- package/dist/runtime/src/topics/personal-general.ts +65 -0
- package/dist/runtime/src/topics/session.ts +381 -0
- package/dist/runtime/src/types/api.ts +497 -0
- package/dist/runtime/src/types.ts +255 -0
- package/dist/runtime/src/version.ts +1 -0
- package/dist/runtime/tsconfig.json +13 -0
- package/package.json +16 -3
- package/dist/main.d.ts +0 -3
- package/dist/main.d.ts.map +0 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** "from" field for session-inbox entries injected after a setting change. */
|
|
2
|
+
export const FROM_AUTO_CONTINUE = "auto-continue";
|
|
3
|
+
|
|
4
|
+
/** "from" field for durable delayed continuations created by schedule_self. */
|
|
5
|
+
export const FROM_SELF_SCHEDULE = "self-schedule";
|
|
6
|
+
|
|
7
|
+
/** Topic names that collide with internal sentinels and must not be used. */
|
|
8
|
+
export const RESERVED_TOPIC_NAMES: ReadonlySet<string> = new Set([
|
|
9
|
+
FROM_AUTO_CONTINUE,
|
|
10
|
+
FROM_SELF_SCHEDULE,
|
|
11
|
+
"general",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/** Legacy shared General id. New General rooms use per-user UUIDs. */
|
|
15
|
+
export const GENERAL_TOPIC_ID = "general";
|
|
16
|
+
|
|
17
|
+
/** Internal topic name for the forum manager session (General topic). */
|
|
18
|
+
export const TOPIC_MANAGER = "__manager__";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { DEBUG_FILE } from "#platform/config";
|
|
3
|
+
import { readJsonFile } from "#platform/jsonl";
|
|
4
|
+
|
|
5
|
+
/** Load the set of debug-enabled user IDs from disk. Returns empty set on error. */
|
|
6
|
+
export function loadDebugUsers(): Set<string> {
|
|
7
|
+
const arr = readJsonFile<string[]>(DEBUG_FILE);
|
|
8
|
+
return new Set(arr ?? []);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Persist the debug users set to disk. */
|
|
12
|
+
export function saveDebugUsers(users: Set<string>): void {
|
|
13
|
+
writeFileSync(DEBUG_FILE, JSON.stringify([...users], null, 2));
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stringify an unknown thrown value for logging or user-visible messages.
|
|
3
|
+
* Default fallback is `String(e)`. Pass an explicit fallback (e.g. "unknown")
|
|
4
|
+
* to override what non-Error throws turn into.
|
|
5
|
+
*/
|
|
6
|
+
export function errMsg(e: unknown, fallback?: string): string {
|
|
7
|
+
if (e instanceof Error) return e.message;
|
|
8
|
+
return fallback ?? String(e);
|
|
9
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { unlinkSync } from "node:fs";
|
|
2
|
+
import { logger } from "#platform/logger";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Best-effort unlink. ENOENT is always swallowed (the file is already gone).
|
|
6
|
+
* Other errors are silent unless `warnLabel` is provided, in which case they
|
|
7
|
+
* are logged at warn level with `{ err, path }` context.
|
|
8
|
+
*/
|
|
9
|
+
export function safeUnlink(path: string, warnLabel?: string): void {
|
|
10
|
+
try {
|
|
11
|
+
unlinkSync(path);
|
|
12
|
+
} catch (e) {
|
|
13
|
+
if ((e as NodeJS.ErrnoException)?.code === "ENOENT") return;
|
|
14
|
+
if (warnLabel) logger.warn({ err: e, path }, warnLabel);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
closeSync,
|
|
4
|
+
fsyncSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
statSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { dirname } from "node:path";
|
|
14
|
+
import { safeUnlink } from "#platform/file-utils";
|
|
15
|
+
|
|
16
|
+
export function readJsonlLines(filePath: string): string[] {
|
|
17
|
+
return readFileSync(filePath, "utf-8").trim().split("\n").filter(Boolean);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse a JSONL string (one JSON value per line) into an array of `T`.
|
|
22
|
+
* Throws on malformed lines — use when a corrupt fixture/log should fail
|
|
23
|
+
* loudly rather than silently drop entries (paired with `readJsonlLines`'
|
|
24
|
+
* resilient counterpart for live data streams).
|
|
25
|
+
*/
|
|
26
|
+
export function parseJsonlText<T = unknown>(raw: string): T[] {
|
|
27
|
+
return raw
|
|
28
|
+
.trim()
|
|
29
|
+
.split("\n")
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.map((line) => JSON.parse(line) as T);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read and JSON-parse a file. Returns null if the file is missing or contains
|
|
36
|
+
* invalid JSON. Use when a missing/corrupt file should fall through to a
|
|
37
|
+
* default rather than throw.
|
|
38
|
+
*/
|
|
39
|
+
export function readJsonFile<T = unknown>(filePath: string): T | null {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(readFileSync(filePath, "utf-8")) as T;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- Cross-process append lock ---
|
|
48
|
+
// POSIX `O_APPEND` is only atomic when the write payload fits in PIPE_BUF
|
|
49
|
+
// (Linux 4096, macOS 512). MCP servers and the bot run as separate
|
|
50
|
+
// processes that all `appendFileSync` to shared inbox/outbox files; a long
|
|
51
|
+
// `tell_session` prompt or large `ask_session` reply is comfortably above
|
|
52
|
+
// PIPE_BUF, and a concurrent append from another process can interleave at
|
|
53
|
+
// the byte level — the result fails JSON parse and the entire entry is
|
|
54
|
+
// silently dropped by `parseOutboxLine`.
|
|
55
|
+
//
|
|
56
|
+
// Mitigation: a sidecar `<filePath>.lock` created with `O_EXCL` serializes
|
|
57
|
+
// every append at the OS level. Lock is held for the duration of one
|
|
58
|
+
// `appendFileSync`, so the contention window is microseconds. Stale locks
|
|
59
|
+
// (process crashed mid-write) are detected via mtime and forcibly removed
|
|
60
|
+
// after `LOCK_STALE_MS`. As a last resort, after `LOCK_TIMEOUT_MS` of
|
|
61
|
+
// retries, the lock is bypassed — the entry survives but may interleave;
|
|
62
|
+
// preferable to dropping it entirely.
|
|
63
|
+
const LOCK_SUFFIX = ".lock";
|
|
64
|
+
const LOCK_RETRY_MS = 5;
|
|
65
|
+
const LOCK_TIMEOUT_MS = 1500;
|
|
66
|
+
const LOCK_STALE_MS = 5000;
|
|
67
|
+
const LOCK_SLEEP = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
|
|
68
|
+
|
|
69
|
+
/** Synchronous, non-spinning sleep supported by both Bun and Node MCP workers. */
|
|
70
|
+
function sleepForAppendLock(ms: number): void {
|
|
71
|
+
Atomics.wait(LOCK_SLEEP, 0, 0, ms);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function tryAcquireAppendLock(lockPath: string): boolean {
|
|
75
|
+
try {
|
|
76
|
+
closeSync(openSync(lockPath, "wx")); // O_CREAT | O_EXCL — atomic
|
|
77
|
+
return true;
|
|
78
|
+
} catch (e) {
|
|
79
|
+
if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isStaleLock(lockPath: string): boolean {
|
|
85
|
+
try {
|
|
86
|
+
return Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Append a JSON-encoded entry + newline to a JSONL file. Ensures the parent
|
|
94
|
+
* directory exists and serializes concurrent multi-process appends through a
|
|
95
|
+
* sidecar lock so payloads larger than PIPE_BUF do not interleave.
|
|
96
|
+
*
|
|
97
|
+
* The drainer side (`outbox/file-ops.ts`) claims the file via atomic rename
|
|
98
|
+
* and handles recovery of leftover `.processing` files from crashes.
|
|
99
|
+
*/
|
|
100
|
+
export function appendJsonlEntry(filePath: string, entry: unknown): void {
|
|
101
|
+
appendJsonlLine(filePath, `${JSON.stringify(entry)}\n`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Append an already-serialized JSONL line (must end with "\n") under the
|
|
106
|
+
* same cross-process append lock as `appendJsonlEntry`. Use when you
|
|
107
|
+
* want to write back a raw line **exactly** — e.g. flusher tail-writeback
|
|
108
|
+
* preserving lines that current parser can't decode, so a future parser
|
|
109
|
+
* release still sees them.
|
|
110
|
+
*/
|
|
111
|
+
export function appendJsonlLine(filePath: string, line: string): void {
|
|
112
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
113
|
+
const lockPath = `${filePath}${LOCK_SUFFIX}`;
|
|
114
|
+
const payload = line.endsWith("\n") ? line : `${line}\n`;
|
|
115
|
+
|
|
116
|
+
let acquired = tryAcquireAppendLock(lockPath);
|
|
117
|
+
// Pre-check: if the existing lock is already stale (writer crashed before
|
|
118
|
+
// unlinking), reclaim it before sleeping. Saves the entire retry budget
|
|
119
|
+
// for the common stale-leftover case.
|
|
120
|
+
if (!acquired && isStaleLock(lockPath)) {
|
|
121
|
+
safeUnlink(lockPath);
|
|
122
|
+
acquired = tryAcquireAppendLock(lockPath);
|
|
123
|
+
}
|
|
124
|
+
if (!acquired) {
|
|
125
|
+
const start = Date.now();
|
|
126
|
+
while (!acquired && Date.now() - start < LOCK_TIMEOUT_MS) {
|
|
127
|
+
// Codex launches built-in stdio MCPs under Node+tsx while the runtime
|
|
128
|
+
// itself uses Bun. Atomics.wait gives both processes a blocking sleep
|
|
129
|
+
// without a CPU-spinning fallback or a Bun-only global.
|
|
130
|
+
sleepForAppendLock(LOCK_RETRY_MS);
|
|
131
|
+
acquired = tryAcquireAppendLock(lockPath);
|
|
132
|
+
// Recheck staleness inside the loop so a writer that crashes mid-wait
|
|
133
|
+
// doesn't leave us spinning the whole timeout.
|
|
134
|
+
if (!acquired && isStaleLock(lockPath)) {
|
|
135
|
+
safeUnlink(lockPath);
|
|
136
|
+
acquired = tryAcquireAppendLock(lockPath);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!acquired) {
|
|
142
|
+
// Best-effort fallback: append unlocked. Worst case the line interleaves
|
|
143
|
+
// with another writer and one of them is dropped at parse time — still
|
|
144
|
+
// better than dropping the entry on the floor here.
|
|
145
|
+
appendFileSync(filePath, payload);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
appendFileSync(filePath, payload);
|
|
151
|
+
} finally {
|
|
152
|
+
safeUnlink(lockPath);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Overwrite a JSONL file with the given entries. Trailing newline included. */
|
|
157
|
+
export function writeJsonlFile(filePath: string, entries: readonly unknown[]): void {
|
|
158
|
+
const dir = dirname(filePath);
|
|
159
|
+
mkdirSync(dir, { recursive: true });
|
|
160
|
+
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random()
|
|
161
|
+
.toString(16)
|
|
162
|
+
.slice(2)}`;
|
|
163
|
+
const payload = `${entries.map((e) => JSON.stringify(e)).join("\n")}\n`;
|
|
164
|
+
let fd: number | null = null;
|
|
165
|
+
try {
|
|
166
|
+
fd = openSync(tmpPath, "w");
|
|
167
|
+
writeFileSync(fd, payload);
|
|
168
|
+
fsyncSync(fd);
|
|
169
|
+
closeSync(fd);
|
|
170
|
+
fd = null;
|
|
171
|
+
renameSync(tmpPath, filePath);
|
|
172
|
+
fsyncDirectoryBestEffort(dir);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (fd !== null) {
|
|
175
|
+
try {
|
|
176
|
+
closeSync(fd);
|
|
177
|
+
} catch {}
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
unlinkSync(tmpPath);
|
|
181
|
+
} catch {}
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function fsyncDirectoryBestEffort(dir: string): void {
|
|
187
|
+
let fd: number | null = null;
|
|
188
|
+
try {
|
|
189
|
+
fd = openSync(dir, "r");
|
|
190
|
+
fsyncSync(fd);
|
|
191
|
+
} catch {
|
|
192
|
+
// Directory fsync is not portable across every runtime/filesystem. The
|
|
193
|
+
// file fsync + rename above is the important integrity boundary; this is
|
|
194
|
+
// best-effort durability for the directory entry.
|
|
195
|
+
} finally {
|
|
196
|
+
if (fd !== null) {
|
|
197
|
+
try {
|
|
198
|
+
closeSync(fd);
|
|
199
|
+
} catch {}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { logger } from "#platform/logger";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared process-shutdown registry.
|
|
5
|
+
*
|
|
6
|
+
* The backend has multiple subsystems that need to clean up on SIGTERM /
|
|
7
|
+
* SIGINT / beforeExit — MCP client pool, browser/background-bash
|
|
8
|
+
* children, codex process trees, and DB/runtime handles. Before this module each subsystem
|
|
9
|
+
* registered its own `process.once("SIGTERM", ...)` handler. node fires
|
|
10
|
+
* them in registration order with no priority, so a cleanup that wanted
|
|
11
|
+
* to wait for in-flight MCP calls to finish could be racing the same
|
|
12
|
+
* subprocess-kill cleanup that takes those calls' children out from under
|
|
13
|
+
* it.
|
|
14
|
+
*
|
|
15
|
+
* Replace that with a single set of signal hooks + a priority-ordered
|
|
16
|
+
* registry. Modules call `onShutdown(name, priority, fn)`; the single
|
|
17
|
+
* handler iterates handlers in priority order (highest first), awaiting
|
|
18
|
+
* each with a hard ceiling so a hung cleanup can't keep the process
|
|
19
|
+
* alive past a reasonable bound.
|
|
20
|
+
*
|
|
21
|
+
* Priority convention (rough):
|
|
22
|
+
* - 100+: stateful network/connection resources whose in-flight calls
|
|
23
|
+
* deserve a graceful close before any subprocess gets killed
|
|
24
|
+
* (MCP pool, DB connections, WebSocket/server resources).
|
|
25
|
+
* - 50: subsystems that kill external processes / free OS handles
|
|
26
|
+
* (playwright, background-bash, codex tree).
|
|
27
|
+
* - 10: final reporting / metrics flushing.
|
|
28
|
+
* Numbers are not enforced — they're a convention so the order is
|
|
29
|
+
* scrutable at a glance.
|
|
30
|
+
*
|
|
31
|
+
* Idempotent: the first signal triggers; subsequent signals are
|
|
32
|
+
* no-ops so SIGINT-then-SIGTERM doesn't double-run handlers.
|
|
33
|
+
*
|
|
34
|
+
* Test isolation: `__resetForTests()` clears the registry AND removes
|
|
35
|
+
* the installed listeners so a fresh registration in the next test
|
|
36
|
+
* starts clean.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
export interface ShutdownHandler {
|
|
40
|
+
name: string;
|
|
41
|
+
priority: number;
|
|
42
|
+
fn: () => Promise<void> | void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const handlers: ShutdownHandler[] = [];
|
|
46
|
+
let signalHooksInstalled = false;
|
|
47
|
+
let triggered = false;
|
|
48
|
+
let shutdownPromise: Promise<void> | null = null;
|
|
49
|
+
|
|
50
|
+
/** Per-handler hard ceiling. A hung cleanup can't keep the process up
|
|
51
|
+
* beyond this; we log and move on so subsequent handlers still run. */
|
|
52
|
+
const HANDLER_TIMEOUT_MS = 5_000;
|
|
53
|
+
|
|
54
|
+
/** Process-exit-after-handlers ceiling. Even if every handler hangs we
|
|
55
|
+
* call process.exit so an orphaned bot doesn't wedge a forum forever. */
|
|
56
|
+
const HARD_EXIT_TIMEOUT_MS = 15_000;
|
|
57
|
+
|
|
58
|
+
/** Test-injectable signal trigger — exposed so tests can fire shutdown
|
|
59
|
+
* without touching real process signals. */
|
|
60
|
+
export type SignalReason = "beforeExit" | "SIGINT" | "SIGTERM" | "test";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Register a shutdown handler. Lazy-installs the single process-level
|
|
64
|
+
* signal listener on first call.
|
|
65
|
+
*
|
|
66
|
+
* `priority` is highest-first (100 runs before 50). Ties keep registration
|
|
67
|
+
* order (stable sort), so two subsystems at the same tier behave
|
|
68
|
+
* deterministically across runs.
|
|
69
|
+
*/
|
|
70
|
+
export function onShutdown(name: string, priority: number, fn: () => Promise<void> | void): void {
|
|
71
|
+
handlers.push({ name, priority, fn });
|
|
72
|
+
ensureSignalHooks();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ensureSignalHooks(): void {
|
|
76
|
+
if (signalHooksInstalled) return;
|
|
77
|
+
signalHooksInstalled = true;
|
|
78
|
+
process.once("beforeExit", () => {
|
|
79
|
+
void runShutdown("beforeExit");
|
|
80
|
+
});
|
|
81
|
+
process.once("SIGINT", () => {
|
|
82
|
+
void runShutdown("SIGINT");
|
|
83
|
+
});
|
|
84
|
+
process.once("SIGTERM", () => {
|
|
85
|
+
void runShutdown("SIGTERM");
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Run every registered handler in priority order. Each handler gets a
|
|
91
|
+
* HANDLER_TIMEOUT_MS budget; if it hangs we log and continue. The whole
|
|
92
|
+
* sweep is capped at HARD_EXIT_TIMEOUT_MS — past that we call
|
|
93
|
+
* `process.exit` so a stuck cleanup can't deadlock the shutdown.
|
|
94
|
+
*
|
|
95
|
+
* `reason` is passed through to handlers so a handler can log which
|
|
96
|
+
* signal triggered it (useful for debugging which path led to exit).
|
|
97
|
+
*
|
|
98
|
+
* Exported for tests / explicit "I want shutdown now" callers.
|
|
99
|
+
*/
|
|
100
|
+
export function runShutdown(reason: SignalReason): Promise<void> {
|
|
101
|
+
if (shutdownPromise) return shutdownPromise;
|
|
102
|
+
triggered = true;
|
|
103
|
+
shutdownPromise = performShutdown(reason);
|
|
104
|
+
return shutdownPromise;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function performShutdown(reason: SignalReason): Promise<void> {
|
|
108
|
+
logger.info({ reason, handlerCount: handlers.length }, "lifecycle: shutdown sequence starting");
|
|
109
|
+
// Stable sort: descending priority, registration order on ties.
|
|
110
|
+
const ordered = handlers
|
|
111
|
+
.map((h, i) => ({ h, i }))
|
|
112
|
+
.sort((a, b) => b.h.priority - a.h.priority || a.i - b.i)
|
|
113
|
+
.map(({ h }) => h);
|
|
114
|
+
|
|
115
|
+
const hardExit = setTimeout(() => {
|
|
116
|
+
logger.error({ reason }, "lifecycle: hard-exit ceiling reached, forcing process.exit");
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}, HARD_EXIT_TIMEOUT_MS);
|
|
119
|
+
hardExit.unref?.();
|
|
120
|
+
|
|
121
|
+
for (const h of ordered) {
|
|
122
|
+
const start = Date.now();
|
|
123
|
+
let handlerTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
124
|
+
try {
|
|
125
|
+
await Promise.race([
|
|
126
|
+
Promise.resolve().then(() => h.fn()),
|
|
127
|
+
new Promise<void>((_, reject) => {
|
|
128
|
+
handlerTimeout = setTimeout(
|
|
129
|
+
() => reject(new Error("handler timeout")),
|
|
130
|
+
HANDLER_TIMEOUT_MS,
|
|
131
|
+
);
|
|
132
|
+
}),
|
|
133
|
+
]);
|
|
134
|
+
logger.info(
|
|
135
|
+
{ handler: h.name, priority: h.priority, ms: Date.now() - start },
|
|
136
|
+
"lifecycle: shutdown handler completed",
|
|
137
|
+
);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
logger.warn(
|
|
140
|
+
{ err, handler: h.name, priority: h.priority, ms: Date.now() - start },
|
|
141
|
+
"lifecycle: shutdown handler failed or timed out (continuing)",
|
|
142
|
+
);
|
|
143
|
+
} finally {
|
|
144
|
+
if (handlerTimeout) clearTimeout(handlerTimeout);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
clearTimeout(hardExit);
|
|
148
|
+
logger.info({ reason }, "lifecycle: shutdown sequence complete");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Test-only: clear the registry and uninstall signal hooks so the next
|
|
152
|
+
* test's onShutdown calls start fresh. Does NOT actually call any
|
|
153
|
+
* handler — tests that want to exercise the trigger path should call
|
|
154
|
+
* `runShutdown("test")` after registering. */
|
|
155
|
+
export function __resetForTests(): void {
|
|
156
|
+
handlers.length = 0;
|
|
157
|
+
triggered = false;
|
|
158
|
+
shutdownPromise = null;
|
|
159
|
+
signalHooksInstalled = false;
|
|
160
|
+
// No way to remove process.once listeners that haven't fired yet —
|
|
161
|
+
// they'll just become no-ops because `triggered` is false-but-handlers
|
|
162
|
+
// is empty. The signalHooksInstalled flag is reset so the next
|
|
163
|
+
// onShutdown call installs fresh listeners.
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Test-only: read the registered handler count. */
|
|
167
|
+
export function __handlerCount(): number {
|
|
168
|
+
return handlers.length;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Test-only: read whether triggered. */
|
|
172
|
+
export function __triggered(): boolean {
|
|
173
|
+
return triggered;
|
|
174
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import pino from "pino";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Always write logs to stderr (fd 2), never stdout.
|
|
5
|
+
*
|
|
6
|
+
* MCP servers under `src/mcp/**` run as stdio subprocesses where stdout is the
|
|
7
|
+
* JSON-RPC transport channel. A single log line on stdout corrupts the next
|
|
8
|
+
* message and the MCP client closes the transport ("Transport closed"). The
|
|
9
|
+
* main bot process is also fine with stderr — pm2 captures both streams.
|
|
10
|
+
*
|
|
11
|
+
* In dev mode pino spawns a `pino-pretty` worker that owns its own sink, so we
|
|
12
|
+
* pass `destination: 2` through transport.options. In prod (no transport) the
|
|
13
|
+
* second arg to `pino()` sets the destination directly.
|
|
14
|
+
*/
|
|
15
|
+
export const logger = pino(
|
|
16
|
+
{
|
|
17
|
+
level: process.env.LOG_LEVEL || "info",
|
|
18
|
+
transport:
|
|
19
|
+
process.env.NODE_ENV === "development"
|
|
20
|
+
? {
|
|
21
|
+
target: "pino-pretty",
|
|
22
|
+
options: {
|
|
23
|
+
colorize: true,
|
|
24
|
+
translateTime: "SYS:yyyy-mm-dd HH:MM:ss",
|
|
25
|
+
destination: 2,
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
: undefined,
|
|
29
|
+
},
|
|
30
|
+
pino.destination(2),
|
|
31
|
+
);
|