negotium 0.1.7 → 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,785 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import type {
|
|
3
|
+
FileChangeItem,
|
|
4
|
+
McpToolCallItem,
|
|
5
|
+
ModelReasoningEffort,
|
|
6
|
+
SandboxMode,
|
|
7
|
+
ThreadOptions,
|
|
8
|
+
} from "@openai/codex-sdk";
|
|
9
|
+
import { Codex } from "@openai/codex-sdk";
|
|
10
|
+
import {
|
|
11
|
+
ensureCodexModelCache,
|
|
12
|
+
writeCodexCatalogWithNativeMultiAgentDisabled,
|
|
13
|
+
} from "#agents/codex-native-multi-agent";
|
|
14
|
+
import {
|
|
15
|
+
acquireCodexSpawnLock,
|
|
16
|
+
findNewCodexChildren,
|
|
17
|
+
killCodexTrees,
|
|
18
|
+
registerOwnedCodexPids,
|
|
19
|
+
snapshotCodexChildren,
|
|
20
|
+
unregisterOwnedCodexPids,
|
|
21
|
+
} from "#agents/codex-tree-kill";
|
|
22
|
+
import { hostedCodexAuthFilePath, hostedMcpServers } from "#agents/execution-host";
|
|
23
|
+
import {
|
|
24
|
+
migrateCodexRolloutNativeMultiAgentMetadata,
|
|
25
|
+
readLatestCodexContextUsage,
|
|
26
|
+
} from "#agents/rollout/codex";
|
|
27
|
+
import { extractFileEvents } from "#media/file-events";
|
|
28
|
+
import { errMsg } from "#platform/error";
|
|
29
|
+
import { logger } from "#platform/logger";
|
|
30
|
+
import {
|
|
31
|
+
browserOwnerCapability,
|
|
32
|
+
browserOwnerForContext,
|
|
33
|
+
CODEX_BROWSER_CAPABILITY_ENV,
|
|
34
|
+
} from "#platform/mcp-config";
|
|
35
|
+
import type { AgentQueryOptions, EffortLevel, UnifiedEvent } from "#types";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pass Otium's EffortLevel through to Codex's ModelReasoningEffort.
|
|
39
|
+
*
|
|
40
|
+
* Storage validation (`getRegistry("codex").validateEffort` in the repository
|
|
41
|
+
* setters) guarantees that any effort persisted on a codex topic is one of
|
|
42
|
+
* codex's accepted set (`low | medium | high | xhigh`), so the SDK shape and
|
|
43
|
+
* the stored shape are identical for codex topics. A `max` or `minimal`
|
|
44
|
+
* reaching this function means storage validation was bypassed — we throw
|
|
45
|
+
* instead of silently demoting so the bug surfaces rather than masking itself.
|
|
46
|
+
*/
|
|
47
|
+
function mapEffort(effort?: EffortLevel): ModelReasoningEffort | undefined {
|
|
48
|
+
if (!effort) return undefined;
|
|
49
|
+
if (effort === "max" || (effort as string) === "minimal") {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`codexProvider received effort='${effort}', which codex does not support — storage validation was bypassed`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return effort as ModelReasoningEffort;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Convert Otium's mcp-config output (Claude SDK shape) to Codex's
|
|
59
|
+
* `config.mcp_servers` shape. Codex supports two transports:
|
|
60
|
+
* - stdio: `{ command, args?, env? }`
|
|
61
|
+
* - streamable HTTP: `{ url }` (verified via `codex mcp add --url`)
|
|
62
|
+
*
|
|
63
|
+
* Entries with the SSE transport shape (`{ type: "sse", url }`) are dropped:
|
|
64
|
+
* codex doesn't speak SSE. Callers must produce the streamable HTTP shape
|
|
65
|
+
* for codex by passing `agent: "codex"` into the `playwright.build()` arm
|
|
66
|
+
* in `mcp-config.ts` (which switches `/sse` → `/mcp`).
|
|
67
|
+
*/
|
|
68
|
+
type CodexMcpTimeouts = {
|
|
69
|
+
enabled?: boolean;
|
|
70
|
+
startup_timeout_sec?: number;
|
|
71
|
+
tool_timeout_sec?: number;
|
|
72
|
+
};
|
|
73
|
+
type CodexStdioServer = {
|
|
74
|
+
command: string;
|
|
75
|
+
args?: string[];
|
|
76
|
+
env?: Record<string, string>;
|
|
77
|
+
} & CodexMcpTimeouts;
|
|
78
|
+
type CodexHttpServer = {
|
|
79
|
+
url: string;
|
|
80
|
+
http_headers?: Record<string, string>;
|
|
81
|
+
env_http_headers?: Record<string, string>;
|
|
82
|
+
} & CodexMcpTimeouts;
|
|
83
|
+
type CodexMcpServer = CodexStdioServer | CodexHttpServer;
|
|
84
|
+
|
|
85
|
+
const CODEX_MCP_SERVER_NAME_OVERRIDES: Record<string, string> = {
|
|
86
|
+
// Codex merges per-turn `--config mcp_servers.<name>.*` overrides with the
|
|
87
|
+
// user's global ~/.codex/config.toml. `playwright` is a common global stdio
|
|
88
|
+
// server name, and adding Otium's per-topic HTTP `url` to that same table
|
|
89
|
+
// makes Codex fail config parsing with "url is not supported for stdio".
|
|
90
|
+
playwright: "otium_playwright",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function codexMcpServerName(name: string): string {
|
|
94
|
+
return CODEX_MCP_SERVER_NAME_OVERRIDES[name] ?? name;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parsePositiveInt(value: string | undefined, fallback: number): number {
|
|
98
|
+
if (!value) return fallback;
|
|
99
|
+
const parsed = Number.parseInt(value, 10);
|
|
100
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function withCodexMcpServerOverrides(name: string, server: CodexMcpServer): CodexMcpServer {
|
|
104
|
+
if (name === "paddleocr") {
|
|
105
|
+
return {
|
|
106
|
+
...server,
|
|
107
|
+
startup_timeout_sec: parsePositiveInt(process.env.PADDLEOCR_MCP_STARTUP_TIMEOUT_SEC, 30),
|
|
108
|
+
tool_timeout_sec: parsePositiveInt(process.env.PADDLEOCR_MCP_TOOL_TIMEOUT_SEC, 300),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// For codex, Otium launches MCP servers via node + tsx (see serverLaunch in
|
|
112
|
+
// mcp-config.ts), which transpiles the .ts on the fly — cold start is slower
|
|
113
|
+
// than `bun run`, so give the handshake a generous startup window. codex's
|
|
114
|
+
// default startup timeout is short enough that a heavier server can miss it.
|
|
115
|
+
return {
|
|
116
|
+
startup_timeout_sec: parsePositiveInt(process.env.CODEX_MCP_STARTUP_TIMEOUT_SEC, 30),
|
|
117
|
+
...server,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function toCodexMcpServers(
|
|
122
|
+
claudeShape: Record<string, unknown>,
|
|
123
|
+
): Record<string, CodexMcpServer> {
|
|
124
|
+
// Codex merges turn config with ~/.codex/config.toml instead of replacing it.
|
|
125
|
+
// Disable common global browser stdio servers so browser state can only flow
|
|
126
|
+
// through Negotium's long-lived, owner-scoped HTTP server.
|
|
127
|
+
const disabledBrowserStdio = (): CodexStdioServer => ({
|
|
128
|
+
command: process.execPath,
|
|
129
|
+
args: ["-e", "process.exit(0)"],
|
|
130
|
+
enabled: false,
|
|
131
|
+
});
|
|
132
|
+
const out: Record<string, CodexMcpServer> = {
|
|
133
|
+
playwright: disabledBrowserStdio(),
|
|
134
|
+
"browser-rs": disabledBrowserStdio(),
|
|
135
|
+
patchright: disabledBrowserStdio(),
|
|
136
|
+
};
|
|
137
|
+
for (const [name, srv] of Object.entries(claudeShape)) {
|
|
138
|
+
if (!srv || typeof srv !== "object") continue;
|
|
139
|
+
const s = srv as Record<string, unknown>;
|
|
140
|
+
const codexName = codexMcpServerName(name);
|
|
141
|
+
if (typeof s.command === "string") {
|
|
142
|
+
out[codexName] = withCodexMcpServerOverrides(name, {
|
|
143
|
+
command: s.command,
|
|
144
|
+
...(Array.isArray(s.args) ? { args: s.args as string[] } : {}),
|
|
145
|
+
...(s.env && typeof s.env === "object" ? { env: s.env as Record<string, string> } : {}),
|
|
146
|
+
});
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (typeof s.url === "string" && s.type !== "sse") {
|
|
150
|
+
out[codexName] = withCodexMcpServerOverrides(name, {
|
|
151
|
+
url: s.url,
|
|
152
|
+
...(s.http_headers && typeof s.http_headers === "object"
|
|
153
|
+
? { http_headers: s.http_headers as Record<string, string> }
|
|
154
|
+
: {}),
|
|
155
|
+
...(s.env_http_headers && typeof s.env_http_headers === "object"
|
|
156
|
+
? { env_http_headers: s.env_http_headers as Record<string, string> }
|
|
157
|
+
: {}),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isMissingCodexRolloutError(err: unknown): boolean {
|
|
165
|
+
const msg = errMsg(err);
|
|
166
|
+
return /no rollout found|thread\/resume failed/i.test(msg);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Render an `mcp_tool_call` item's terminal payload into a short string for
|
|
171
|
+
* the unified `tool_result` event. The SDK shape is
|
|
172
|
+
* `result?: { content: ContentBlock[]; structured_content: unknown }` on
|
|
173
|
+
* success, or `error?: { message: string }` on failure — neither is a raw
|
|
174
|
+
* string, which is why the previous `typeof result === "string"` branch was
|
|
175
|
+
* unreachable. Truncate to 200 chars to match command_execution behavior.
|
|
176
|
+
*/
|
|
177
|
+
function summarizeMcpToolCallResult(item: McpToolCallItem): string {
|
|
178
|
+
if (item.error) return item.error.message.slice(0, 200);
|
|
179
|
+
if (!item.result) return "";
|
|
180
|
+
const blocks = Array.isArray(item.result.content) ? item.result.content : [];
|
|
181
|
+
const text = blocks
|
|
182
|
+
.map((b) => {
|
|
183
|
+
if (b && typeof b === "object" && "type" in b && b.type === "text" && "text" in b) {
|
|
184
|
+
return String((b as { text?: unknown }).text ?? "");
|
|
185
|
+
}
|
|
186
|
+
return "";
|
|
187
|
+
})
|
|
188
|
+
.filter(Boolean)
|
|
189
|
+
.join("\n");
|
|
190
|
+
if (text) return text.slice(0, 200);
|
|
191
|
+
// Fall back to a JSON dump of structured_content for non-text content blocks.
|
|
192
|
+
return JSON.stringify(item.result.structured_content ?? "").slice(0, 200);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function fileChangeEvents(item: FileChangeItem): UnifiedEvent[] {
|
|
196
|
+
return item.changes.flatMap((change, index) => {
|
|
197
|
+
const name = change.kind === "add" ? "Write" : change.kind === "delete" ? "Delete" : "Edit";
|
|
198
|
+
const toolUseId = `${item.id}:${index}`;
|
|
199
|
+
const success = item.status === "completed";
|
|
200
|
+
return [
|
|
201
|
+
{
|
|
202
|
+
type: "tool_use" as const,
|
|
203
|
+
name,
|
|
204
|
+
input: { file_path: change.path, change_kind: change.kind },
|
|
205
|
+
toolUseId,
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
type: "tool_result" as const,
|
|
209
|
+
toolUseId,
|
|
210
|
+
content: success
|
|
211
|
+
? `${change.kind} applied: ${change.path}`
|
|
212
|
+
: `${change.kind} failed: ${change.path}`,
|
|
213
|
+
},
|
|
214
|
+
];
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function promptForThread(opts: AgentQueryOptions, includeSystemPrompt: boolean): string {
|
|
219
|
+
return includeSystemPrompt && opts.systemPrompt
|
|
220
|
+
? `[System Instructions]\n${opts.systemPrompt}\n\n${opts.prompt}`
|
|
221
|
+
: opts.prompt;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Start a streamed turn and identify the codex PIDs we spawned, holding the
|
|
226
|
+
* global spawn lock across the full window from baseline → first event.
|
|
227
|
+
*
|
|
228
|
+
* Why hold the lock across iteration: `@openai/codex-sdk`'s `runStreamed()`
|
|
229
|
+
* returns a lazy async generator object. The actual `child_process.spawn()`
|
|
230
|
+
* call happens on the first `iterator.next()`, not when `runStreamed()` is
|
|
231
|
+
* awaited (see `node_modules/@openai/codex-sdk/dist/index.js:51-93,238`).
|
|
232
|
+
* If we released the lock after `await runStreamed(...)`, a concurrent
|
|
233
|
+
* codex caller's baseline would race against our actual spawn and one
|
|
234
|
+
* caller would mis-attribute the other's PID.
|
|
235
|
+
*
|
|
236
|
+
* Returns the iterator (positioned just past the first event) plus the
|
|
237
|
+
* first event itself, so the streaming loop can re-emit it before
|
|
238
|
+
* resuming `iterator.next()`.
|
|
239
|
+
*/
|
|
240
|
+
type StartResult = {
|
|
241
|
+
iter: AsyncIterator<unknown>;
|
|
242
|
+
firstEvent: unknown;
|
|
243
|
+
done: boolean;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
async function closeIterator(iter: AsyncIterator<unknown>): Promise<void> {
|
|
247
|
+
try {
|
|
248
|
+
await iter.return?.();
|
|
249
|
+
} catch (err) {
|
|
250
|
+
logger.warn({ err }, "codexProvider: failed to close codex event iterator");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Bound the spawn→first-event window. `thread.started` normally arrives within
|
|
256
|
+
* a few seconds, so if NOTHING comes back in this window the codex turn is
|
|
257
|
+
* genuinely stuck at startup (the bare-`/codex` hang symptom). We surface a
|
|
258
|
+
* clear error fast instead of waiting out the much longer mid-stream heartbeat
|
|
259
|
+
* (8 min) — a stuck startup never recovers, and retrying it is futile. Once the
|
|
260
|
+
* first event arrives, the normal heartbeat governs long-running work.
|
|
261
|
+
*/
|
|
262
|
+
const CODEX_STARTUP_TIMEOUT_MS = 90_000;
|
|
263
|
+
|
|
264
|
+
async function startStreamedWithTracking(
|
|
265
|
+
thread: ReturnType<Codex["startThread"]>,
|
|
266
|
+
prompt: string,
|
|
267
|
+
abortSignal: AbortSignal | undefined,
|
|
268
|
+
trackedPids: { pids: number[] },
|
|
269
|
+
): Promise<StartResult> {
|
|
270
|
+
const release = await acquireCodexSpawnLock();
|
|
271
|
+
try {
|
|
272
|
+
const baseline = snapshotCodexChildren();
|
|
273
|
+
const runResult = await thread.runStreamed(prompt, {
|
|
274
|
+
...(abortSignal ? { signal: abortSignal } : {}),
|
|
275
|
+
});
|
|
276
|
+
const iter = (runResult.events as AsyncIterable<unknown>)[Symbol.asyncIterator]();
|
|
277
|
+
// Force the first iterator step — this is when the SDK actually
|
|
278
|
+
// child_process.spawn()s codex. After this awaits, the codex PID is
|
|
279
|
+
// visible to pgrep as a child of our process.
|
|
280
|
+
let first: IteratorResult<unknown>;
|
|
281
|
+
// Startup watchdog: race the first event against CODEX_STARTUP_TIMEOUT_MS so
|
|
282
|
+
// a codex that spawns but never emits anything can't hang the turn. On
|
|
283
|
+
// timeout the iter.next() promise loses the race and we fall into the catch
|
|
284
|
+
// below, which kills the spawned tree, closes the iterator, and rethrows the
|
|
285
|
+
// clear error — surfaced to the user as an `error` event (no infinite wait).
|
|
286
|
+
let startupTimer: ReturnType<typeof setTimeout> | undefined;
|
|
287
|
+
try {
|
|
288
|
+
const startupTimeout = new Promise<never>((_, reject) => {
|
|
289
|
+
startupTimer = setTimeout(
|
|
290
|
+
() =>
|
|
291
|
+
reject(
|
|
292
|
+
new Error(
|
|
293
|
+
`Codex가 ${CODEX_STARTUP_TIMEOUT_MS / 1000}초 내 응답을 시작하지 않아 중단했습니다. 다시 시도해주세요.`,
|
|
294
|
+
),
|
|
295
|
+
),
|
|
296
|
+
CODEX_STARTUP_TIMEOUT_MS,
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
// Swallow a late rejection from the losing promise: if the startup
|
|
300
|
+
// timeout wins the race, this dangling next() may settle later with
|
|
301
|
+
// nobody awaiting it (would surface as an unhandledRejection).
|
|
302
|
+
const nextP = iter.next();
|
|
303
|
+
nextP.catch(() => {});
|
|
304
|
+
first = await Promise.race([nextP, startupTimeout]);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
// Abort OR startup-timeout after the SDK spawned codex but before the
|
|
307
|
+
// first event resolves: the normal abort listener still sees no tracked
|
|
308
|
+
// PID, so diff the baseline here and kill anything attributable before
|
|
309
|
+
// propagating.
|
|
310
|
+
const novel = findNewCodexChildren(baseline);
|
|
311
|
+
if (novel.length > 0) {
|
|
312
|
+
killCodexTrees(novel);
|
|
313
|
+
}
|
|
314
|
+
await closeIterator(iter);
|
|
315
|
+
throw err;
|
|
316
|
+
} finally {
|
|
317
|
+
if (startupTimer) clearTimeout(startupTimer);
|
|
318
|
+
}
|
|
319
|
+
const novel = findNewCodexChildren(baseline);
|
|
320
|
+
trackedPids.pids = novel;
|
|
321
|
+
registerOwnedCodexPids(novel);
|
|
322
|
+
return { iter, firstEvent: first.value, done: !!first.done };
|
|
323
|
+
} finally {
|
|
324
|
+
release();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Wrap an iterator into an AsyncIterable that first yields a pre-captured
|
|
330
|
+
* `firstEvent` and then drains the underlying iterator. Lets the streaming
|
|
331
|
+
* `for await` loop keep its existing switch over events without special-casing
|
|
332
|
+
* the first iteration we already consumed during PID tracking.
|
|
333
|
+
*/
|
|
334
|
+
function prependFirstEvent(
|
|
335
|
+
iter: AsyncIterator<unknown>,
|
|
336
|
+
firstEvent: unknown,
|
|
337
|
+
alreadyDone: boolean,
|
|
338
|
+
): AsyncIterable<unknown> {
|
|
339
|
+
return {
|
|
340
|
+
[Symbol.asyncIterator]() {
|
|
341
|
+
let emittedFirst = false;
|
|
342
|
+
let exhausted = alreadyDone;
|
|
343
|
+
return {
|
|
344
|
+
async next(): Promise<IteratorResult<unknown>> {
|
|
345
|
+
if (!emittedFirst) {
|
|
346
|
+
emittedFirst = true;
|
|
347
|
+
if (alreadyDone) return { value: undefined, done: true };
|
|
348
|
+
return { value: firstEvent, done: false };
|
|
349
|
+
}
|
|
350
|
+
if (exhausted) return { value: undefined, done: true };
|
|
351
|
+
const r = await iter.next();
|
|
352
|
+
if (r.done) exhausted = true;
|
|
353
|
+
return r;
|
|
354
|
+
},
|
|
355
|
+
async return(value?: unknown): Promise<IteratorResult<unknown>> {
|
|
356
|
+
exhausted = true;
|
|
357
|
+
if (iter.return) return iter.return(value);
|
|
358
|
+
return { value, done: true };
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Core Codex provider.
|
|
367
|
+
* Same UnifiedEvent contract as claudeProvider. Uses @openai/codex-sdk.
|
|
368
|
+
*
|
|
369
|
+
* Notes:
|
|
370
|
+
* - Codex has no `systemPrompt` option; prepend the current topic/system
|
|
371
|
+
* instructions on every invocation, including resumed synthetic threads.
|
|
372
|
+
* - Per-turn MCP isolation is achieved via CodexOptions.config.mcp_servers,
|
|
373
|
+
* so the global ~/.codex/config.toml stays untouched.
|
|
374
|
+
* - Streaming token-by-token text deltas are not exposed by the SDK; only
|
|
375
|
+
* final agent_message and per-item updates. Callers that rely on
|
|
376
|
+
* text_delta should still work because we yield a single text/result
|
|
377
|
+
* event when the assistant message completes.
|
|
378
|
+
*/
|
|
379
|
+
export async function* codexProvider(opts: AgentQueryOptions): AsyncGenerator<UnifiedEvent> {
|
|
380
|
+
// Up-front auth file check — gives the user an actionable message
|
|
381
|
+
// ("`codex login` 으로 다시 로그인해주세요") immediately instead of waiting
|
|
382
|
+
// for the @openai/codex-sdk to spawn the codex binary and surface an
|
|
383
|
+
// opaque OAuth failure on the first turn. Path is the default codex
|
|
384
|
+
// location unless NEGOTIUM_CODEX_AUTH_FILE relocates it for this host.
|
|
385
|
+
const codexAuthPath = hostedCodexAuthFilePath();
|
|
386
|
+
if (!existsSync(codexAuthPath)) {
|
|
387
|
+
yield {
|
|
388
|
+
type: "error",
|
|
389
|
+
content: `Codex auth file not found at ${codexAuthPath}. Run \`codex login\` to authenticate.`,
|
|
390
|
+
};
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let codexModelCatalogPath: string;
|
|
395
|
+
try {
|
|
396
|
+
await ensureCodexModelCache(codexAuthPath);
|
|
397
|
+
codexModelCatalogPath = writeCodexCatalogWithNativeMultiAgentDisabled(codexAuthPath);
|
|
398
|
+
if (opts.sessionId) migrateCodexRolloutNativeMultiAgentMetadata(opts.sessionId);
|
|
399
|
+
} catch (err) {
|
|
400
|
+
yield {
|
|
401
|
+
type: "error",
|
|
402
|
+
content: `Failed to enforce the Codex native multi-agent policy: ${errMsg(err)}`,
|
|
403
|
+
};
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const codexMcpServers = toCodexMcpServers(hostedMcpServers(opts));
|
|
408
|
+
const browserOwner = browserOwnerForContext(opts);
|
|
409
|
+
const scopedBrowserCapability =
|
|
410
|
+
opts.playwrightCapability && browserOwner
|
|
411
|
+
? browserOwnerCapability(opts.playwrightCapability, browserOwner)
|
|
412
|
+
: undefined;
|
|
413
|
+
|
|
414
|
+
// Startup trace: codexProvider was otherwise silent until its first error or
|
|
415
|
+
// heartbeat (8 min), so a stuck turn left "0 lines of codex" in the logs and
|
|
416
|
+
// was impossible to localize (bug B). Record the resolved model/effort/cwd +
|
|
417
|
+
// whether this is a fresh thread or a resume, so a future hang is pinpointed
|
|
418
|
+
// to setup vs. the first stream step.
|
|
419
|
+
logger.info(
|
|
420
|
+
{
|
|
421
|
+
model: opts.model ?? "(sdk default)",
|
|
422
|
+
effort: opts.effort ?? "(off)",
|
|
423
|
+
cwd: opts.cwd,
|
|
424
|
+
cwdExists: existsSync(opts.cwd),
|
|
425
|
+
resume: Boolean(opts.sessionId),
|
|
426
|
+
mcpServerCount: Object.keys(codexMcpServers).length,
|
|
427
|
+
},
|
|
428
|
+
"codexProvider: starting turn",
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
const codex = new Codex({
|
|
432
|
+
...(scopedBrowserCapability
|
|
433
|
+
? {
|
|
434
|
+
env: {
|
|
435
|
+
...Object.fromEntries(
|
|
436
|
+
Object.entries(process.env).filter(
|
|
437
|
+
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
438
|
+
),
|
|
439
|
+
),
|
|
440
|
+
[CODEX_BROWSER_CAPABILITY_ENV]: scopedBrowserCapability,
|
|
441
|
+
},
|
|
442
|
+
}
|
|
443
|
+
: {}),
|
|
444
|
+
config: {
|
|
445
|
+
// Otium exposes delegation through runtime.spawn_subagent so child work
|
|
446
|
+
// gets its own room/card and its result is routed back to the parent.
|
|
447
|
+
// Codex enables its provider-native collaboration tools from the user's
|
|
448
|
+
// global config by default; explicitly turn that feature off so tools
|
|
449
|
+
// such as spawn_agent/send_message cannot bypass Otium's orchestration
|
|
450
|
+
// and so subagent rooms cannot recursively fan out through Codex.
|
|
451
|
+
// Codex 0.144 lets model metadata override these flags. The authoritative
|
|
452
|
+
// catalog above sets multi_agent_version=disabled as the hard stop; keep
|
|
453
|
+
// all feature switches off as a second layer and for future CLI versions.
|
|
454
|
+
features: { multi_agent: false, multi_agent_v2: false, enable_fanout: false },
|
|
455
|
+
model_catalog_json: codexModelCatalogPath,
|
|
456
|
+
mcp_servers: codexMcpServers,
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const threadOptions: ThreadOptions = {
|
|
461
|
+
workingDirectory: opts.cwd,
|
|
462
|
+
skipGitRepoCheck: true,
|
|
463
|
+
// Codex sandbox is intentionally disabled: Otium already assigns a managed
|
|
464
|
+
// workspace cwd for the turn, and the bot is expected to act on it freely on
|
|
465
|
+
// the user's behalf. SDK-level sandboxing would only constrain reach inside
|
|
466
|
+
// that already-owned directory, which is not the threat model.
|
|
467
|
+
sandboxMode: "danger-full-access" as SandboxMode,
|
|
468
|
+
approvalPolicy: "never",
|
|
469
|
+
...(opts.model ? { model: opts.model } : {}),
|
|
470
|
+
...(opts.effort ? { modelReasoningEffort: mapEffort(opts.effort) } : {}),
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
let currentSessionId = opts.sessionId;
|
|
474
|
+
|
|
475
|
+
// Codex SDK has no systemPrompt option. A resumed thread may have been
|
|
476
|
+
// synthesized from a captured shell, so always refresh the current runtime
|
|
477
|
+
// instructions instead of trusting historical developer/environment data.
|
|
478
|
+
let thread = currentSessionId
|
|
479
|
+
? codex.resumeThread(currentSessionId, threadOptions)
|
|
480
|
+
: codex.startThread(threadOptions);
|
|
481
|
+
let prompt = promptForThread(opts, true);
|
|
482
|
+
|
|
483
|
+
let agentTextSoFar = "";
|
|
484
|
+
let currentAgentMessageId: string | undefined;
|
|
485
|
+
let finalText = "";
|
|
486
|
+
let stopReason = "end_turn";
|
|
487
|
+
|
|
488
|
+
// Tree-kill instrumentation. The codex SDK spawns the `codex` binary as a
|
|
489
|
+
// direct child of our process but does not expose the PID or place it in
|
|
490
|
+
// its own process group. We identify our PIDs by diffing pgrep snapshots
|
|
491
|
+
// around the SDK's first iterator step (when codex is actually spawned),
|
|
492
|
+
// and SIGTERM the whole tree on abort with SIGKILL fallback inside
|
|
493
|
+
// killCodexTrees().
|
|
494
|
+
const trackedPids: { pids: number[] } = { pids: [] };
|
|
495
|
+
const abortSignal = opts.abortController?.signal;
|
|
496
|
+
const onAbortKill = () => {
|
|
497
|
+
if (trackedPids.pids.length > 0) {
|
|
498
|
+
killCodexTrees(trackedPids.pids);
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
abortSignal?.addEventListener("abort", onAbortKill, { once: true });
|
|
502
|
+
|
|
503
|
+
// Heartbeat: if the Codex API emits no events within the window, abort the
|
|
504
|
+
// current stream and resume the thread with a short continuation prompt.
|
|
505
|
+
// The interval doubles each attempt (up to HEARTBEAT_MAX_MS) to avoid
|
|
506
|
+
// interrupting genuinely long-running work on the first timeout.
|
|
507
|
+
let heartbeatMs = 8 * 60 * 1000;
|
|
508
|
+
let heartbeatAttempts = 0;
|
|
509
|
+
const HEARTBEAT_MAX_MS = 16 * 60 * 1000;
|
|
510
|
+
const HEARTBEAT_MAX_ATTEMPTS = 2;
|
|
511
|
+
const HEARTBEAT_CONTINUATION = "계속 진행해줘.";
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
while (true) {
|
|
515
|
+
if (abortSignal?.aborted) return;
|
|
516
|
+
|
|
517
|
+
// Per-attempt abort controller: lets the heartbeat timer abort only
|
|
518
|
+
// this attempt's stream without touching the user-facing outer signal.
|
|
519
|
+
const attemptAbort = new AbortController();
|
|
520
|
+
const propagateOuter = () => attemptAbort.abort();
|
|
521
|
+
abortSignal?.addEventListener("abort", propagateOuter, { once: true });
|
|
522
|
+
|
|
523
|
+
trackedPids.pids = [];
|
|
524
|
+
let heartbeatTriggered = false;
|
|
525
|
+
let attemptCompleted = false;
|
|
526
|
+
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
|
527
|
+
|
|
528
|
+
const scheduleHeartbeat = () => {
|
|
529
|
+
if (heartbeatTimer !== null) clearTimeout(heartbeatTimer);
|
|
530
|
+
if (abortSignal?.aborted || attemptAbort.signal.aborted) return;
|
|
531
|
+
heartbeatTimer = setTimeout(() => {
|
|
532
|
+
heartbeatTriggered = true;
|
|
533
|
+
if (trackedPids.pids.length > 0) killCodexTrees(trackedPids.pids);
|
|
534
|
+
attemptAbort.abort();
|
|
535
|
+
}, heartbeatMs);
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
try {
|
|
539
|
+
scheduleHeartbeat();
|
|
540
|
+
|
|
541
|
+
let startResult!: StartResult;
|
|
542
|
+
try {
|
|
543
|
+
startResult = await startStreamedWithTracking(
|
|
544
|
+
thread,
|
|
545
|
+
prompt,
|
|
546
|
+
attemptAbort.signal,
|
|
547
|
+
trackedPids,
|
|
548
|
+
);
|
|
549
|
+
} catch (err) {
|
|
550
|
+
if (err instanceof Error && err.name === "AbortError") throw err;
|
|
551
|
+
if (!currentSessionId || !isMissingCodexRolloutError(err)) throw err;
|
|
552
|
+
logger.warn(
|
|
553
|
+
{ staleSessionId: currentSessionId, err: errMsg(err) },
|
|
554
|
+
"codexProvider: stale/missing rollout, restarting fresh thread",
|
|
555
|
+
);
|
|
556
|
+
thread = codex.startThread(threadOptions);
|
|
557
|
+
prompt = promptForThread(opts, true);
|
|
558
|
+
currentSessionId = undefined;
|
|
559
|
+
startResult = await startStreamedWithTracking(
|
|
560
|
+
thread,
|
|
561
|
+
prompt,
|
|
562
|
+
attemptAbort.signal,
|
|
563
|
+
trackedPids,
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Cover the narrow window where abort fired between PID registration
|
|
568
|
+
// and the abort listener actually running.
|
|
569
|
+
if (attemptAbort.signal.aborted) {
|
|
570
|
+
killCodexTrees(trackedPids.pids);
|
|
571
|
+
await closeIterator(startResult.iter);
|
|
572
|
+
throw Object.assign(new Error("heartbeat abort"), { name: "AbortError" });
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const eventStream = prependFirstEvent(
|
|
576
|
+
startResult.iter,
|
|
577
|
+
startResult.firstEvent,
|
|
578
|
+
startResult.done,
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
for await (const rawEvent of eventStream) {
|
|
582
|
+
scheduleHeartbeat();
|
|
583
|
+
|
|
584
|
+
const event = rawEvent as {
|
|
585
|
+
type: string;
|
|
586
|
+
thread_id?: string;
|
|
587
|
+
item?: {
|
|
588
|
+
type: string;
|
|
589
|
+
command?: string;
|
|
590
|
+
tool?: string;
|
|
591
|
+
arguments?: unknown;
|
|
592
|
+
text?: string;
|
|
593
|
+
id?: string;
|
|
594
|
+
aggregated_output?: string;
|
|
595
|
+
message?: string;
|
|
596
|
+
};
|
|
597
|
+
usage?: {
|
|
598
|
+
input_tokens: number;
|
|
599
|
+
output_tokens: number;
|
|
600
|
+
cached_input_tokens?: number;
|
|
601
|
+
};
|
|
602
|
+
error?: { message?: string };
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
switch (event.type) {
|
|
606
|
+
case "thread.started": {
|
|
607
|
+
if (event.thread_id) {
|
|
608
|
+
currentSessionId = event.thread_id;
|
|
609
|
+
yield { type: "session", sessionId: event.thread_id };
|
|
610
|
+
}
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
case "item.started": {
|
|
615
|
+
const item = event.item;
|
|
616
|
+
if (!item) break;
|
|
617
|
+
if (item.type === "command_execution") {
|
|
618
|
+
yield {
|
|
619
|
+
type: "tool_use",
|
|
620
|
+
name: "Bash",
|
|
621
|
+
input: { command: String(item.command ?? "") },
|
|
622
|
+
toolUseId: String(item.id ?? ""),
|
|
623
|
+
};
|
|
624
|
+
} else if (item.type === "mcp_tool_call") {
|
|
625
|
+
yield {
|
|
626
|
+
type: "tool_use",
|
|
627
|
+
name: String(item.tool ?? "unknown"),
|
|
628
|
+
input:
|
|
629
|
+
item.arguments && typeof item.arguments === "object"
|
|
630
|
+
? (item.arguments as Record<string, unknown>)
|
|
631
|
+
: {},
|
|
632
|
+
toolUseId: String(item.id ?? ""),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
break;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
case "item.updated": {
|
|
639
|
+
const item = event.item;
|
|
640
|
+
if (!item) break;
|
|
641
|
+
if (item.type === "agent_message") {
|
|
642
|
+
const messageId = item.id != null ? String(item.id) : undefined;
|
|
643
|
+
if (messageId !== currentAgentMessageId) {
|
|
644
|
+
currentAgentMessageId = messageId;
|
|
645
|
+
agentTextSoFar = "";
|
|
646
|
+
}
|
|
647
|
+
const text = String(item.text ?? "");
|
|
648
|
+
const newChars = text.slice(agentTextSoFar.length);
|
|
649
|
+
if (newChars) {
|
|
650
|
+
yield { type: "text_delta", content: newChars };
|
|
651
|
+
agentTextSoFar = text;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
break;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
case "item.completed": {
|
|
658
|
+
const item = event.item;
|
|
659
|
+
if (!item) break;
|
|
660
|
+
if (item.type === "agent_message") {
|
|
661
|
+
finalText = String(item.text ?? "");
|
|
662
|
+
agentTextSoFar = finalText;
|
|
663
|
+
yield { type: "text", content: finalText };
|
|
664
|
+
yield* extractFileEvents(finalText, "text");
|
|
665
|
+
} else if (item.type === "reasoning") {
|
|
666
|
+
const reasoning = String(item.text ?? "").trim();
|
|
667
|
+
if (reasoning) yield { type: "reasoning", content: reasoning };
|
|
668
|
+
} else if (item.type === "mcp_tool_call") {
|
|
669
|
+
yield {
|
|
670
|
+
type: "tool_result",
|
|
671
|
+
toolUseId: String(item.id ?? ""),
|
|
672
|
+
content: summarizeMcpToolCallResult(item as unknown as McpToolCallItem),
|
|
673
|
+
};
|
|
674
|
+
} else if (item.type === "command_execution") {
|
|
675
|
+
yield {
|
|
676
|
+
type: "tool_result",
|
|
677
|
+
toolUseId: String(item.id ?? ""),
|
|
678
|
+
content: String(item.aggregated_output ?? "").slice(0, 200),
|
|
679
|
+
};
|
|
680
|
+
} else if (item.type === "file_change") {
|
|
681
|
+
for (const fileEvent of fileChangeEvents(item as unknown as FileChangeItem)) {
|
|
682
|
+
yield fileEvent;
|
|
683
|
+
}
|
|
684
|
+
} else if (item.type === "error") {
|
|
685
|
+
yield { type: "error", content: String(item.message ?? "") };
|
|
686
|
+
}
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
case "turn.completed": {
|
|
691
|
+
const usage = event.usage;
|
|
692
|
+
if (!usage) break;
|
|
693
|
+
attemptCompleted = true;
|
|
694
|
+
const contextUsage = currentSessionId
|
|
695
|
+
? readLatestCodexContextUsage(currentSessionId)
|
|
696
|
+
: undefined;
|
|
697
|
+
yield {
|
|
698
|
+
type: "result",
|
|
699
|
+
content: finalText,
|
|
700
|
+
stopReason,
|
|
701
|
+
usage: {
|
|
702
|
+
inputTokens: usage.input_tokens,
|
|
703
|
+
outputTokens: usage.output_tokens,
|
|
704
|
+
cacheReadInputTokens: usage.cached_input_tokens,
|
|
705
|
+
...contextUsage,
|
|
706
|
+
},
|
|
707
|
+
};
|
|
708
|
+
if (finalText) yield* extractFileEvents(finalText, "result");
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
case "turn.failed": {
|
|
713
|
+
stopReason = "error";
|
|
714
|
+
yield {
|
|
715
|
+
type: "error",
|
|
716
|
+
content: event.error?.message || "Codex turn failed",
|
|
717
|
+
};
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
default:
|
|
722
|
+
break;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
} catch (err) {
|
|
726
|
+
if (abortSignal?.aborted) return;
|
|
727
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
728
|
+
if (!heartbeatTriggered) return;
|
|
729
|
+
// Heartbeat abort — fall through to retry logic below
|
|
730
|
+
} else {
|
|
731
|
+
logger.error({ err }, "codexProvider: attempt failed");
|
|
732
|
+
yield { type: "error", content: errMsg(err) };
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
} finally {
|
|
736
|
+
if (heartbeatTimer !== null) clearTimeout(heartbeatTimer);
|
|
737
|
+
abortSignal?.removeEventListener("abort", propagateOuter);
|
|
738
|
+
if (trackedPids.pids.length > 0) unregisterOwnedCodexPids(trackedPids.pids);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (attemptCompleted || abortSignal?.aborted) break;
|
|
742
|
+
if (!heartbeatTriggered) break;
|
|
743
|
+
|
|
744
|
+
heartbeatAttempts++;
|
|
745
|
+
if (heartbeatAttempts > HEARTBEAT_MAX_ATTEMPTS) {
|
|
746
|
+
logger.warn(
|
|
747
|
+
{ attempts: heartbeatAttempts },
|
|
748
|
+
"codexProvider: max heartbeat attempts reached",
|
|
749
|
+
);
|
|
750
|
+
yield {
|
|
751
|
+
type: "error",
|
|
752
|
+
content: `Codex가 ${HEARTBEAT_MAX_ATTEMPTS}회 재시도 후에도 응답하지 않았습니다.`,
|
|
753
|
+
};
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
heartbeatMs = Math.min(heartbeatMs * 2, HEARTBEAT_MAX_MS);
|
|
758
|
+
logger.info(
|
|
759
|
+
{ attempt: heartbeatAttempts, nextIntervalMs: heartbeatMs },
|
|
760
|
+
"codexProvider: heartbeat fired, resuming thread with continuation prompt",
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
// The resumed turn's agent_message grows from "" again. Stale lengths
|
|
764
|
+
// from the aborted attempt would mis-slice deltas (silence until the new
|
|
765
|
+
// text outgrows the old, then an arbitrary mid-string fragment), and a
|
|
766
|
+
// resumed turn that ends without an agent_message would ship the
|
|
767
|
+
// previous attempt's finalText as its result.
|
|
768
|
+
agentTextSoFar = "";
|
|
769
|
+
finalText = "";
|
|
770
|
+
|
|
771
|
+
thread = currentSessionId
|
|
772
|
+
? codex.resumeThread(currentSessionId, threadOptions)
|
|
773
|
+
: codex.startThread(threadOptions);
|
|
774
|
+
prompt = currentSessionId ? HEARTBEAT_CONTINUATION : promptForThread(opts, true);
|
|
775
|
+
}
|
|
776
|
+
} catch (err) {
|
|
777
|
+
if (err instanceof Error && err.name === "AbortError") return;
|
|
778
|
+
if (abortSignal?.aborted) return;
|
|
779
|
+
logger.error({ err }, "codexProvider: setup failed");
|
|
780
|
+
yield { type: "error", content: errMsg(err) };
|
|
781
|
+
} finally {
|
|
782
|
+
abortSignal?.removeEventListener("abort", onAbortKill);
|
|
783
|
+
if (trackedPids.pids.length > 0) unregisterOwnedCodexPids(trackedPids.pids);
|
|
784
|
+
}
|
|
785
|
+
}
|