cli-claw-kit 0.0.1
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/LICENSE +21 -0
- package/README.md +245 -0
- package/config/default-groups.json +1 -0
- package/config/global-agents-md.template.md +37 -0
- package/config/mount-allowlist.json +11 -0
- package/container/Dockerfile +160 -0
- package/container/agent-runner/dist/.tsbuildinfo +1 -0
- package/container/agent-runner/dist/agent-definitions.js +22 -0
- package/container/agent-runner/dist/channel-prefixes.js +16 -0
- package/container/agent-runner/dist/codex-config.js +29 -0
- package/container/agent-runner/dist/image-detector.js +96 -0
- package/container/agent-runner/dist/index.js +2587 -0
- package/container/agent-runner/dist/mcp-tools.js +1076 -0
- package/container/agent-runner/dist/stream-event.types.js +5 -0
- package/container/agent-runner/dist/stream-processor.js +867 -0
- package/container/agent-runner/dist/types.js +6 -0
- package/container/agent-runner/dist/utils.js +115 -0
- package/container/agent-runner/package.json +36 -0
- package/container/agent-runner/prompts/security-rules.md +31 -0
- package/container/agent-runner/src/agent-definitions.ts +27 -0
- package/container/agent-runner/src/channel-prefixes.ts +16 -0
- package/container/agent-runner/src/codex-config.ts +40 -0
- package/container/agent-runner/src/image-detector.ts +116 -0
- package/container/agent-runner/src/index.ts +3107 -0
- package/container/agent-runner/src/mcp-tools.ts +1295 -0
- package/container/agent-runner/src/stream-event.types.ts +10 -0
- package/container/agent-runner/src/stream-processor.ts +932 -0
- package/container/agent-runner/src/types.ts +75 -0
- package/container/agent-runner/src/utils.ts +114 -0
- package/container/agent-runner/tsconfig.json +17 -0
- package/container/build.sh +28 -0
- package/container/entrypoint.sh +64 -0
- package/container/skills/agent-browser/SKILL.md +159 -0
- package/container/skills/install-skill/SKILL.md +64 -0
- package/container/skills/post-test-cleanup/SKILL.md +121 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/agent-output-parser.js +459 -0
- package/dist/app-root.js +52 -0
- package/dist/assistant-meta-footer.js +1 -0
- package/dist/auth.js +91 -0
- package/dist/billing.js +694 -0
- package/dist/channel-prefixes.js +16 -0
- package/dist/cli.js +86 -0
- package/dist/commands.js +79 -0
- package/dist/config.js +120 -0
- package/dist/container-runner.js +981 -0
- package/dist/daily-summary.js +210 -0
- package/dist/db.js +3683 -0
- package/dist/dingtalk.js +1347 -0
- package/dist/feishu-markdown-style.js +97 -0
- package/dist/feishu-streaming-card.js +1875 -0
- package/dist/feishu.js +1628 -0
- package/dist/file-manager.js +270 -0
- package/dist/group-queue.js +1070 -0
- package/dist/group-runtime.js +35 -0
- package/dist/host-workspace-cwd.js +85 -0
- package/dist/im-channel.js +384 -0
- package/dist/im-command-utils.js +142 -0
- package/dist/im-downloader.js +45 -0
- package/dist/im-manager.js +527 -0
- package/dist/im-utils.js +53 -0
- package/dist/image-detector.js +96 -0
- package/dist/index.js +5828 -0
- package/dist/logger.js +22 -0
- package/dist/mcp-utils.js +66 -0
- package/dist/message-attachments.js +69 -0
- package/dist/message-notifier.js +36 -0
- package/dist/middleware/auth.js +85 -0
- package/dist/mount-security.js +315 -0
- package/dist/permissions.js +67 -0
- package/dist/project-memory.js +6 -0
- package/dist/provider-pool.js +189 -0
- package/dist/qq.js +826 -0
- package/dist/reset-admin.js +42 -0
- package/dist/routes/admin.js +543 -0
- package/dist/routes/agent-definitions.js +241 -0
- package/dist/routes/agents.js +533 -0
- package/dist/routes/auth.js +675 -0
- package/dist/routes/billing.js +490 -0
- package/dist/routes/browse.js +210 -0
- package/dist/routes/bug-report.js +387 -0
- package/dist/routes/config.js +1868 -0
- package/dist/routes/files.js +671 -0
- package/dist/routes/groups.js +1367 -0
- package/dist/routes/mcp-servers.js +320 -0
- package/dist/routes/memory.js +523 -0
- package/dist/routes/monitor.js +307 -0
- package/dist/routes/skills.js +777 -0
- package/dist/routes/tasks.js +509 -0
- package/dist/routes/usage.js +64 -0
- package/dist/routes/workspace-config.js +458 -0
- package/dist/runtime-build.js +112 -0
- package/dist/runtime-command-handler.js +189 -0
- package/dist/runtime-command-registry.js +1 -0
- package/dist/runtime-config.js +1777 -0
- package/dist/runtime-identity.js +52 -0
- package/dist/schemas.js +590 -0
- package/dist/script-runner.js +64 -0
- package/dist/sdk-query.js +82 -0
- package/dist/skill-utils.js +145 -0
- package/dist/sqlite-compat.js +19 -0
- package/dist/stream-event.types.js +5 -0
- package/dist/streaming-runtime-meta.js +29 -0
- package/dist/task-scheduler.js +695 -0
- package/dist/task-utils.js +13 -0
- package/dist/telegram-pairing.js +59 -0
- package/dist/telegram.js +897 -0
- package/dist/terminal-manager.js +307 -0
- package/dist/tool-step-display.js +1 -0
- package/dist/types.js +1 -0
- package/dist/utils.js +85 -0
- package/dist/web-context.js +161 -0
- package/dist/web.js +1377 -0
- package/dist/wechat-crypto.js +182 -0
- package/dist/wechat.js +589 -0
- package/dist/workspace-runtime-reset.js +35 -0
- package/package.json +107 -0
- package/shared/assistant-meta-footer.ts +127 -0
- package/shared/channel-prefixes.ts +16 -0
- package/shared/dist/assistant-meta-footer.d.ts +29 -0
- package/shared/dist/assistant-meta-footer.js +85 -0
- package/shared/dist/channel-prefixes.d.ts +4 -0
- package/shared/dist/channel-prefixes.js +16 -0
- package/shared/dist/image-detector.d.ts +20 -0
- package/shared/dist/image-detector.js +96 -0
- package/shared/dist/runtime-command-registry.d.ts +38 -0
- package/shared/dist/runtime-command-registry.js +185 -0
- package/shared/dist/stream-event.d.ts +65 -0
- package/shared/dist/stream-event.js +8 -0
- package/shared/dist/tool-step-display.d.ts +4 -0
- package/shared/dist/tool-step-display.js +11 -0
- package/shared/image-detector.ts +116 -0
- package/shared/runtime-command-registry.ts +252 -0
- package/shared/stream-event.ts +67 -0
- package/shared/tool-step-display.ts +21 -0
- package/shared/tsconfig.json +24 -0
- package/web/dist/assets/BillingPage-B1wBR_o-.js +52 -0
- package/web/dist/assets/ChatPage-6GBZ9nXN.css +32 -0
- package/web/dist/assets/ChatPage-BOJcXtaj.js +161 -0
- package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/web/dist/assets/SettingsPage-DoY7FoZ_.js +153 -0
- package/web/dist/assets/ShareImageDialog-C1ga8b7l.js +22 -0
- package/web/dist/assets/TasksPage-CRivnNsx.js +14 -0
- package/web/dist/assets/_basePickBy-Bf-bSoS9.js +1 -0
- package/web/dist/assets/_baseUniq-zAOaCuKw.js +1 -0
- package/web/dist/assets/arc-Dm9mVQ9U.js +1 -0
- package/web/dist/assets/architectureDiagram-2XIMDMQ5-BLmzX1wr.js +36 -0
- package/web/dist/assets/band-CquvqAHh.js +1 -0
- package/web/dist/assets/blockDiagram-WCTKOSBZ-B9pcqm3j.js +132 -0
- package/web/dist/assets/c4Diagram-IC4MRINW-Cytx1q3b.js +10 -0
- package/web/dist/assets/channel-BOVj73LR.js +1 -0
- package/web/dist/assets/channel-meta-CQD0Pei-.js +41 -0
- package/web/dist/assets/chunk-4BX2VUAB-0ToDr6RE.js +1 -0
- package/web/dist/assets/chunk-55IACEB6-DQDjnXfS.js +1 -0
- package/web/dist/assets/chunk-FMBD7UC4-Di8ABm6c.js +15 -0
- package/web/dist/assets/chunk-JSJVCQXG-BZQN6rnX.js +1 -0
- package/web/dist/assets/chunk-KX2RTZJC-zBbcpaN_.js +1 -0
- package/web/dist/assets/chunk-NQ4KR5QH-BCrLoU88.js +220 -0
- package/web/dist/assets/chunk-QZHKN3VN-Bqk8juan.js +1 -0
- package/web/dist/assets/chunk-WL4C6EOR-D2YX-MHY.js +189 -0
- package/web/dist/assets/classDiagram-VBA2DB6C-DUUoMyaK.js +1 -0
- package/web/dist/assets/classDiagram-v2-RAHNMMFH-DUUoMyaK.js +1 -0
- package/web/dist/assets/clone-BmaCesfa.js +1 -0
- package/web/dist/assets/cose-bilkent-S5V4N54A-CTsv6qQA.js +1 -0
- package/web/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
- package/web/dist/assets/dagre-KLK3FWXG-Ci4Jh9nu.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-E7M64L7V-BFRnfTI2.js +24 -0
- package/web/dist/assets/diagram-IFDJBPK2-B7Zhnp0b.js +43 -0
- package/web/dist/assets/diagram-P4PSJMXO-BVyP7nwq.js +24 -0
- package/web/dist/assets/erDiagram-INFDFZHY-NorKdTOF.js +70 -0
- package/web/dist/assets/error-CGD5mp5f.js +1 -0
- package/web/dist/assets/flowDiagram-PKNHOUZH-Ch97nABF.js +162 -0
- package/web/dist/assets/ganttDiagram-A5KZAMGK-BQ2pLWsy.js +292 -0
- package/web/dist/assets/gitGraphDiagram-K3NZZRJ6-bcvnBsD2.js +65 -0
- package/web/dist/assets/graph-CeAEckur.js +1 -0
- package/web/dist/assets/index-CPnL1_qC.js +768 -0
- package/web/dist/assets/index-DVevCbcO.css +10 -0
- package/web/dist/assets/infoDiagram-LFFYTUFH-CcsrFdj-.js +2 -0
- package/web/dist/assets/init-Dmth1JHB.js +1 -0
- package/web/dist/assets/ishikawaDiagram-PHBUUO56-1upyMfHN.js +70 -0
- package/web/dist/assets/journeyDiagram-4ABVD52K-CKUi-V0c.js +139 -0
- package/web/dist/assets/kanban-definition-K7BYSVSG-DOnQwXfL.js +89 -0
- package/web/dist/assets/layout-BmMMqTnJ.js +1 -0
- package/web/dist/assets/linear-DiaJloY5.js +1 -0
- package/web/dist/assets/mermaid.core-BWLV1B2v.js +254 -0
- package/web/dist/assets/mindmap-definition-YRQLILUH-BeAKHVWP.js +68 -0
- package/web/dist/assets/ordinal-DILIJJjt.js +1 -0
- package/web/dist/assets/pieDiagram-SKSYHLDU-DfiMSfWo.js +30 -0
- package/web/dist/assets/quadrantDiagram-337W2JSQ-wZxZOJxd.js +7 -0
- package/web/dist/assets/requirementDiagram-Z7DCOOCP-BK4HHm17.js +73 -0
- package/web/dist/assets/sankeyDiagram-WA2Y5GQK-BX6t2avX.js +10 -0
- package/web/dist/assets/sequenceDiagram-2WXFIKYE-BPQlkbAa.js +145 -0
- package/web/dist/assets/sheet-rI0FfB1g.js +6 -0
- package/web/dist/assets/sliders-horizontal-CuijWFNK.js +6 -0
- package/web/dist/assets/sparkles-BsMYXJoT.js +11 -0
- package/web/dist/assets/square-0CqMX1Q3.js +11 -0
- package/web/dist/assets/stateDiagram-RAJIS63D-DxkV0Vwd.js +1 -0
- package/web/dist/assets/stateDiagram-v2-FVOUBMTO-qLYoiOPe.js +1 -0
- package/web/dist/assets/step-D51IIHGA.js +1 -0
- package/web/dist/assets/tasks-D8JjBTwx.js +1 -0
- package/web/dist/assets/time-O8zIGux3.js +1 -0
- package/web/dist/assets/timeline-definition-YZTLITO2-kNp1DyFc.js +61 -0
- package/web/dist/assets/treemap-KZPCXAKY-CkrClVhk.js +162 -0
- package/web/dist/assets/utils-KGAn0XTg.js +11 -0
- package/web/dist/assets/vennDiagram-LZ73GAT5-CgdzEZz4.js +34 -0
- package/web/dist/assets/xychartDiagram-JWTSCODW-DfYGPfNB.js +7 -0
- package/web/dist/assets/zap-_hKJYy7J.js +6 -0
- package/web/dist/favicon.svg +332 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-55-Regular.woff2 +0 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-65-Medium.woff2 +0 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2 +0 -0
- package/web/dist/fonts/DMSans-latin-ext.woff2 +0 -0
- package/web/dist/fonts/DMSans-latin.woff2 +0 -0
- package/web/dist/icons/README.md +20 -0
- package/web/dist/icons/apple-touch-icon-180.png +0 -0
- package/web/dist/icons/icon-128.png +0 -0
- package/web/dist/icons/icon-144.png +0 -0
- package/web/dist/icons/icon-152.png +0 -0
- package/web/dist/icons/icon-192.png +0 -0
- package/web/dist/icons/icon-192.svg +332 -0
- package/web/dist/icons/icon-384.png +0 -0
- package/web/dist/icons/icon-48.png +0 -0
- package/web/dist/icons/icon-512-maskable.png +0 -0
- package/web/dist/icons/icon-512.png +0 -0
- package/web/dist/icons/icon-512.svg +332 -0
- package/web/dist/icons/icon-72.png +0 -0
- package/web/dist/icons/icon-96.png +0 -0
- package/web/dist/icons/loading-logo.svg +332 -0
- package/web/dist/icons/logo-1024.png +0 -0
- package/web/dist/icons/logo-icon.svg +332 -0
- package/web/dist/icons/logo-text.svg +332 -0
- package/web/dist/index.html +30 -0
- package/web/dist/manifest.webmanifest +1 -0
- package/web/dist/registerSW.js +1 -0
- package/web/dist/sw.js +1 -0
- package/web/dist/workbox-08d6266a.js +1 -0
|
@@ -0,0 +1,3107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli-claw Agent Runner
|
|
3
|
+
* Runs inside a container, receives config via stdin, outputs result to stdout
|
|
4
|
+
*
|
|
5
|
+
* Input protocol:
|
|
6
|
+
* Stdin: Full ContainerInput JSON (read until EOF, like before)
|
|
7
|
+
* IPC: Follow-up messages written as JSON files to /workspace/ipc/input/
|
|
8
|
+
* Files: {type:"message", text:"..."}.json — polled and consumed
|
|
9
|
+
* Sentinel: /workspace/ipc/input/_close — signals session end
|
|
10
|
+
*
|
|
11
|
+
* Stdout protocol:
|
|
12
|
+
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
|
|
13
|
+
* Multiple results may be emitted (one per agent teams result).
|
|
14
|
+
* Final marker after loop ends signals completion.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import {
|
|
20
|
+
query,
|
|
21
|
+
HookCallback,
|
|
22
|
+
PreCompactHookInput,
|
|
23
|
+
createSdkMcpServer,
|
|
24
|
+
} from '@anthropic-ai/claude-agent-sdk';
|
|
25
|
+
import {
|
|
26
|
+
ClientSideConnection,
|
|
27
|
+
ndJsonStream,
|
|
28
|
+
type ContentBlock,
|
|
29
|
+
type McpServer,
|
|
30
|
+
type PermissionOption,
|
|
31
|
+
type SessionMode,
|
|
32
|
+
type SessionNotification,
|
|
33
|
+
} from '@agentclientprotocol/sdk';
|
|
34
|
+
import { detectImageMimeTypeFromBase64Strict } from './image-detector.js';
|
|
35
|
+
import { getChannelFromJid } from './channel-prefixes.js';
|
|
36
|
+
import { spawn } from 'node:child_process';
|
|
37
|
+
import { Readable, Writable } from 'node:stream';
|
|
38
|
+
|
|
39
|
+
import type {
|
|
40
|
+
ContainerInput,
|
|
41
|
+
ContainerOutput,
|
|
42
|
+
ImageMediaType,
|
|
43
|
+
SessionsIndex,
|
|
44
|
+
SDKUserMessage,
|
|
45
|
+
ParsedMessage,
|
|
46
|
+
StreamEvent,
|
|
47
|
+
} from './types.js';
|
|
48
|
+
import type { StreamRuntimeIdentity } from './stream-event.types.js';
|
|
49
|
+
export type { StreamEventType, StreamEvent } from './types.js';
|
|
50
|
+
|
|
51
|
+
import { sanitizeFilename, generateFallbackName } from './utils.js';
|
|
52
|
+
import { StreamEventProcessor } from './stream-processor.js';
|
|
53
|
+
import { PREDEFINED_AGENTS } from './agent-definitions.js';
|
|
54
|
+
import { createMcpTools } from './mcp-tools.js';
|
|
55
|
+
import { readCodexCliConfig } from './codex-config.js';
|
|
56
|
+
|
|
57
|
+
// 路径解析:优先读取环境变量,降级到容器内默认路径(保持向后兼容)
|
|
58
|
+
const WORKSPACE_GROUP =
|
|
59
|
+
process.env.CLI_CLAW_WORKSPACE_GROUP || '/workspace/group';
|
|
60
|
+
const WORKSPACE_GLOBAL =
|
|
61
|
+
process.env.CLI_CLAW_WORKSPACE_GLOBAL || '/workspace/global';
|
|
62
|
+
const WORKSPACE_MEMORY =
|
|
63
|
+
process.env.CLI_CLAW_WORKSPACE_MEMORY || '/workspace/memory';
|
|
64
|
+
const WORKSPACE_IPC = process.env.CLI_CLAW_WORKSPACE_IPC || '/workspace/ipc';
|
|
65
|
+
|
|
66
|
+
// 模型配置:支持别名(opus/sonnet/haiku)或完整模型 ID
|
|
67
|
+
// 别名自动解析为最新版本,如 opus → Opus 4.6
|
|
68
|
+
// [1m] 后缀启用 1M 上下文窗口(CLI 内部 jG() 识别后缀,sM() 返回 1M 窗口)
|
|
69
|
+
const CLAUDE_MODEL = process.env.ANTHROPIC_MODEL || 'opus[1m]';
|
|
70
|
+
const CODEX_MODEL =
|
|
71
|
+
process.env.OPENAI_MODEL ||
|
|
72
|
+
process.env.CODEX_MODEL ||
|
|
73
|
+
'';
|
|
74
|
+
const CODEX_REASONING_EFFORT =
|
|
75
|
+
process.env.OPENAI_REASONING_EFFORT ||
|
|
76
|
+
process.env.CODEX_REASONING_EFFORT ||
|
|
77
|
+
process.env.REASONING_EFFORT ||
|
|
78
|
+
'';
|
|
79
|
+
const CODEX_CLI_CONFIG = readCodexCliConfig();
|
|
80
|
+
|
|
81
|
+
const IPC_INPUT_DIR = path.join(WORKSPACE_IPC, 'input');
|
|
82
|
+
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
|
83
|
+
const IPC_FALLBACK_POLL_MS = 5000; // 后备轮询间隔(仅防止 inotify 事件丢失)
|
|
84
|
+
const CODEX_INTERRUPT_POLL_MS = 250;
|
|
85
|
+
|
|
86
|
+
let needsMemoryFlush = false;
|
|
87
|
+
let hadCompaction = false;
|
|
88
|
+
// Module-level session ID so SIGTERM handler can emit it before exit.
|
|
89
|
+
// Updated in main() whenever a query returns a new session.
|
|
90
|
+
let latestSessionId: string | undefined;
|
|
91
|
+
let activeRuntimeIdentity: StreamRuntimeIdentity | null = null;
|
|
92
|
+
|
|
93
|
+
function normalizeRuntimeText(value: string | undefined): string | null {
|
|
94
|
+
if (!value) return null;
|
|
95
|
+
const trimmed = value.trim();
|
|
96
|
+
return trimmed || null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
100
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readStringField(
|
|
104
|
+
record: Record<string, unknown>,
|
|
105
|
+
key: string,
|
|
106
|
+
): string | null {
|
|
107
|
+
const value = record[key];
|
|
108
|
+
return typeof value === 'string' ? normalizeRuntimeText(value) : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readCodexConfigOption(
|
|
112
|
+
options: unknown,
|
|
113
|
+
optionKey: string,
|
|
114
|
+
): string | null {
|
|
115
|
+
if (Array.isArray(options)) {
|
|
116
|
+
for (const option of options) {
|
|
117
|
+
if (!isRecord(option)) continue;
|
|
118
|
+
const key =
|
|
119
|
+
readStringField(option, 'key') ??
|
|
120
|
+
readStringField(option, 'id') ??
|
|
121
|
+
readStringField(option, 'name');
|
|
122
|
+
if (key !== optionKey) continue;
|
|
123
|
+
const currentValue =
|
|
124
|
+
readStringField(option, 'currentValue') ??
|
|
125
|
+
readStringField(option, 'value') ??
|
|
126
|
+
readStringField(option, 'selectedValue');
|
|
127
|
+
if (currentValue) return currentValue;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (isRecord(options)) {
|
|
133
|
+
const direct = options[optionKey];
|
|
134
|
+
if (typeof direct === 'string') {
|
|
135
|
+
return normalizeRuntimeText(direct);
|
|
136
|
+
}
|
|
137
|
+
if (isRecord(direct)) {
|
|
138
|
+
return (
|
|
139
|
+
readStringField(direct, 'currentValue') ??
|
|
140
|
+
readStringField(direct, 'value') ??
|
|
141
|
+
readStringField(direct, 'selectedValue')
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function extractCodexRuntimeIdentity(
|
|
150
|
+
payload: unknown,
|
|
151
|
+
): StreamRuntimeIdentity | null {
|
|
152
|
+
if (!isRecord(payload)) return null;
|
|
153
|
+
|
|
154
|
+
let model =
|
|
155
|
+
readStringField(payload, 'model') ??
|
|
156
|
+
readStringField(payload, 'currentModelId');
|
|
157
|
+
let reasoningEffort =
|
|
158
|
+
readStringField(payload, 'reasoning_effort') ??
|
|
159
|
+
readStringField(payload, 'model_reasoning_effort');
|
|
160
|
+
|
|
161
|
+
const models = isRecord(payload.models) ? payload.models : null;
|
|
162
|
+
const currentModelId = models
|
|
163
|
+
? readStringField(models, 'currentModelId')
|
|
164
|
+
: null;
|
|
165
|
+
|
|
166
|
+
if ((!model || !reasoningEffort) && currentModelId) {
|
|
167
|
+
const [modelPart, effortPart] = currentModelId.split('/', 2);
|
|
168
|
+
model = model ?? normalizeRuntimeText(modelPart);
|
|
169
|
+
reasoningEffort = reasoningEffort ?? normalizeRuntimeText(effortPart);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const configOptions = payload.configOptions;
|
|
173
|
+
model =
|
|
174
|
+
model ??
|
|
175
|
+
readCodexConfigOption(configOptions, 'model') ??
|
|
176
|
+
readCodexConfigOption(payload.config, 'model');
|
|
177
|
+
reasoningEffort =
|
|
178
|
+
reasoningEffort ??
|
|
179
|
+
readCodexConfigOption(configOptions, 'reasoning_effort') ??
|
|
180
|
+
readCodexConfigOption(configOptions, 'model_reasoning_effort') ??
|
|
181
|
+
readCodexConfigOption(payload.config, 'reasoning_effort') ??
|
|
182
|
+
readCodexConfigOption(payload.config, 'model_reasoning_effort');
|
|
183
|
+
|
|
184
|
+
if (!model && !reasoningEffort) return null;
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
agentType: 'codex',
|
|
188
|
+
model,
|
|
189
|
+
reasoningEffort,
|
|
190
|
+
supportsReasoningEffort: true,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function buildRuntimeIdentity(
|
|
195
|
+
agentType: 'claude' | 'codex',
|
|
196
|
+
requestedRuntime?: Pick<ContainerInput, 'model' | 'reasoningEffort'>,
|
|
197
|
+
): StreamRuntimeIdentity {
|
|
198
|
+
if (agentType === 'codex') {
|
|
199
|
+
return {
|
|
200
|
+
agentType: 'codex',
|
|
201
|
+
model:
|
|
202
|
+
normalizeRuntimeText(requestedRuntime?.model ?? undefined) ??
|
|
203
|
+
normalizeRuntimeText(CODEX_MODEL) ??
|
|
204
|
+
normalizeRuntimeText(CODEX_CLI_CONFIG.model ?? undefined),
|
|
205
|
+
reasoningEffort:
|
|
206
|
+
normalizeRuntimeText(requestedRuntime?.reasoningEffort ?? undefined) ??
|
|
207
|
+
normalizeRuntimeText(CODEX_REASONING_EFFORT) ??
|
|
208
|
+
normalizeRuntimeText(CODEX_CLI_CONFIG.reasoningEffort ?? undefined),
|
|
209
|
+
supportsReasoningEffort: true,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
agentType: 'claude',
|
|
214
|
+
model:
|
|
215
|
+
normalizeRuntimeText(requestedRuntime?.model ?? undefined) ??
|
|
216
|
+
normalizeRuntimeText(CLAUDE_MODEL),
|
|
217
|
+
reasoningEffort: null,
|
|
218
|
+
supportsReasoningEffort: false,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const DEFAULT_ALLOWED_TOOLS = [
|
|
223
|
+
'Bash',
|
|
224
|
+
'Read',
|
|
225
|
+
'Write',
|
|
226
|
+
'Edit',
|
|
227
|
+
'Glob',
|
|
228
|
+
'Grep',
|
|
229
|
+
'WebSearch',
|
|
230
|
+
'WebFetch',
|
|
231
|
+
'Task',
|
|
232
|
+
'TaskOutput',
|
|
233
|
+
'TaskStop',
|
|
234
|
+
'TeamCreate',
|
|
235
|
+
'TeamDelete',
|
|
236
|
+
'SendMessage',
|
|
237
|
+
'TodoWrite',
|
|
238
|
+
'ToolSearch',
|
|
239
|
+
'Skill',
|
|
240
|
+
'NotebookEdit',
|
|
241
|
+
'mcp__cli-claw__*',
|
|
242
|
+
];
|
|
243
|
+
|
|
244
|
+
const MEMORY_FLUSH_ALLOWED_TOOLS = [
|
|
245
|
+
'mcp__cli-claw__memory_search',
|
|
246
|
+
'mcp__cli-claw__memory_get',
|
|
247
|
+
'mcp__cli-claw__memory_append',
|
|
248
|
+
'Read', // 读取全局 AGENTS.md 当前内容
|
|
249
|
+
'Edit', // 编辑全局 AGENTS.md(永久记忆)
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
// Memory flush 期间禁用的工具(disallowedTools 会从模型上下文中完全移除这些工具)
|
|
253
|
+
// 注意:allowedTools 仅控制自动审批,不限制工具可见性;
|
|
254
|
+
// bypassPermissions 模式下所有工具都自动通过,所以必须用 disallowedTools 来限制
|
|
255
|
+
const MEMORY_FLUSH_DISALLOWED_TOOLS = [
|
|
256
|
+
'Bash',
|
|
257
|
+
'Write',
|
|
258
|
+
'WebSearch',
|
|
259
|
+
'WebFetch',
|
|
260
|
+
'Glob',
|
|
261
|
+
'Grep',
|
|
262
|
+
'Task',
|
|
263
|
+
'TaskOutput',
|
|
264
|
+
'TaskStop',
|
|
265
|
+
'TeamCreate',
|
|
266
|
+
'TeamDelete',
|
|
267
|
+
'SendMessage',
|
|
268
|
+
'TodoWrite',
|
|
269
|
+
'ToolSearch',
|
|
270
|
+
'Skill',
|
|
271
|
+
'NotebookEdit',
|
|
272
|
+
'mcp__cli-claw__send_message',
|
|
273
|
+
'mcp__cli-claw__schedule_task',
|
|
274
|
+
'mcp__cli-claw__list_tasks',
|
|
275
|
+
'mcp__cli-claw__pause_task',
|
|
276
|
+
'mcp__cli-claw__resume_task',
|
|
277
|
+
'mcp__cli-claw__cancel_task',
|
|
278
|
+
'mcp__cli-claw__register_group',
|
|
279
|
+
];
|
|
280
|
+
|
|
281
|
+
const IMAGE_MAX_DIMENSION = 8000; // Anthropic API 限制
|
|
282
|
+
|
|
283
|
+
// ── 系统提示词优化:安全守则(从独立 Markdown 文件加载,始终注入所有容器) ──
|
|
284
|
+
|
|
285
|
+
const SECURITY_RULES_PATH = path.join(
|
|
286
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
287
|
+
'..',
|
|
288
|
+
'prompts',
|
|
289
|
+
'security-rules.md',
|
|
290
|
+
);
|
|
291
|
+
const SECURITY_RULES = fs.readFileSync(SECURITY_RULES_PATH, 'utf-8');
|
|
292
|
+
|
|
293
|
+
// globalAgentMemory 截断保护:防止用户 AGENTS.md 过大导致系统提示词膨胀
|
|
294
|
+
const GLOBAL_AGENT_MEMORY_MAX_CHARS = 8000;
|
|
295
|
+
|
|
296
|
+
/** Head+Tail 截断:保留头 75% + 尾 25%,中间标记已截断 */
|
|
297
|
+
function truncateWithHeadTail(content: string, maxChars: number): string {
|
|
298
|
+
if (content.length <= maxChars) return content;
|
|
299
|
+
const headSize = Math.floor(maxChars * 0.75);
|
|
300
|
+
const tailSize = Math.max(0, maxChars - headSize - 30);
|
|
301
|
+
return (
|
|
302
|
+
content.slice(0, headSize) +
|
|
303
|
+
'\n\n[...内容过长,已截断...]\n\n' +
|
|
304
|
+
content.slice(-tailSize)
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** 按渠道生成格式指南(仅 IM 渠道需要,Web 前端原生支持 Markdown + Mermaid) */
|
|
309
|
+
function buildChannelGuidelines(channel: string): string {
|
|
310
|
+
switch (channel) {
|
|
311
|
+
case 'feishu':
|
|
312
|
+
return [
|
|
313
|
+
'## 飞书消息格式',
|
|
314
|
+
'',
|
|
315
|
+
'当前消息来自飞书。飞书卡片支持的 Markdown:**加粗**、_斜体_、`行内代码`、代码块、标题、列表、链接。',
|
|
316
|
+
'用户同时可以在 Web 端查看你的回复,Web 端支持完整 Markdown + Mermaid 图表渲染,因此**不要因为来源是飞书就限制输出格式**。',
|
|
317
|
+
'可使用 `send_image` 和 `send_file` 工具直接发送文件到飞书。',
|
|
318
|
+
].join('\n');
|
|
319
|
+
case 'telegram':
|
|
320
|
+
return [
|
|
321
|
+
'## Telegram 消息格式',
|
|
322
|
+
'',
|
|
323
|
+
'当前消息来自 Telegram。Markdown 自动转换为 Telegram HTML,长消息自动分片(3800 字符)。',
|
|
324
|
+
'用户同时可以在 Web 端查看你的回复,Web 端支持完整 Markdown + Mermaid 图表渲染,因此**不要因为来源是 Telegram 就限制输出格式**。',
|
|
325
|
+
'可使用 `send_image` 和 `send_file` 工具直接发送文件到 Telegram。',
|
|
326
|
+
].join('\n');
|
|
327
|
+
case 'qq':
|
|
328
|
+
return [
|
|
329
|
+
'## QQ 消息格式',
|
|
330
|
+
'',
|
|
331
|
+
'当前消息来自 QQ。Markdown 自动转换为纯文本,长消息自动分片(5000 字符)。',
|
|
332
|
+
'用户同时可以在 Web 端查看你的回复,Web 端支持完整 Markdown + Mermaid 图表渲染,因此**不要因为来源是 QQ 就限制输出格式**。',
|
|
333
|
+
].join('\n');
|
|
334
|
+
default:
|
|
335
|
+
return '';
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* 规范化图片 MIME:
|
|
341
|
+
* - 优先使用声明值(若合法且与内容一致)
|
|
342
|
+
* - 若声明缺失或与内容不一致,使用内容识别值
|
|
343
|
+
* - 最后兜底 image/jpeg
|
|
344
|
+
*/
|
|
345
|
+
function resolveImageMimeType(img: {
|
|
346
|
+
data: string;
|
|
347
|
+
mimeType?: string;
|
|
348
|
+
}): ImageMediaType {
|
|
349
|
+
const declared =
|
|
350
|
+
typeof img.mimeType === 'string' && img.mimeType.startsWith('image/')
|
|
351
|
+
? img.mimeType.toLowerCase()
|
|
352
|
+
: undefined;
|
|
353
|
+
const detected = detectImageMimeTypeFromBase64Strict(img.data);
|
|
354
|
+
|
|
355
|
+
if (declared && detected && declared !== detected) {
|
|
356
|
+
log(
|
|
357
|
+
`Image MIME mismatch: declared=${declared}, detected=${detected}, using detected`,
|
|
358
|
+
);
|
|
359
|
+
return detected as ImageMediaType;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return (declared || detected || 'image/jpeg') as ImageMediaType;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* 从 base64 编码的图片数据中提取宽高(支持 PNG / JPEG / GIF / WebP / BMP)。
|
|
367
|
+
* 仅解析头部字节,不需要完整解码图片。
|
|
368
|
+
* 返回 null 表示无法识别格式。
|
|
369
|
+
*/
|
|
370
|
+
function getImageDimensions(
|
|
371
|
+
base64Data: string,
|
|
372
|
+
): { width: number; height: number } | null {
|
|
373
|
+
try {
|
|
374
|
+
const headerB64 = base64Data.slice(0, 400);
|
|
375
|
+
const buf = Buffer.from(headerB64, 'base64');
|
|
376
|
+
|
|
377
|
+
// PNG: 固定位置 (bytes 16-23)
|
|
378
|
+
if (
|
|
379
|
+
buf.length >= 24 &&
|
|
380
|
+
buf[0] === 0x89 &&
|
|
381
|
+
buf[1] === 0x50 &&
|
|
382
|
+
buf[2] === 0x4e &&
|
|
383
|
+
buf[3] === 0x47
|
|
384
|
+
) {
|
|
385
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// JPEG: 扫描 SOF marker(SOF 可能在大 EXIF/ICC 之后,需要 ~30KB)
|
|
389
|
+
if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) {
|
|
390
|
+
const JPEG_SCAN_B64_LEN = 40000; // ~30KB binary,覆盖大多数 EXIF/ICC 场景
|
|
391
|
+
const fullHeader = Buffer.from(
|
|
392
|
+
base64Data.slice(0, JPEG_SCAN_B64_LEN),
|
|
393
|
+
'base64',
|
|
394
|
+
);
|
|
395
|
+
for (let i = 2; i < fullHeader.length - 9; i++) {
|
|
396
|
+
if (fullHeader[i] !== 0xff) continue;
|
|
397
|
+
const marker = fullHeader[i + 1];
|
|
398
|
+
if (marker >= 0xc0 && marker <= 0xc3) {
|
|
399
|
+
return {
|
|
400
|
+
width: fullHeader.readUInt16BE(i + 7),
|
|
401
|
+
height: fullHeader.readUInt16BE(i + 5),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
if (marker !== 0xd8 && marker !== 0xd9 && marker !== 0x00) {
|
|
405
|
+
i += 1 + fullHeader.readUInt16BE(i + 2);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// GIF: bytes 6-9 (little-endian)
|
|
411
|
+
if (
|
|
412
|
+
buf.length >= 10 &&
|
|
413
|
+
buf[0] === 0x47 &&
|
|
414
|
+
buf[1] === 0x49 &&
|
|
415
|
+
buf[2] === 0x46
|
|
416
|
+
) {
|
|
417
|
+
return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// BMP: bytes 18-25
|
|
421
|
+
if (buf.length >= 26 && buf[0] === 0x42 && buf[1] === 0x4d) {
|
|
422
|
+
return {
|
|
423
|
+
width: buf.readInt32LE(18),
|
|
424
|
+
height: Math.abs(buf.readInt32LE(22)),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// WebP
|
|
429
|
+
if (
|
|
430
|
+
buf.length >= 30 &&
|
|
431
|
+
buf[0] === 0x52 &&
|
|
432
|
+
buf[1] === 0x49 &&
|
|
433
|
+
buf[2] === 0x46 &&
|
|
434
|
+
buf[3] === 0x46
|
|
435
|
+
) {
|
|
436
|
+
const fourCC = buf.toString('ascii', 12, 16);
|
|
437
|
+
if (fourCC === 'VP8 ' && buf.length >= 30)
|
|
438
|
+
return {
|
|
439
|
+
width: buf.readUInt16LE(26) & 0x3fff,
|
|
440
|
+
height: buf.readUInt16LE(28) & 0x3fff,
|
|
441
|
+
};
|
|
442
|
+
if (fourCC === 'VP8L' && buf.length >= 25) {
|
|
443
|
+
const b = buf.readUInt32LE(21);
|
|
444
|
+
return { width: (b & 0x3fff) + 1, height: ((b >> 14) & 0x3fff) + 1 };
|
|
445
|
+
}
|
|
446
|
+
if (fourCC === 'VP8X' && buf.length >= 30)
|
|
447
|
+
return {
|
|
448
|
+
width: (buf[24] | (buf[25] << 8) | (buf[26] << 16)) + 1,
|
|
449
|
+
height: (buf[27] | (buf[28] << 8) | (buf[29] << 16)) + 1,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return null;
|
|
454
|
+
} catch {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* 过滤超过 API 尺寸限制的图片。
|
|
461
|
+
*/
|
|
462
|
+
function filterOversizedImages(
|
|
463
|
+
images: Array<{ data: string; mimeType?: string }>,
|
|
464
|
+
): { valid: Array<{ data: string; mimeType?: string }>; rejected: string[] } {
|
|
465
|
+
const valid: Array<{ data: string; mimeType?: string }> = [];
|
|
466
|
+
const rejected: string[] = [];
|
|
467
|
+
for (const img of images) {
|
|
468
|
+
const dims = getImageDimensions(img.data);
|
|
469
|
+
if (
|
|
470
|
+
dims &&
|
|
471
|
+
(dims.width > IMAGE_MAX_DIMENSION || dims.height > IMAGE_MAX_DIMENSION)
|
|
472
|
+
) {
|
|
473
|
+
const reason = `图片尺寸 ${dims.width}×${dims.height} 超过 API 限制(最大 ${IMAGE_MAX_DIMENSION}px),已跳过`;
|
|
474
|
+
log(reason);
|
|
475
|
+
rejected.push(reason);
|
|
476
|
+
} else {
|
|
477
|
+
valid.push(img);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return { valid, rejected };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Push-based async iterable for streaming user messages to the SDK.
|
|
485
|
+
* Keeps the iterable alive until end() is called, preventing isSingleUserTurn.
|
|
486
|
+
*/
|
|
487
|
+
class MessageStream {
|
|
488
|
+
private queue: SDKUserMessage[] = [];
|
|
489
|
+
private waiting: (() => void) | null = null;
|
|
490
|
+
private done = false;
|
|
491
|
+
|
|
492
|
+
push(
|
|
493
|
+
text: string,
|
|
494
|
+
images?: Array<{ data: string; mimeType?: string }>,
|
|
495
|
+
): string[] {
|
|
496
|
+
const rejectedReasons: string[] = [];
|
|
497
|
+
let filteredImages = images;
|
|
498
|
+
|
|
499
|
+
// 过滤超限图片,在发送给 SDK 之前拦截
|
|
500
|
+
if (filteredImages && filteredImages.length > 0) {
|
|
501
|
+
const { valid, rejected } = filterOversizedImages(filteredImages);
|
|
502
|
+
rejectedReasons.push(...rejected);
|
|
503
|
+
filteredImages = valid.length > 0 ? valid : undefined;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
let content:
|
|
507
|
+
| string
|
|
508
|
+
| Array<
|
|
509
|
+
| { type: 'text'; text: string }
|
|
510
|
+
| {
|
|
511
|
+
type: 'image';
|
|
512
|
+
source: {
|
|
513
|
+
type: 'base64';
|
|
514
|
+
media_type: ImageMediaType;
|
|
515
|
+
data: string;
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
>;
|
|
519
|
+
|
|
520
|
+
if (filteredImages && filteredImages.length > 0) {
|
|
521
|
+
// 多模态消息:text + images
|
|
522
|
+
content = [
|
|
523
|
+
{ type: 'text', text },
|
|
524
|
+
...filteredImages.map((img) => ({
|
|
525
|
+
type: 'image' as const,
|
|
526
|
+
source: {
|
|
527
|
+
type: 'base64' as const,
|
|
528
|
+
media_type: resolveImageMimeType(img),
|
|
529
|
+
data: img.data,
|
|
530
|
+
},
|
|
531
|
+
})),
|
|
532
|
+
];
|
|
533
|
+
} else {
|
|
534
|
+
// 纯文本消息
|
|
535
|
+
content = text;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
this.queue.push({
|
|
539
|
+
type: 'user',
|
|
540
|
+
message: { role: 'user', content },
|
|
541
|
+
parent_tool_use_id: null,
|
|
542
|
+
session_id: '',
|
|
543
|
+
});
|
|
544
|
+
this.waiting?.();
|
|
545
|
+
return rejectedReasons;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
end(): void {
|
|
549
|
+
this.done = true;
|
|
550
|
+
this.waiting?.();
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<SDKUserMessage> {
|
|
554
|
+
while (true) {
|
|
555
|
+
while (this.queue.length > 0) {
|
|
556
|
+
yield this.queue.shift()!;
|
|
557
|
+
}
|
|
558
|
+
if (this.done) return;
|
|
559
|
+
await new Promise<void>((r) => {
|
|
560
|
+
this.waiting = r;
|
|
561
|
+
});
|
|
562
|
+
this.waiting = null;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function readStdin(): Promise<string> {
|
|
568
|
+
return new Promise((resolve, reject) => {
|
|
569
|
+
let data = '';
|
|
570
|
+
process.stdin.setEncoding('utf8');
|
|
571
|
+
process.stdin.on('data', (chunk) => {
|
|
572
|
+
data += chunk;
|
|
573
|
+
});
|
|
574
|
+
process.stdin.on('end', () => resolve(data));
|
|
575
|
+
process.stdin.on('error', reject);
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const OUTPUT_START_MARKER = '---CLI_CLAW_OUTPUT_START---';
|
|
580
|
+
const OUTPUT_END_MARKER = '---CLI_CLAW_OUTPUT_END---';
|
|
581
|
+
|
|
582
|
+
function writeOutput(output: ContainerOutput): void {
|
|
583
|
+
const runtimeIdentity = output.runtimeIdentity ?? activeRuntimeIdentity;
|
|
584
|
+
if (runtimeIdentity) {
|
|
585
|
+
output = {
|
|
586
|
+
...output,
|
|
587
|
+
runtimeIdentity,
|
|
588
|
+
...(output.streamEvent
|
|
589
|
+
? {
|
|
590
|
+
streamEvent: output.streamEvent.runtimeIdentity
|
|
591
|
+
? output.streamEvent
|
|
592
|
+
: { ...output.streamEvent, runtimeIdentity },
|
|
593
|
+
}
|
|
594
|
+
: {}),
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
console.log(OUTPUT_START_MARKER);
|
|
598
|
+
console.log(JSON.stringify(output));
|
|
599
|
+
console.log(OUTPUT_END_MARKER);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function log(message: string): void {
|
|
603
|
+
console.error(`[agent-runner] ${message}`);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function generateTurnId(): string {
|
|
607
|
+
return `ipc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Normalize isMain/isHome/isAdminHome flags for backward compatibility.
|
|
612
|
+
* If the host sends the old `isMain` field, treat it as isHome=true + isAdminHome=true.
|
|
613
|
+
*/
|
|
614
|
+
function normalizeHomeFlags(input: ContainerInput): {
|
|
615
|
+
isHome: boolean;
|
|
616
|
+
isAdminHome: boolean;
|
|
617
|
+
} {
|
|
618
|
+
if (input.isHome !== undefined) {
|
|
619
|
+
return { isHome: !!input.isHome, isAdminHome: !!input.isAdminHome };
|
|
620
|
+
}
|
|
621
|
+
// Legacy: isMain was the only flag
|
|
622
|
+
const legacy = !!input.isMain;
|
|
623
|
+
return { isHome: legacy, isAdminHome: legacy };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* 检测是否为上下文溢出错误
|
|
628
|
+
*/
|
|
629
|
+
function isContextOverflowError(msg: string): boolean {
|
|
630
|
+
const patterns: RegExp[] = [
|
|
631
|
+
/prompt is too long/i,
|
|
632
|
+
/maximum context length/i,
|
|
633
|
+
/context.*too large/i,
|
|
634
|
+
/exceeds.*token limit/i,
|
|
635
|
+
/context window.*exceeded/i,
|
|
636
|
+
];
|
|
637
|
+
return patterns.some((pattern) => pattern.test(msg));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* 检测会话转录中不可恢复的请求错误(400 invalid_request_error)。
|
|
642
|
+
* 这类错误被固化在会话历史中,每次 resume 都会重放导致永久失败。
|
|
643
|
+
* 例如:图片尺寸超过 8000px 限制、图片 MIME 声明与真实内容不一致等。
|
|
644
|
+
*
|
|
645
|
+
* 判定条件:必须同时满足「图片特征」+「API 拒绝」,避免对通用 400 错误误判导致会话丢失。
|
|
646
|
+
*/
|
|
647
|
+
function isImageMimeMismatchError(msg: string): boolean {
|
|
648
|
+
return (
|
|
649
|
+
/image\s+was\s+specified\s+using\s+the\s+image\/[a-z0-9.+-]+\s+media\s+type,\s+but\s+the\s+image\s+appears\s+to\s+be\s+(?:an?\s+)?image\/[a-z0-9.+-]+\s+image/i.test(
|
|
650
|
+
msg,
|
|
651
|
+
) ||
|
|
652
|
+
/image\/[a-z0-9.+-]+\s+media\s+type.*appears\s+to\s+be.*image\/[a-z0-9.+-]+/i.test(
|
|
653
|
+
msg,
|
|
654
|
+
)
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function isUnrecoverableTranscriptError(msg: string): boolean {
|
|
659
|
+
const isImageSizeError =
|
|
660
|
+
/image.*dimensions?\s+exceed/i.test(msg) ||
|
|
661
|
+
/max\s+allowed\s+size.*pixels/i.test(msg);
|
|
662
|
+
const isMimeMismatch = isImageMimeMismatchError(msg);
|
|
663
|
+
const isApiReject = /invalid_request_error/i.test(msg);
|
|
664
|
+
return isApiReject && (isImageSizeError || isMimeMismatch);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function getSessionSummary(
|
|
668
|
+
sessionId: string,
|
|
669
|
+
transcriptPath: string,
|
|
670
|
+
): string | null {
|
|
671
|
+
const projectDir = path.dirname(transcriptPath);
|
|
672
|
+
const indexPath = path.join(projectDir, 'sessions-index.json');
|
|
673
|
+
|
|
674
|
+
if (!fs.existsSync(indexPath)) {
|
|
675
|
+
log(`Sessions index not found at ${indexPath}`);
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
try {
|
|
680
|
+
const index: SessionsIndex = JSON.parse(
|
|
681
|
+
fs.readFileSync(indexPath, 'utf-8'),
|
|
682
|
+
);
|
|
683
|
+
const entry = index.entries.find((e) => e.sessionId === sessionId);
|
|
684
|
+
if (entry?.summary) {
|
|
685
|
+
return entry.summary;
|
|
686
|
+
}
|
|
687
|
+
} catch (err) {
|
|
688
|
+
log(
|
|
689
|
+
`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`,
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Trim session JSONL file by removing all entries before the last compact_boundary.
|
|
698
|
+
* After compaction, entries before the boundary are already summarized and no longer
|
|
699
|
+
* needed for session reconstruction. This prevents unbounded file growth.
|
|
700
|
+
*
|
|
701
|
+
* Safety: uses atomic write (tmp + rename) to avoid data loss on crash.
|
|
702
|
+
*/
|
|
703
|
+
function trimSessionJsonl(jsonlPath: string): void {
|
|
704
|
+
try {
|
|
705
|
+
const content = fs.readFileSync(jsonlPath, 'utf-8');
|
|
706
|
+
const lines = content.split('\n');
|
|
707
|
+
const nonEmptyLines: { index: number; line: string }[] = [];
|
|
708
|
+
for (let i = 0; i < lines.length; i++) {
|
|
709
|
+
if (lines[i].trim()) nonEmptyLines.push({ index: i, line: lines[i] });
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Find the last compact_boundary entry
|
|
713
|
+
let lastBoundaryPos = -1;
|
|
714
|
+
let parseSkipped = 0;
|
|
715
|
+
for (let i = nonEmptyLines.length - 1; i >= 0; i--) {
|
|
716
|
+
try {
|
|
717
|
+
const entry = JSON.parse(nonEmptyLines[i].line);
|
|
718
|
+
if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
|
|
719
|
+
lastBoundaryPos = i;
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
} catch {
|
|
723
|
+
parseSkipped++;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (parseSkipped > 0) {
|
|
727
|
+
log(`Session trim: skipped ${parseSkipped} unparseable JSONL lines`);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (lastBoundaryPos <= 0) {
|
|
731
|
+
// No boundary found or it's already the first entry — nothing to trim
|
|
732
|
+
log('Session trim: no compact_boundary found or already minimal');
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Keep entries from last compact_boundary onwards
|
|
737
|
+
const trimmedLines = nonEmptyLines
|
|
738
|
+
.slice(lastBoundaryPos)
|
|
739
|
+
.map((e) => e.line);
|
|
740
|
+
const removedCount = lastBoundaryPos;
|
|
741
|
+
|
|
742
|
+
const TRIM_MIN_ENTRIES = 50; // Skip trimming if fewer entries before boundary (not worth the I/O)
|
|
743
|
+
if (removedCount < TRIM_MIN_ENTRIES) {
|
|
744
|
+
log(
|
|
745
|
+
`Session trim: only ${removedCount} entries before boundary, skipping`,
|
|
746
|
+
);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Atomic write: temp file + rename
|
|
751
|
+
const tmpPath = jsonlPath + '.trim-tmp';
|
|
752
|
+
fs.writeFileSync(tmpPath, trimmedLines.join('\n') + '\n');
|
|
753
|
+
fs.renameSync(tmpPath, jsonlPath);
|
|
754
|
+
|
|
755
|
+
const sizeBefore = Buffer.byteLength(content, 'utf-8');
|
|
756
|
+
const sizeAfter = fs.statSync(jsonlPath).size;
|
|
757
|
+
log(
|
|
758
|
+
`Session trim: ${nonEmptyLines.length} → ${trimmedLines.length} entries (removed ${removedCount}), ` +
|
|
759
|
+
`${(sizeBefore / 1024 / 1024).toFixed(1)}MB → ${(sizeAfter / 1024 / 1024).toFixed(1)}MB`,
|
|
760
|
+
);
|
|
761
|
+
} catch (err) {
|
|
762
|
+
log(
|
|
763
|
+
`Session trim failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Archive the full transcript to conversations/ before compaction.
|
|
770
|
+
* Also flush any accumulated streaming text as a compact_partial message
|
|
771
|
+
* so users don't lose the response that was being generated.
|
|
772
|
+
* Finally, trim the JSONL file to remove already-compacted history.
|
|
773
|
+
*/
|
|
774
|
+
function createPreCompactHook(
|
|
775
|
+
isHome: boolean,
|
|
776
|
+
_isAdminHome: boolean,
|
|
777
|
+
deps: {
|
|
778
|
+
emit: (output: ContainerOutput) => void;
|
|
779
|
+
getFullText: () => string;
|
|
780
|
+
resetFullText: () => void;
|
|
781
|
+
},
|
|
782
|
+
): HookCallback {
|
|
783
|
+
return async (input, _toolUseId, _context) => {
|
|
784
|
+
const preCompact = input as PreCompactHookInput;
|
|
785
|
+
const transcriptPath = preCompact.transcript_path;
|
|
786
|
+
const sessionId = preCompact.session_id;
|
|
787
|
+
|
|
788
|
+
// Skip sub-agent compactions — they'd archive the unchanged main transcript
|
|
789
|
+
// and set hadCompaction, triggering spurious auto-continue + memory flush (#321)
|
|
790
|
+
if (preCompact.agent_id) {
|
|
791
|
+
log(
|
|
792
|
+
`PreCompact: skipping sub-agent compact (agent_id=${preCompact.agent_id})`,
|
|
793
|
+
);
|
|
794
|
+
return {};
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ── Flush accumulated streaming text as compact_partial ──
|
|
798
|
+
// This ensures users see the partial response even after compaction.
|
|
799
|
+
const partialText = deps.getFullText();
|
|
800
|
+
if (partialText.trim()) {
|
|
801
|
+
log(
|
|
802
|
+
`PreCompact: flushing ${partialText.length} chars as compact_partial`,
|
|
803
|
+
);
|
|
804
|
+
deps.emit({
|
|
805
|
+
status: 'success',
|
|
806
|
+
result: partialText,
|
|
807
|
+
sourceKind: 'compact_partial',
|
|
808
|
+
finalizationReason: 'completed',
|
|
809
|
+
});
|
|
810
|
+
deps.resetFullText();
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
|
814
|
+
log('No transcript found for archiving');
|
|
815
|
+
return {};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
try {
|
|
819
|
+
const content = fs.readFileSync(transcriptPath, 'utf-8');
|
|
820
|
+
const messages = parseTranscript(content);
|
|
821
|
+
|
|
822
|
+
if (messages.length === 0) {
|
|
823
|
+
log('No messages to archive');
|
|
824
|
+
return {};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const summary = getSessionSummary(sessionId, transcriptPath);
|
|
828
|
+
const name = summary ? sanitizeFilename(summary) : generateFallbackName();
|
|
829
|
+
|
|
830
|
+
const conversationsDir = path.join(WORKSPACE_GROUP, 'conversations');
|
|
831
|
+
fs.mkdirSync(conversationsDir, { recursive: true });
|
|
832
|
+
|
|
833
|
+
const date = new Date().toISOString().split('T')[0];
|
|
834
|
+
const filename = `${date}-${name}.md`;
|
|
835
|
+
const filePath = path.join(conversationsDir, filename);
|
|
836
|
+
|
|
837
|
+
const markdown = formatTranscriptMarkdown(messages, summary);
|
|
838
|
+
fs.writeFileSync(filePath, markdown);
|
|
839
|
+
|
|
840
|
+
log(`Archived conversation to ${filePath}`);
|
|
841
|
+
} catch (err) {
|
|
842
|
+
log(
|
|
843
|
+
`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`,
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// ── Trim session JSONL to prevent unbounded growth ──
|
|
848
|
+
// Remove entries before the last compact_boundary (already summarized).
|
|
849
|
+
// Must run AFTER archiving (archive needs full transcript).
|
|
850
|
+
trimSessionJsonl(transcriptPath);
|
|
851
|
+
|
|
852
|
+
// Flag compaction so the query loop auto-continues instead of
|
|
853
|
+
// waiting for user input (non-blocking compaction #229).
|
|
854
|
+
hadCompaction = true;
|
|
855
|
+
|
|
856
|
+
// Flag memory flush for home containers (full memory write access)
|
|
857
|
+
if (isHome) {
|
|
858
|
+
needsMemoryFlush = true;
|
|
859
|
+
log('PreCompact: flagged memory flush for home container');
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return {};
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function parseTranscript(content: string): ParsedMessage[] {
|
|
867
|
+
const messages: ParsedMessage[] = [];
|
|
868
|
+
|
|
869
|
+
for (const line of content.split('\n')) {
|
|
870
|
+
if (!line.trim()) continue;
|
|
871
|
+
try {
|
|
872
|
+
const entry = JSON.parse(line);
|
|
873
|
+
if (entry.type === 'user' && entry.message?.content) {
|
|
874
|
+
const text =
|
|
875
|
+
typeof entry.message.content === 'string'
|
|
876
|
+
? entry.message.content
|
|
877
|
+
: entry.message.content
|
|
878
|
+
.map((c: { text?: string }) => c.text || '')
|
|
879
|
+
.join('');
|
|
880
|
+
if (text) messages.push({ role: 'user', content: text });
|
|
881
|
+
} else if (entry.type === 'assistant' && entry.message?.content) {
|
|
882
|
+
const textParts = entry.message.content
|
|
883
|
+
.filter((c: { type: string }) => c.type === 'text')
|
|
884
|
+
.map((c: { text: string }) => c.text);
|
|
885
|
+
const text = textParts.join('');
|
|
886
|
+
if (text) messages.push({ role: 'assistant', content: text });
|
|
887
|
+
}
|
|
888
|
+
} catch {}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
return messages;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function formatTranscriptMarkdown(
|
|
895
|
+
messages: ParsedMessage[],
|
|
896
|
+
title?: string | null,
|
|
897
|
+
): string {
|
|
898
|
+
const now = new Date();
|
|
899
|
+
const formatDateTime = (d: Date) =>
|
|
900
|
+
d.toLocaleString('en-US', {
|
|
901
|
+
month: 'short',
|
|
902
|
+
day: 'numeric',
|
|
903
|
+
hour: 'numeric',
|
|
904
|
+
minute: '2-digit',
|
|
905
|
+
hour12: true,
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
const lines: string[] = [];
|
|
909
|
+
lines.push(`# ${title || 'Conversation'}`);
|
|
910
|
+
lines.push('');
|
|
911
|
+
lines.push(`Archived: ${formatDateTime(now)}`);
|
|
912
|
+
lines.push('');
|
|
913
|
+
lines.push('---');
|
|
914
|
+
lines.push('');
|
|
915
|
+
|
|
916
|
+
for (const msg of messages) {
|
|
917
|
+
const sender = msg.role === 'user' ? 'User' : 'cli-claw';
|
|
918
|
+
const content =
|
|
919
|
+
msg.content.length > 2000
|
|
920
|
+
? msg.content.slice(0, 2000) + '...'
|
|
921
|
+
: msg.content;
|
|
922
|
+
lines.push(`**${sender}**: ${content}`);
|
|
923
|
+
lines.push('');
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
return lines.join('\n');
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Check for _close sentinel.
|
|
931
|
+
*/
|
|
932
|
+
function shouldClose(): boolean {
|
|
933
|
+
if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) {
|
|
934
|
+
try {
|
|
935
|
+
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
|
|
936
|
+
} catch {
|
|
937
|
+
/* ignore */
|
|
938
|
+
}
|
|
939
|
+
return true;
|
|
940
|
+
}
|
|
941
|
+
return false;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const IPC_INPUT_DRAIN_SENTINEL = path.join(IPC_INPUT_DIR, '_drain');
|
|
945
|
+
|
|
946
|
+
const IPC_INPUT_INTERRUPT_SENTINEL = path.join(IPC_INPUT_DIR, '_interrupt');
|
|
947
|
+
const INTERRUPT_GRACE_WINDOW_MS = 10_000;
|
|
948
|
+
let lastInterruptRequestedAt = 0;
|
|
949
|
+
|
|
950
|
+
function markInterruptRequested(): void {
|
|
951
|
+
lastInterruptRequestedAt = Date.now();
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function clearInterruptRequested(): void {
|
|
955
|
+
lastInterruptRequestedAt = 0;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function isWithinInterruptGraceWindow(): boolean {
|
|
959
|
+
return (
|
|
960
|
+
lastInterruptRequestedAt > 0 &&
|
|
961
|
+
Date.now() - lastInterruptRequestedAt <= INTERRUPT_GRACE_WINDOW_MS
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function isInterruptRelatedError(err: unknown): boolean {
|
|
966
|
+
const errno = err as NodeJS.ErrnoException;
|
|
967
|
+
const message = err instanceof Error ? err.message : String(err ?? '');
|
|
968
|
+
return (
|
|
969
|
+
errno?.code === 'ABORT_ERR' ||
|
|
970
|
+
/abort|aborted|interrupt|interrupted|cancelled|canceled/i.test(message)
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Check for _interrupt sentinel (graceful query interruption).
|
|
976
|
+
*/
|
|
977
|
+
function shouldInterrupt(): boolean {
|
|
978
|
+
if (fs.existsSync(IPC_INPUT_INTERRUPT_SENTINEL)) {
|
|
979
|
+
try {
|
|
980
|
+
fs.unlinkSync(IPC_INPUT_INTERRUPT_SENTINEL);
|
|
981
|
+
} catch {
|
|
982
|
+
/* ignore */
|
|
983
|
+
}
|
|
984
|
+
markInterruptRequested();
|
|
985
|
+
return true;
|
|
986
|
+
}
|
|
987
|
+
return false;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function cleanupStartupInterruptSentinel(): void {
|
|
991
|
+
try {
|
|
992
|
+
const stat = fs.statSync(IPC_INPUT_INTERRUPT_SENTINEL);
|
|
993
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
994
|
+
if (ageMs <= INTERRUPT_GRACE_WINDOW_MS) {
|
|
995
|
+
log(
|
|
996
|
+
`Preserving recent interrupt sentinel at startup (${Math.round(ageMs)}ms old)`,
|
|
997
|
+
);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
fs.unlinkSync(IPC_INPUT_INTERRUPT_SENTINEL);
|
|
1001
|
+
log(
|
|
1002
|
+
`Removed stale interrupt sentinel at startup (${Math.round(ageMs)}ms old)`,
|
|
1003
|
+
);
|
|
1004
|
+
} catch {
|
|
1005
|
+
/* ignore */
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Check for _drain sentinel (finish current query then exit).
|
|
1011
|
+
* Unlike _close which exits from idle wait, _drain is checked after
|
|
1012
|
+
* a query completes to implement one-question-one-answer semantics.
|
|
1013
|
+
*/
|
|
1014
|
+
function shouldDrain(): boolean {
|
|
1015
|
+
if (fs.existsSync(IPC_INPUT_DRAIN_SENTINEL)) {
|
|
1016
|
+
try {
|
|
1017
|
+
fs.unlinkSync(IPC_INPUT_DRAIN_SENTINEL);
|
|
1018
|
+
} catch {
|
|
1019
|
+
/* ignore */
|
|
1020
|
+
}
|
|
1021
|
+
return true;
|
|
1022
|
+
}
|
|
1023
|
+
return false;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Drain all pending IPC input messages.
|
|
1028
|
+
* Returns messages found (with optional images), or empty array.
|
|
1029
|
+
*/
|
|
1030
|
+
interface IpcDrainResult {
|
|
1031
|
+
messages: Array<{
|
|
1032
|
+
text: string;
|
|
1033
|
+
images?: Array<{ data: string; mimeType?: string }>;
|
|
1034
|
+
}>;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function drainIpcInput(): IpcDrainResult {
|
|
1038
|
+
const result: IpcDrainResult = { messages: [] };
|
|
1039
|
+
try {
|
|
1040
|
+
const files = fs
|
|
1041
|
+
.readdirSync(IPC_INPUT_DIR)
|
|
1042
|
+
.filter((f) => f.endsWith('.json'))
|
|
1043
|
+
.sort();
|
|
1044
|
+
|
|
1045
|
+
for (const file of files) {
|
|
1046
|
+
const filePath = path.join(IPC_INPUT_DIR, file);
|
|
1047
|
+
try {
|
|
1048
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
1049
|
+
fs.unlinkSync(filePath);
|
|
1050
|
+
if (data.type === 'message' && data.text) {
|
|
1051
|
+
result.messages.push({
|
|
1052
|
+
text: data.text,
|
|
1053
|
+
images: data.images,
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
} catch (err) {
|
|
1057
|
+
log(
|
|
1058
|
+
`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1059
|
+
);
|
|
1060
|
+
try {
|
|
1061
|
+
fs.unlinkSync(filePath);
|
|
1062
|
+
} catch {
|
|
1063
|
+
/* ignore */
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
} catch (err) {
|
|
1068
|
+
log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1069
|
+
}
|
|
1070
|
+
return result;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Create a fs.watch() based IPC watcher for event-driven file detection.
|
|
1075
|
+
* Falls back to periodic polling every IPC_FALLBACK_POLL_MS.
|
|
1076
|
+
*/
|
|
1077
|
+
function createIpcWatcher(onFileDetected: () => void): { close: () => void } {
|
|
1078
|
+
let watcher: fs.FSWatcher | null = null;
|
|
1079
|
+
let fallbackTimer: ReturnType<typeof setInterval> | null = null;
|
|
1080
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1081
|
+
let closed = false;
|
|
1082
|
+
|
|
1083
|
+
const debouncedDetect = () => {
|
|
1084
|
+
if (closed) return;
|
|
1085
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
1086
|
+
debounceTimer = setTimeout(() => {
|
|
1087
|
+
debounceTimer = null;
|
|
1088
|
+
if (!closed) onFileDetected();
|
|
1089
|
+
}, 50);
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
// Ensure IPC_INPUT_DIR exists
|
|
1093
|
+
try {
|
|
1094
|
+
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
|
|
1095
|
+
} catch {}
|
|
1096
|
+
|
|
1097
|
+
try {
|
|
1098
|
+
// Listen to all event types — 'rename' covers atomic writes on Linux,
|
|
1099
|
+
// but Docker bind mounts (macOS virtiofs) may emit 'change' instead.
|
|
1100
|
+
watcher = fs.watch(IPC_INPUT_DIR, () => {
|
|
1101
|
+
debouncedDetect();
|
|
1102
|
+
});
|
|
1103
|
+
watcher.on('error', (err) => {
|
|
1104
|
+
log(
|
|
1105
|
+
`IPC watcher error: ${err.message}, degrading to ${IPC_FALLBACK_POLL_MS}ms fallback polling`,
|
|
1106
|
+
);
|
|
1107
|
+
watcher?.close();
|
|
1108
|
+
watcher = null;
|
|
1109
|
+
});
|
|
1110
|
+
} catch (err) {
|
|
1111
|
+
log(
|
|
1112
|
+
`Failed to create IPC watcher: ${err instanceof Error ? err.message : String(err)}, using fallback polling`,
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Fallback polling for reliability
|
|
1117
|
+
fallbackTimer = setInterval(() => {
|
|
1118
|
+
if (!closed) onFileDetected();
|
|
1119
|
+
}, IPC_FALLBACK_POLL_MS);
|
|
1120
|
+
fallbackTimer.unref(); // Don't prevent process from naturally exiting
|
|
1121
|
+
|
|
1122
|
+
return {
|
|
1123
|
+
close() {
|
|
1124
|
+
closed = true;
|
|
1125
|
+
watcher?.close();
|
|
1126
|
+
watcher = null;
|
|
1127
|
+
if (debounceTimer) {
|
|
1128
|
+
clearTimeout(debounceTimer);
|
|
1129
|
+
debounceTimer = null;
|
|
1130
|
+
}
|
|
1131
|
+
if (fallbackTimer) {
|
|
1132
|
+
clearInterval(fallbackTimer);
|
|
1133
|
+
fallbackTimer = null;
|
|
1134
|
+
}
|
|
1135
|
+
},
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Wait for a new IPC message or _close sentinel.
|
|
1141
|
+
* Returns the messages (with optional images), or null if _close.
|
|
1142
|
+
*/
|
|
1143
|
+
function waitForIpcMessage(): Promise<{
|
|
1144
|
+
text: string;
|
|
1145
|
+
images?: Array<{ data: string; mimeType?: string }>;
|
|
1146
|
+
} | null> {
|
|
1147
|
+
return new Promise((resolve) => {
|
|
1148
|
+
let resolved = false;
|
|
1149
|
+
const tryDrain = () => {
|
|
1150
|
+
if (resolved) return;
|
|
1151
|
+
|
|
1152
|
+
if (shouldClose()) {
|
|
1153
|
+
resolved = true;
|
|
1154
|
+
ipcWatcher?.close();
|
|
1155
|
+
resolve(null);
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
if (shouldDrain()) {
|
|
1160
|
+
log('Drain sentinel received, exiting after completed query');
|
|
1161
|
+
resolved = true;
|
|
1162
|
+
ipcWatcher?.close();
|
|
1163
|
+
resolve(null);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
if (shouldInterrupt()) {
|
|
1168
|
+
log('Interrupt sentinel received while idle, ignoring');
|
|
1169
|
+
clearInterruptRequested();
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
const { messages } = drainIpcInput();
|
|
1173
|
+
|
|
1174
|
+
if (messages.length > 0) {
|
|
1175
|
+
const combinedText = messages.map((m) => m.text).join('\n');
|
|
1176
|
+
const allImages = messages.flatMap((m) => m.images || []);
|
|
1177
|
+
resolved = true;
|
|
1178
|
+
ipcWatcher?.close();
|
|
1179
|
+
resolve({
|
|
1180
|
+
text: combinedText,
|
|
1181
|
+
images: allImages.length > 0 ? allImages : undefined,
|
|
1182
|
+
});
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
};
|
|
1186
|
+
|
|
1187
|
+
const ipcWatcher = createIpcWatcher(tryDrain);
|
|
1188
|
+
// Initial check in case files already exist
|
|
1189
|
+
tryDrain();
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function buildMemoryRecallPrompt(
|
|
1194
|
+
isHome: boolean,
|
|
1195
|
+
isAdminHome: boolean,
|
|
1196
|
+
): string {
|
|
1197
|
+
if (isHome) {
|
|
1198
|
+
// Home container (admin or member): full memory system with read/write access to user's global AGENTS.md
|
|
1199
|
+
return [
|
|
1200
|
+
'',
|
|
1201
|
+
'## 记忆系统',
|
|
1202
|
+
'',
|
|
1203
|
+
'你拥有跨会话的持久记忆能力,请积极使用。',
|
|
1204
|
+
'',
|
|
1205
|
+
'### 回忆',
|
|
1206
|
+
'在回答关于过去的工作、决策、日期、偏好或待办事项之前:',
|
|
1207
|
+
'先用 `memory_search` 搜索,再用 `memory_get` 获取完整上下文。',
|
|
1208
|
+
'',
|
|
1209
|
+
'### 存储——两层记忆架构',
|
|
1210
|
+
'',
|
|
1211
|
+
'获知重要信息后**必须立即保存**,不要等到上下文压缩。',
|
|
1212
|
+
'根据信息的**时效性**选择存储位置:',
|
|
1213
|
+
'',
|
|
1214
|
+
'#### 全局记忆(永久)→ 直接编辑 `/workspace/global/AGENTS.md`',
|
|
1215
|
+
'',
|
|
1216
|
+
'**优先使用全局记忆。** 适用于所有**跨会话仍然有用**的信息:',
|
|
1217
|
+
'- 用户身份:姓名、生日、联系方式、地址、工作单位',
|
|
1218
|
+
'- 长期偏好:沟通风格、称呼方式、喜好厌恶、技术栈偏好',
|
|
1219
|
+
'- 身份配置:你的名字、角色设定、行为准则',
|
|
1220
|
+
'- 常用项目与上下文:反复提到的仓库、服务、架构信息',
|
|
1221
|
+
'- 用户明确要求「记住」的任何内容',
|
|
1222
|
+
'',
|
|
1223
|
+
'使用 `Read` 工具读取当前内容,再用 `Edit` 工具**原地更新对应字段**。',
|
|
1224
|
+
'文件中标记「待记录」的字段发现信息后**必须立即填写**。',
|
|
1225
|
+
'不要追加重复信息,保持文件简洁有序。',
|
|
1226
|
+
'',
|
|
1227
|
+
'#### 日期记忆(时效性)→ 调用 `memory_append`',
|
|
1228
|
+
'',
|
|
1229
|
+
'适用于**过一段时间会过时**的信息:',
|
|
1230
|
+
'- 项目进展:今天做了什么、决定了什么、遇到了什么问题',
|
|
1231
|
+
'- 临时技术决策:选型理由、架构方案、变更记录',
|
|
1232
|
+
'- 待办与承诺:约定事项、截止日期、后续跟进',
|
|
1233
|
+
'- 会议/讨论要点:关键结论、行动项',
|
|
1234
|
+
'',
|
|
1235
|
+
'`memory_append` 自动保存到独立的记忆目录(不在工作区内)。',
|
|
1236
|
+
'',
|
|
1237
|
+
'#### 判断标准',
|
|
1238
|
+
'> **默认优先全局记忆。** 问自己:这条信息下次对话还可能用到吗?',
|
|
1239
|
+
'> - 是 / 可能 → **全局记忆**(编辑 `/workspace/global/AGENTS.md`)',
|
|
1240
|
+
'> - 明确只跟今天有关 → 日期记忆(`memory_append`)',
|
|
1241
|
+
'> - 用户说「记住这个」→ **一定写全局记忆**',
|
|
1242
|
+
'',
|
|
1243
|
+
'系统也会在上下文压缩前提示你保存记忆。',
|
|
1244
|
+
].join('\n');
|
|
1245
|
+
}
|
|
1246
|
+
// Non-home group container: read-only access to home memory, use Claude auto memory
|
|
1247
|
+
return [
|
|
1248
|
+
'',
|
|
1249
|
+
'## 记忆',
|
|
1250
|
+
'',
|
|
1251
|
+
'### 查询主工作区记忆',
|
|
1252
|
+
'可使用 `memory_search` 和 `memory_get` 工具搜索主工作区的记忆(全局记忆和日期记忆)。',
|
|
1253
|
+
'需要回忆过去的决策、偏好或项目上下文时使用这些工具。',
|
|
1254
|
+
'',
|
|
1255
|
+
'### 本地记忆',
|
|
1256
|
+
'重要信息直接记录在当前工作区的 AGENTS.md 或其他文件中。',
|
|
1257
|
+
'Claude 会自动维护你的会话记忆,无需额外操作。',
|
|
1258
|
+
'',
|
|
1259
|
+
'全局记忆(`/workspace/global/AGENTS.md`)为只读参考。',
|
|
1260
|
+
].join('\n');
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/** 从 settings.json 读取用户配置的 MCP servers(stdio/http/sse 类型) */
|
|
1264
|
+
function loadUserMcpServers(): Record<string, unknown> {
|
|
1265
|
+
const configDir =
|
|
1266
|
+
process.env.CLAUDE_CONFIG_DIR ||
|
|
1267
|
+
path.join(process.env.HOME || '/home/node', '.claude');
|
|
1268
|
+
const settingsFile = path.join(configDir, 'settings.json');
|
|
1269
|
+
try {
|
|
1270
|
+
if (fs.existsSync(settingsFile)) {
|
|
1271
|
+
const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
|
|
1272
|
+
if (settings.mcpServers && typeof settings.mcpServers === 'object') {
|
|
1273
|
+
return settings.mcpServers;
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
} catch {
|
|
1277
|
+
/* ignore parse errors */
|
|
1278
|
+
}
|
|
1279
|
+
return {};
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function loadWorkspaceMcpServers(): Record<string, unknown> {
|
|
1283
|
+
const settingsFile = path.join(WORKSPACE_GROUP, '.claude', 'settings.json');
|
|
1284
|
+
try {
|
|
1285
|
+
if (fs.existsSync(settingsFile)) {
|
|
1286
|
+
const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
|
|
1287
|
+
if (settings.mcpServers && typeof settings.mcpServers === 'object') {
|
|
1288
|
+
return settings.mcpServers;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
} catch {
|
|
1292
|
+
/* ignore parse errors */
|
|
1293
|
+
}
|
|
1294
|
+
return {};
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function buildAcpMcpServers(): McpServer[] {
|
|
1298
|
+
const merged = {
|
|
1299
|
+
...loadUserMcpServers(),
|
|
1300
|
+
...loadWorkspaceMcpServers(),
|
|
1301
|
+
} as Record<string, Record<string, unknown>>;
|
|
1302
|
+
const servers: McpServer[] = [];
|
|
1303
|
+
|
|
1304
|
+
for (const [name, entry] of Object.entries(merged)) {
|
|
1305
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
1306
|
+
|
|
1307
|
+
if (typeof entry.command === 'string') {
|
|
1308
|
+
servers.push({
|
|
1309
|
+
name,
|
|
1310
|
+
command: entry.command,
|
|
1311
|
+
args: Array.isArray(entry.args)
|
|
1312
|
+
? entry.args.map((value) => String(value))
|
|
1313
|
+
: [],
|
|
1314
|
+
env:
|
|
1315
|
+
entry.env && typeof entry.env === 'object'
|
|
1316
|
+
? Object.entries(entry.env as Record<string, unknown>).map(
|
|
1317
|
+
([envName, value]) => ({
|
|
1318
|
+
name: envName,
|
|
1319
|
+
value: String(value),
|
|
1320
|
+
}),
|
|
1321
|
+
)
|
|
1322
|
+
: [],
|
|
1323
|
+
});
|
|
1324
|
+
continue;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
const type = entry.type === 'sse' ? 'sse' : 'http';
|
|
1328
|
+
if (typeof entry.url === 'string') {
|
|
1329
|
+
servers.push({
|
|
1330
|
+
name,
|
|
1331
|
+
type,
|
|
1332
|
+
url: entry.url,
|
|
1333
|
+
headers:
|
|
1334
|
+
entry.headers && typeof entry.headers === 'object'
|
|
1335
|
+
? Object.entries(entry.headers as Record<string, unknown>).map(
|
|
1336
|
+
([headerName, value]) => ({
|
|
1337
|
+
name: headerName,
|
|
1338
|
+
value: String(value),
|
|
1339
|
+
}),
|
|
1340
|
+
)
|
|
1341
|
+
: [],
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
return servers;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function choosePermissionOption(options: PermissionOption[]): string | null {
|
|
1350
|
+
const preferredKinds = ['allow_once', 'allow_always'];
|
|
1351
|
+
for (const kind of preferredKinds) {
|
|
1352
|
+
const match = options.find((option) => option.kind === kind);
|
|
1353
|
+
if (match) return match.optionId;
|
|
1354
|
+
}
|
|
1355
|
+
return options[0]?.optionId ?? null;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function summarizeUnknown(value: unknown, maxLength = 240): string | undefined {
|
|
1359
|
+
if (value === undefined) return undefined;
|
|
1360
|
+
try {
|
|
1361
|
+
const serialized =
|
|
1362
|
+
typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
|
1363
|
+
return serialized.length > maxLength
|
|
1364
|
+
? `${serialized.slice(0, maxLength)}...`
|
|
1365
|
+
: serialized;
|
|
1366
|
+
} catch {
|
|
1367
|
+
return String(value);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function codexPromptBlocks(
|
|
1372
|
+
prompt: string,
|
|
1373
|
+
images?: Array<{ data: string; mimeType?: string }>,
|
|
1374
|
+
): ContentBlock[] {
|
|
1375
|
+
const blocks: ContentBlock[] = [{ type: 'text', text: prompt }];
|
|
1376
|
+
for (const image of images || []) {
|
|
1377
|
+
blocks.push({
|
|
1378
|
+
type: 'image',
|
|
1379
|
+
data: image.data,
|
|
1380
|
+
mimeType: image.mimeType || 'image/png',
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
return blocks;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
async function runCodexLoop(containerInput: ContainerInput): Promise<void> {
|
|
1387
|
+
if (containerInput.isScheduledTask) {
|
|
1388
|
+
writeOutput({
|
|
1389
|
+
status: 'error',
|
|
1390
|
+
result: null,
|
|
1391
|
+
error: 'Codex does not support scheduled task runs yet',
|
|
1392
|
+
});
|
|
1393
|
+
forceExitWithSafetyNet(1);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
let sessionId = containerInput.sessionId;
|
|
1397
|
+
latestSessionId = sessionId;
|
|
1398
|
+
|
|
1399
|
+
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
|
|
1400
|
+
try {
|
|
1401
|
+
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
|
|
1402
|
+
} catch {
|
|
1403
|
+
/* ignore */
|
|
1404
|
+
}
|
|
1405
|
+
cleanupStartupInterruptSentinel();
|
|
1406
|
+
|
|
1407
|
+
let prompt = containerInput.prompt;
|
|
1408
|
+
let promptImages = containerInput.images;
|
|
1409
|
+
const pendingDrain = drainIpcInput();
|
|
1410
|
+
if (pendingDrain.messages.length > 0) {
|
|
1411
|
+
prompt +=
|
|
1412
|
+
'\n' + pendingDrain.messages.map((message) => message.text).join('\n');
|
|
1413
|
+
const pendingImages = pendingDrain.messages.flatMap(
|
|
1414
|
+
(message) => message.images || [],
|
|
1415
|
+
);
|
|
1416
|
+
if (pendingImages.length > 0) {
|
|
1417
|
+
promptImages = [...(promptImages || []), ...pendingImages];
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
const acpCommand = process.env.CODEX_ACP_COMMAND?.trim() || 'npx';
|
|
1422
|
+
const acpArgs =
|
|
1423
|
+
acpCommand === 'npx' ? ['-y', '@zed-industries/codex-acp'] : [];
|
|
1424
|
+
const acpEnv = {
|
|
1425
|
+
...process.env,
|
|
1426
|
+
...(containerInput.model
|
|
1427
|
+
? {
|
|
1428
|
+
OPENAI_MODEL: containerInput.model,
|
|
1429
|
+
CODEX_MODEL: containerInput.model,
|
|
1430
|
+
}
|
|
1431
|
+
: {}),
|
|
1432
|
+
...(containerInput.reasoningEffort
|
|
1433
|
+
? {
|
|
1434
|
+
OPENAI_REASONING_EFFORT: containerInput.reasoningEffort,
|
|
1435
|
+
CODEX_REASONING_EFFORT: containerInput.reasoningEffort,
|
|
1436
|
+
REASONING_EFFORT: containerInput.reasoningEffort,
|
|
1437
|
+
}
|
|
1438
|
+
: {}),
|
|
1439
|
+
};
|
|
1440
|
+
const acpProcess = spawn(acpCommand, acpArgs, {
|
|
1441
|
+
cwd: WORKSPACE_GROUP,
|
|
1442
|
+
env: acpEnv,
|
|
1443
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
acpProcess.stderr.setEncoding('utf8');
|
|
1447
|
+
acpProcess.stderr.on('data', (chunk) => {
|
|
1448
|
+
const text = String(chunk).trim();
|
|
1449
|
+
if (text) {
|
|
1450
|
+
log(`[codex-acp] ${text}`);
|
|
1451
|
+
}
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
const stream = ndJsonStream(
|
|
1455
|
+
Writable.toWeb(acpProcess.stdin),
|
|
1456
|
+
Readable.toWeb(acpProcess.stdout),
|
|
1457
|
+
);
|
|
1458
|
+
|
|
1459
|
+
let activeTurnText = '';
|
|
1460
|
+
const connection = new ClientSideConnection(
|
|
1461
|
+
() => ({
|
|
1462
|
+
requestPermission: async (params) => {
|
|
1463
|
+
const optionId = choosePermissionOption(params.options);
|
|
1464
|
+
if (!optionId) {
|
|
1465
|
+
return { outcome: { outcome: 'cancelled' } };
|
|
1466
|
+
}
|
|
1467
|
+
return {
|
|
1468
|
+
outcome: {
|
|
1469
|
+
outcome: 'selected',
|
|
1470
|
+
optionId,
|
|
1471
|
+
},
|
|
1472
|
+
};
|
|
1473
|
+
},
|
|
1474
|
+
sessionUpdate: async (notification: SessionNotification) => {
|
|
1475
|
+
const update = notification.update as any;
|
|
1476
|
+
const baseEvent = {
|
|
1477
|
+
turnId: containerInput.turnId,
|
|
1478
|
+
sessionId: notification.sessionId,
|
|
1479
|
+
messageUuid:
|
|
1480
|
+
typeof update.messageId === 'string' ? update.messageId : undefined,
|
|
1481
|
+
};
|
|
1482
|
+
|
|
1483
|
+
switch (update.sessionUpdate) {
|
|
1484
|
+
case 'agent_message_chunk': {
|
|
1485
|
+
if (update.content?.type === 'text' && update.content.text) {
|
|
1486
|
+
activeTurnText += update.content.text;
|
|
1487
|
+
writeOutput({
|
|
1488
|
+
status: 'stream',
|
|
1489
|
+
result: null,
|
|
1490
|
+
newSessionId: notification.sessionId,
|
|
1491
|
+
streamEvent: {
|
|
1492
|
+
...baseEvent,
|
|
1493
|
+
eventType: 'text_delta',
|
|
1494
|
+
text: update.content.text,
|
|
1495
|
+
},
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
break;
|
|
1499
|
+
}
|
|
1500
|
+
case 'agent_thought_chunk': {
|
|
1501
|
+
if (update.content?.type === 'text' && update.content.text) {
|
|
1502
|
+
writeOutput({
|
|
1503
|
+
status: 'stream',
|
|
1504
|
+
result: null,
|
|
1505
|
+
newSessionId: notification.sessionId,
|
|
1506
|
+
streamEvent: {
|
|
1507
|
+
...baseEvent,
|
|
1508
|
+
eventType: 'thinking_delta',
|
|
1509
|
+
text: update.content.text,
|
|
1510
|
+
},
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
break;
|
|
1514
|
+
}
|
|
1515
|
+
case 'tool_call': {
|
|
1516
|
+
writeOutput({
|
|
1517
|
+
status: 'stream',
|
|
1518
|
+
result: null,
|
|
1519
|
+
newSessionId: notification.sessionId,
|
|
1520
|
+
streamEvent: {
|
|
1521
|
+
...baseEvent,
|
|
1522
|
+
eventType: 'tool_use_start',
|
|
1523
|
+
toolUseId: update.toolCallId,
|
|
1524
|
+
toolName: update.title || update.kind || 'tool',
|
|
1525
|
+
toolInputSummary: summarizeUnknown(update.rawInput),
|
|
1526
|
+
toolInput:
|
|
1527
|
+
update.rawInput && typeof update.rawInput === 'object'
|
|
1528
|
+
? update.rawInput
|
|
1529
|
+
: undefined,
|
|
1530
|
+
},
|
|
1531
|
+
});
|
|
1532
|
+
break;
|
|
1533
|
+
}
|
|
1534
|
+
case 'tool_call_update': {
|
|
1535
|
+
writeOutput({
|
|
1536
|
+
status: 'stream',
|
|
1537
|
+
result: null,
|
|
1538
|
+
newSessionId: notification.sessionId,
|
|
1539
|
+
streamEvent: {
|
|
1540
|
+
...baseEvent,
|
|
1541
|
+
eventType:
|
|
1542
|
+
update.status === 'completed' ||
|
|
1543
|
+
update.status === 'failed' ||
|
|
1544
|
+
update.status === 'cancelled'
|
|
1545
|
+
? 'tool_use_end'
|
|
1546
|
+
: 'tool_progress',
|
|
1547
|
+
toolUseId: update.toolCallId,
|
|
1548
|
+
toolName: update.title || update.kind || 'tool',
|
|
1549
|
+
text: summarizeUnknown(update.rawOutput),
|
|
1550
|
+
},
|
|
1551
|
+
});
|
|
1552
|
+
break;
|
|
1553
|
+
}
|
|
1554
|
+
case 'usage_update': {
|
|
1555
|
+
writeOutput({
|
|
1556
|
+
status: 'stream',
|
|
1557
|
+
result: null,
|
|
1558
|
+
newSessionId: notification.sessionId,
|
|
1559
|
+
streamEvent: {
|
|
1560
|
+
...baseEvent,
|
|
1561
|
+
eventType: 'status',
|
|
1562
|
+
statusText: 'usage_updated',
|
|
1563
|
+
},
|
|
1564
|
+
});
|
|
1565
|
+
break;
|
|
1566
|
+
}
|
|
1567
|
+
default:
|
|
1568
|
+
break;
|
|
1569
|
+
}
|
|
1570
|
+
},
|
|
1571
|
+
}),
|
|
1572
|
+
stream,
|
|
1573
|
+
);
|
|
1574
|
+
|
|
1575
|
+
try {
|
|
1576
|
+
await connection.initialize({
|
|
1577
|
+
protocolVersion: 1,
|
|
1578
|
+
clientInfo: { name: 'cli-claw', version: '1.0.0' },
|
|
1579
|
+
clientCapabilities: {},
|
|
1580
|
+
});
|
|
1581
|
+
|
|
1582
|
+
const mcpServers = buildAcpMcpServers();
|
|
1583
|
+
if (sessionId) {
|
|
1584
|
+
try {
|
|
1585
|
+
await connection.loadSession({
|
|
1586
|
+
sessionId,
|
|
1587
|
+
cwd: WORKSPACE_GROUP,
|
|
1588
|
+
mcpServers,
|
|
1589
|
+
});
|
|
1590
|
+
} catch {
|
|
1591
|
+
sessionId = undefined;
|
|
1592
|
+
latestSessionId = undefined;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
if (!sessionId) {
|
|
1597
|
+
const newSession = await connection.newSession({
|
|
1598
|
+
cwd: WORKSPACE_GROUP,
|
|
1599
|
+
mcpServers,
|
|
1600
|
+
});
|
|
1601
|
+
sessionId = newSession.sessionId;
|
|
1602
|
+
latestSessionId = sessionId;
|
|
1603
|
+
activeRuntimeIdentity =
|
|
1604
|
+
extractCodexRuntimeIdentity(newSession) ??
|
|
1605
|
+
activeRuntimeIdentity;
|
|
1606
|
+
if (
|
|
1607
|
+
newSession.modes?.availableModes?.some(
|
|
1608
|
+
(mode: SessionMode) => mode.id === 'auto',
|
|
1609
|
+
)
|
|
1610
|
+
) {
|
|
1611
|
+
await connection.setSessionMode({
|
|
1612
|
+
sessionId,
|
|
1613
|
+
modeId: 'auto',
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
while (true) {
|
|
1619
|
+
try {
|
|
1620
|
+
fs.unlinkSync(IPC_INPUT_INTERRUPT_SENTINEL);
|
|
1621
|
+
} catch {
|
|
1622
|
+
/* ignore */
|
|
1623
|
+
}
|
|
1624
|
+
clearInterruptRequested();
|
|
1625
|
+
activeTurnText = '';
|
|
1626
|
+
|
|
1627
|
+
let closeRequested = false;
|
|
1628
|
+
let interruptRequested = false;
|
|
1629
|
+
const cancelWatcher = setInterval(() => {
|
|
1630
|
+
if (!sessionId || closeRequested || interruptRequested) return;
|
|
1631
|
+
if (shouldClose() || shouldDrain()) {
|
|
1632
|
+
closeRequested = true;
|
|
1633
|
+
void connection.cancel({ sessionId }).catch(() => {});
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
if (shouldInterrupt()) {
|
|
1637
|
+
interruptRequested = true;
|
|
1638
|
+
void connection.cancel({ sessionId }).catch(() => {});
|
|
1639
|
+
}
|
|
1640
|
+
}, CODEX_INTERRUPT_POLL_MS);
|
|
1641
|
+
|
|
1642
|
+
try {
|
|
1643
|
+
await connection.prompt({
|
|
1644
|
+
sessionId,
|
|
1645
|
+
prompt: codexPromptBlocks(prompt, promptImages),
|
|
1646
|
+
});
|
|
1647
|
+
} finally {
|
|
1648
|
+
clearInterval(cancelWatcher);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
latestSessionId = sessionId;
|
|
1652
|
+
|
|
1653
|
+
if (closeRequested) {
|
|
1654
|
+
writeOutput({ status: 'closed', result: null });
|
|
1655
|
+
break;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
if (interruptRequested) {
|
|
1659
|
+
writeOutput({
|
|
1660
|
+
status: 'stream',
|
|
1661
|
+
result: null,
|
|
1662
|
+
newSessionId: sessionId,
|
|
1663
|
+
streamEvent: {
|
|
1664
|
+
eventType: 'status',
|
|
1665
|
+
statusText: 'interrupted',
|
|
1666
|
+
turnId: containerInput.turnId,
|
|
1667
|
+
sessionId,
|
|
1668
|
+
},
|
|
1669
|
+
});
|
|
1670
|
+
const nextMessage = await waitForIpcMessage();
|
|
1671
|
+
if (nextMessage === null) {
|
|
1672
|
+
writeOutput({
|
|
1673
|
+
status: 'success',
|
|
1674
|
+
result: null,
|
|
1675
|
+
newSessionId: sessionId,
|
|
1676
|
+
});
|
|
1677
|
+
break;
|
|
1678
|
+
}
|
|
1679
|
+
prompt = nextMessage.text;
|
|
1680
|
+
promptImages = nextMessage.images;
|
|
1681
|
+
containerInput.turnId = generateTurnId();
|
|
1682
|
+
continue;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
writeOutput({
|
|
1686
|
+
status: 'success',
|
|
1687
|
+
result: activeTurnText || null,
|
|
1688
|
+
newSessionId: sessionId,
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
const nextMessage = await waitForIpcMessage();
|
|
1692
|
+
if (nextMessage === null) {
|
|
1693
|
+
break;
|
|
1694
|
+
}
|
|
1695
|
+
prompt = nextMessage.text;
|
|
1696
|
+
promptImages = nextMessage.images;
|
|
1697
|
+
containerInput.turnId = generateTurnId();
|
|
1698
|
+
}
|
|
1699
|
+
} catch (err) {
|
|
1700
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
1701
|
+
writeOutput(buildVisibleRuntimeErrorOutput(errorMessage, sessionId));
|
|
1702
|
+
forceExitWithSafetyNet(1);
|
|
1703
|
+
} finally {
|
|
1704
|
+
acpProcess.kill('SIGTERM');
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
/**
|
|
1709
|
+
* Run a single query and stream results via writeOutput.
|
|
1710
|
+
* Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false,
|
|
1711
|
+
* allowing agent teams subagents to run to completion.
|
|
1712
|
+
* Also pipes IPC messages into the stream during the query.
|
|
1713
|
+
*/
|
|
1714
|
+
async function runQuery(
|
|
1715
|
+
prompt: string,
|
|
1716
|
+
sessionId: string | undefined,
|
|
1717
|
+
mcpServerConfig: ReturnType<typeof createSdkMcpServer>,
|
|
1718
|
+
containerInput: ContainerInput,
|
|
1719
|
+
memoryRecall: string,
|
|
1720
|
+
resumeAt?: string,
|
|
1721
|
+
emitOutput = true,
|
|
1722
|
+
allowedTools: string[] = DEFAULT_ALLOWED_TOOLS,
|
|
1723
|
+
disallowedTools?: string[],
|
|
1724
|
+
images?: Array<{ data: string; mimeType?: string }>,
|
|
1725
|
+
sourceKindOverride?: ContainerOutput['sourceKind'],
|
|
1726
|
+
): Promise<{
|
|
1727
|
+
newSessionId?: string;
|
|
1728
|
+
lastAssistantUuid?: string;
|
|
1729
|
+
closedDuringQuery: boolean;
|
|
1730
|
+
contextOverflow?: boolean;
|
|
1731
|
+
unrecoverableTranscriptError?: boolean;
|
|
1732
|
+
interruptedDuringQuery: boolean;
|
|
1733
|
+
sessionResumeFailed?: boolean;
|
|
1734
|
+
}> {
|
|
1735
|
+
const stream = new MessageStream();
|
|
1736
|
+
let newSessionId: string | undefined;
|
|
1737
|
+
let lastAssistantUuid: string | undefined;
|
|
1738
|
+
let canonicalAssistantText: string | undefined;
|
|
1739
|
+
let canonicalAssistantUuid: string | undefined;
|
|
1740
|
+
const initialRejected = stream.push(prompt, images);
|
|
1741
|
+
const decorateStreamEvent = (event: StreamEvent): StreamEvent => ({
|
|
1742
|
+
...event,
|
|
1743
|
+
turnId: containerInput.turnId,
|
|
1744
|
+
sessionId: newSessionId || sessionId,
|
|
1745
|
+
});
|
|
1746
|
+
const emit = (output: ContainerOutput): void => {
|
|
1747
|
+
if (output.streamEvent) {
|
|
1748
|
+
output = {
|
|
1749
|
+
...output,
|
|
1750
|
+
streamEvent: decorateStreamEvent(output.streamEvent),
|
|
1751
|
+
turnId: containerInput.turnId,
|
|
1752
|
+
sessionId: newSessionId || sessionId,
|
|
1753
|
+
};
|
|
1754
|
+
} else if (output.status === 'success' || output.status === 'error') {
|
|
1755
|
+
output = {
|
|
1756
|
+
...output,
|
|
1757
|
+
turnId: containerInput.turnId,
|
|
1758
|
+
sessionId: newSessionId || sessionId,
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
if (emitOutput) writeOutput(output);
|
|
1762
|
+
};
|
|
1763
|
+
|
|
1764
|
+
// 如果有图片被拒绝,立即通知用户
|
|
1765
|
+
for (const reason of initialRejected) {
|
|
1766
|
+
emit({
|
|
1767
|
+
status: 'success',
|
|
1768
|
+
result: `\u26a0\ufe0f ${reason}`,
|
|
1769
|
+
newSessionId: undefined,
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
// Poll IPC for follow-up messages and _close/_interrupt sentinel during the query
|
|
1774
|
+
let ipcPolling = true;
|
|
1775
|
+
let closedDuringQuery = false;
|
|
1776
|
+
let interruptedDuringQuery = false;
|
|
1777
|
+
let suppressOutputAfterInterrupt = false;
|
|
1778
|
+
let visibleOutputStarted = false;
|
|
1779
|
+
// After a result is received, allow a short window for the host to write _drain
|
|
1780
|
+
// before force-closing the stream.
|
|
1781
|
+
let resultReceivedAt: number | null = null;
|
|
1782
|
+
const POST_RESULT_TIMEOUT_MS = 5_000;
|
|
1783
|
+
// queryRef is set just before the for-await loop so pollIpcDuringQuery can call interrupt()
|
|
1784
|
+
let queryRef: { interrupt(): Promise<void> } | null = null;
|
|
1785
|
+
let messageCount = 0;
|
|
1786
|
+
let resultCount = 0;
|
|
1787
|
+
|
|
1788
|
+
const pollIpcDuringQuery = () => {
|
|
1789
|
+
if (!ipcPolling) return;
|
|
1790
|
+
|
|
1791
|
+
if (shouldClose()) {
|
|
1792
|
+
log('Close sentinel detected during query, ending stream');
|
|
1793
|
+
closedDuringQuery = true;
|
|
1794
|
+
stream.end();
|
|
1795
|
+
ipcPolling = false;
|
|
1796
|
+
ipcQueryWatcher.close();
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
if (shouldInterrupt()) {
|
|
1800
|
+
log('Interrupt sentinel detected, interrupting current query');
|
|
1801
|
+
interruptedDuringQuery = true;
|
|
1802
|
+
if (!visibleOutputStarted && resultCount === 0) {
|
|
1803
|
+
suppressOutputAfterInterrupt = true;
|
|
1804
|
+
log(
|
|
1805
|
+
'Interrupt arrived before visible output, suppressing query output',
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
1808
|
+
lastInterruptRequestedAt = Date.now();
|
|
1809
|
+
queryRef
|
|
1810
|
+
?.interrupt()
|
|
1811
|
+
.catch((err: unknown) => log(`Interrupt call failed: ${err}`));
|
|
1812
|
+
stream.end();
|
|
1813
|
+
ipcPolling = false;
|
|
1814
|
+
ipcQueryWatcher.close();
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
// _drain: finish current query then exit. Once a result has been received,
|
|
1818
|
+
// the query is logically done but the MessageStream keeps the SDK alive.
|
|
1819
|
+
// Treat drain as close at this point to release the container.
|
|
1820
|
+
if (resultCount > 0 && shouldDrain()) {
|
|
1821
|
+
log('Drain sentinel detected after query result, ending stream');
|
|
1822
|
+
closedDuringQuery = true;
|
|
1823
|
+
stream.end();
|
|
1824
|
+
ipcPolling = false;
|
|
1825
|
+
ipcQueryWatcher.close();
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
// ── 结果后超时:result 已收到,给 host 短暂时间写 _drain ──
|
|
1829
|
+
// 注意:不设置 closedDuringQuery — 这只是 stream 清理,不是退出信号。
|
|
1830
|
+
// 主循环会继续进入 waitForIpcMessage(),等待 _close/_drain 才退出。
|
|
1831
|
+
// 这保证了终端预热等场景下容器不会在查询完成后立即退出。
|
|
1832
|
+
if (
|
|
1833
|
+
resultReceivedAt &&
|
|
1834
|
+
Date.now() - resultReceivedAt > POST_RESULT_TIMEOUT_MS
|
|
1835
|
+
) {
|
|
1836
|
+
log(
|
|
1837
|
+
`Post-result timeout (${POST_RESULT_TIMEOUT_MS / 1000}s), closing stream`,
|
|
1838
|
+
);
|
|
1839
|
+
stream.end();
|
|
1840
|
+
ipcPolling = false;
|
|
1841
|
+
ipcQueryWatcher.close();
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
// Side-queries (emitOutput=false, e.g. memory flush / AGENTS.md update) must NOT
|
|
1845
|
+
// consume user IPC messages — those belong to the main query loop. Only sentinels
|
|
1846
|
+
// are checked above. Without this guard, a user message arriving during a side-query
|
|
1847
|
+
// gets silently consumed, leaving queryInFlight=true on the host forever (bug #259).
|
|
1848
|
+
if (!emitOutput) {
|
|
1849
|
+
return; // No setTimeout needed — watcher will trigger next check on file change
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
const { messages } = drainIpcInput();
|
|
1853
|
+
for (const msg of messages) {
|
|
1854
|
+
log(
|
|
1855
|
+
`Piping IPC message into active query (${msg.text.length} chars, ${msg.images?.length || 0} images)`,
|
|
1856
|
+
);
|
|
1857
|
+
const rejected = stream.push(msg.text, msg.images);
|
|
1858
|
+
for (const reason of rejected) {
|
|
1859
|
+
emit({
|
|
1860
|
+
status: 'success',
|
|
1861
|
+
result: `\u26a0\ufe0f ${reason}`,
|
|
1862
|
+
newSessionId: undefined,
|
|
1863
|
+
});
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
// No setTimeout needed — watcher will trigger next check on file change
|
|
1867
|
+
};
|
|
1868
|
+
|
|
1869
|
+
const ipcQueryWatcher = createIpcWatcher(() => {
|
|
1870
|
+
if (!ipcPolling) return;
|
|
1871
|
+
pollIpcDuringQuery();
|
|
1872
|
+
});
|
|
1873
|
+
// Initial drain to process any pre-existing files
|
|
1874
|
+
pollIpcDuringQuery();
|
|
1875
|
+
|
|
1876
|
+
const processor = new StreamEventProcessor(emit, log);
|
|
1877
|
+
|
|
1878
|
+
// Build system prompt: memory recall guidance + global AGENTS.md (for non-admin-home)
|
|
1879
|
+
const { isHome, isAdminHome } = normalizeHomeFlags(containerInput);
|
|
1880
|
+
const globalAgentMemoryPath = path.join(WORKSPACE_GLOBAL, 'AGENTS.md');
|
|
1881
|
+
|
|
1882
|
+
// Home containers: inject full global AGENTS.md for immediate context.
|
|
1883
|
+
// Non-home containers: global AGENTS.md is accessible via filesystem (mounted readonly)
|
|
1884
|
+
// but NOT injected into system prompt to avoid context pollution that causes
|
|
1885
|
+
// the agent to "continue" unrelated previous work.
|
|
1886
|
+
let globalAgentMemory = '';
|
|
1887
|
+
if (isHome && fs.existsSync(globalAgentMemoryPath)) {
|
|
1888
|
+
globalAgentMemory = fs.readFileSync(globalAgentMemoryPath, 'utf-8');
|
|
1889
|
+
globalAgentMemory = truncateWithHeadTail(
|
|
1890
|
+
globalAgentMemory,
|
|
1891
|
+
GLOBAL_AGENT_MEMORY_MAX_CHARS,
|
|
1892
|
+
);
|
|
1893
|
+
}
|
|
1894
|
+
const outputGuidelines = [
|
|
1895
|
+
'',
|
|
1896
|
+
'## 输出格式',
|
|
1897
|
+
'',
|
|
1898
|
+
'### 图片引用',
|
|
1899
|
+
'当你生成了图片文件并需要在回复中展示时,使用 Markdown 图片语法引用**相对路径**(相对于当前工作目录):',
|
|
1900
|
+
'``',
|
|
1901
|
+
'',
|
|
1902
|
+
'**禁止使用绝对路径**(如 `/workspace/group/filename.png`)。Web 界面会自动将相对路径解析为正确的文件下载地址。',
|
|
1903
|
+
'',
|
|
1904
|
+
'### 技术图表',
|
|
1905
|
+
'需要输出技术图表(流程图、时序图、架构图、ER 图、类图、状态图、甘特图等)时,**使用 Mermaid 语法**,用 ```mermaid 代码块包裹。',
|
|
1906
|
+
'Web 界面会自动将 Mermaid 代码渲染为可视化图表。',
|
|
1907
|
+
].join('\n');
|
|
1908
|
+
|
|
1909
|
+
const webFetchGuidelines = [
|
|
1910
|
+
'',
|
|
1911
|
+
'## 网页访问策略',
|
|
1912
|
+
'',
|
|
1913
|
+
'访问外部网页时优先使用 WebFetch(速度快)。',
|
|
1914
|
+
'如果 WebFetch 失败(403、被拦截、内容为空或需要 JavaScript 渲染),',
|
|
1915
|
+
'且 agent-browser 可用,立即改用 agent-browser 通过真实浏览器访问。不要反复重试 WebFetch。',
|
|
1916
|
+
].join('\n');
|
|
1917
|
+
|
|
1918
|
+
// Read HEARTBEAT.md (recent work summary) — only for home containers.
|
|
1919
|
+
// Non-home containers are task-isolated and should not see unrelated work history,
|
|
1920
|
+
// which can mislead the agent into "continuing" previous tasks instead of
|
|
1921
|
+
// focusing on the user's current message.
|
|
1922
|
+
let heartbeatContent = '';
|
|
1923
|
+
if (isHome) {
|
|
1924
|
+
const heartbeatPath = path.join(WORKSPACE_GLOBAL, 'HEARTBEAT.md');
|
|
1925
|
+
if (fs.existsSync(heartbeatPath)) {
|
|
1926
|
+
try {
|
|
1927
|
+
const raw = fs.readFileSync(heartbeatPath, 'utf-8');
|
|
1928
|
+
const truncated =
|
|
1929
|
+
raw.length > 2048 ? raw.slice(0, 2048) + '\n\n[...截断]' : raw;
|
|
1930
|
+
heartbeatContent = [
|
|
1931
|
+
'',
|
|
1932
|
+
'## 近期工作参考(仅供背景了解)',
|
|
1933
|
+
'',
|
|
1934
|
+
'> 以下是系统自动生成的近期工作摘要,仅供参考。',
|
|
1935
|
+
'> **不要主动继续这些工作**,除非用户明确要求「继续」或主动提到相关话题。',
|
|
1936
|
+
'> 请专注于用户当前的消息。',
|
|
1937
|
+
'',
|
|
1938
|
+
truncated,
|
|
1939
|
+
].join('\n');
|
|
1940
|
+
} catch {
|
|
1941
|
+
/* skip */
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
const backgroundTaskGuidelines = [
|
|
1947
|
+
'',
|
|
1948
|
+
'## 后台任务',
|
|
1949
|
+
'',
|
|
1950
|
+
'当用户要求执行耗时较长的批量任务(如批量文件处理、大规模数据操作等),',
|
|
1951
|
+
'你应该使用 Task 工具并设置 `run_in_background: true`,让任务在后台运行。',
|
|
1952
|
+
'这样用户无需等待,可以继续与你交流其他事项。',
|
|
1953
|
+
'任务结束时你会自动收到通知,届时在对话中向用户汇报即可。',
|
|
1954
|
+
'告知用户:「已为您在后台启动该任务,完成后我会第一时间反馈。现在有其他问题也可以随时问我。」',
|
|
1955
|
+
'',
|
|
1956
|
+
'### 任务通知处理(重要)',
|
|
1957
|
+
'',
|
|
1958
|
+
'当你收到多条后台任务的完成或失败通知时:',
|
|
1959
|
+
'- **禁止逐条回复**。不要对每条通知都调用 `send_message`,这会导致 IM 群刷屏。',
|
|
1960
|
+
'- **等待所有通知到齐后,汇总为一条消息回复用户**,例如:「N 个任务完成,M 个失败,失败原因:...」',
|
|
1961
|
+
'- 对于已知的无害失败(如浏览器进程被回收、临时资源超时),**不需要通知用户**,静默忽略即可。',
|
|
1962
|
+
].join('\n');
|
|
1963
|
+
|
|
1964
|
+
// Interaction guidelines to prevent the agent from confusing MCP tool
|
|
1965
|
+
// descriptions with user input, or proactively describing available tools.
|
|
1966
|
+
const interactionGuidelines = [
|
|
1967
|
+
'',
|
|
1968
|
+
'## 交互原则',
|
|
1969
|
+
'',
|
|
1970
|
+
'**始终专注于用户当前的实际消息。**',
|
|
1971
|
+
'',
|
|
1972
|
+
'- 你可能拥有多种 MCP 工具(如外卖点餐、优惠券查询等),这些是你的辅助能力,**不是用户发送的内容**。',
|
|
1973
|
+
'- **不要主动介绍、列举或描述你的可用工具**,除非用户明确询问「你能做什么」或「你有什么功能」。',
|
|
1974
|
+
'- 当用户需要某个功能时,直接使用对应工具完成任务即可,无需事先解释工具的存在。',
|
|
1975
|
+
'- 如果用户的消息很简短(如打招呼),简洁回应即可,不要用工具列表填充回复。',
|
|
1976
|
+
].join('\n');
|
|
1977
|
+
|
|
1978
|
+
// Conversation agents (sub-conversations with agentId) get special behavioral guidelines
|
|
1979
|
+
// to prevent excessive send_message usage and duplicate responses.
|
|
1980
|
+
const conversationAgentGuidelines = containerInput.agentId
|
|
1981
|
+
? [
|
|
1982
|
+
'',
|
|
1983
|
+
'## 子会话行为规则(最高优先级,覆盖其他冲突指令)',
|
|
1984
|
+
'',
|
|
1985
|
+
'你正在一个**子会话**中运行,不是主会话。以下规则覆盖全局记忆中的"响应行为准则":',
|
|
1986
|
+
'',
|
|
1987
|
+
'1. **不要用 `send_message` 发送"收到"之类的确认消息** — 你的正常文本输出就是回复,不需要额外发消息',
|
|
1988
|
+
'2. **每次回复只产生一条消息** — 把分析、结论、建议整合到一条回复中,不要拆成多条',
|
|
1989
|
+
'3. **只在以下情况使用 `send_message`**:',
|
|
1990
|
+
' - 执行超过 2 分钟的长任务时,发送一次进度更新(不是确认收到)',
|
|
1991
|
+
' - 用户明确要求你"先回复一下"时',
|
|
1992
|
+
'4. **你的正常文本输出会自动发送给用户**,不需要通过 `send_message` 转发',
|
|
1993
|
+
'5. **回复语言使用简体中文**,除非用户用其他语言提问',
|
|
1994
|
+
].join('\n')
|
|
1995
|
+
: '';
|
|
1996
|
+
|
|
1997
|
+
const channel = getChannelFromJid(containerInput.chatJid);
|
|
1998
|
+
const channelGuidelines = buildChannelGuidelines(channel);
|
|
1999
|
+
|
|
2000
|
+
const systemPromptAppend = [
|
|
2001
|
+
// L1: Identity — 用户身份与偏好(仅主容器注入)
|
|
2002
|
+
globalAgentMemory &&
|
|
2003
|
+
`<user-profile>\n${globalAgentMemory}\n</user-profile>`,
|
|
2004
|
+
|
|
2005
|
+
// L2: Behavior — 核心行为约束(始终注入所有容器)
|
|
2006
|
+
`<behavior>\n${interactionGuidelines}\n</behavior>`,
|
|
2007
|
+
`<security>\n${SECURITY_RULES}\n</security>`,
|
|
2008
|
+
|
|
2009
|
+
// L3: Context — 记忆系统与工作背景
|
|
2010
|
+
`<memory-system>\n${memoryRecall}\n</memory-system>`,
|
|
2011
|
+
heartbeatContent && `<recent-work>\n${heartbeatContent}\n</recent-work>`,
|
|
2012
|
+
|
|
2013
|
+
// L4: Reference — 输出格式与工具使用指南
|
|
2014
|
+
`<output-format>\n${outputGuidelines}\n</output-format>`,
|
|
2015
|
+
`<web-access>\n${webFetchGuidelines}\n</web-access>`,
|
|
2016
|
+
`<background-tasks>\n${backgroundTaskGuidelines}\n</background-tasks>`,
|
|
2017
|
+
channelGuidelines &&
|
|
2018
|
+
`<channel-format>\n${channelGuidelines}\n</channel-format>`,
|
|
2019
|
+
|
|
2020
|
+
// Override: Sub-Agent 行为覆盖
|
|
2021
|
+
conversationAgentGuidelines &&
|
|
2022
|
+
`<agent-override>\n${conversationAgentGuidelines}\n</agent-override>`,
|
|
2023
|
+
]
|
|
2024
|
+
.filter(Boolean)
|
|
2025
|
+
.join('\n');
|
|
2026
|
+
|
|
2027
|
+
// Home containers (admin & member) can access global and memory directories.
|
|
2028
|
+
// Non-home containers only access memory directory; global AGENTS.md is NOT
|
|
2029
|
+
// injected into systemPrompt but remains accessible via filesystem (readonly mount).
|
|
2030
|
+
const extraDirs = isHome
|
|
2031
|
+
? [WORKSPACE_GLOBAL, WORKSPACE_MEMORY]
|
|
2032
|
+
: [WORKSPACE_MEMORY];
|
|
2033
|
+
|
|
2034
|
+
if (shouldInterrupt()) {
|
|
2035
|
+
log('Interrupt sentinel detected before query start, skipping query');
|
|
2036
|
+
interruptedDuringQuery = true;
|
|
2037
|
+
suppressOutputAfterInterrupt = true;
|
|
2038
|
+
ipcPolling = false;
|
|
2039
|
+
stream.end();
|
|
2040
|
+
return {
|
|
2041
|
+
newSessionId,
|
|
2042
|
+
lastAssistantUuid,
|
|
2043
|
+
closedDuringQuery,
|
|
2044
|
+
interruptedDuringQuery,
|
|
2045
|
+
};
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
try {
|
|
2049
|
+
const q = query({
|
|
2050
|
+
prompt: stream,
|
|
2051
|
+
options: {
|
|
2052
|
+
model: containerInput.model || CLAUDE_MODEL,
|
|
2053
|
+
cwd: WORKSPACE_GROUP,
|
|
2054
|
+
additionalDirectories: extraDirs,
|
|
2055
|
+
resume: sessionId,
|
|
2056
|
+
resumeSessionAt: resumeAt,
|
|
2057
|
+
systemPrompt: {
|
|
2058
|
+
type: 'preset' as const,
|
|
2059
|
+
preset: 'claude_code' as const,
|
|
2060
|
+
append: systemPromptAppend,
|
|
2061
|
+
},
|
|
2062
|
+
allowedTools,
|
|
2063
|
+
...(disallowedTools && { disallowedTools }),
|
|
2064
|
+
thinking: { type: 'adaptive' as const },
|
|
2065
|
+
permissionMode: 'bypassPermissions',
|
|
2066
|
+
allowDangerouslySkipPermissions: true,
|
|
2067
|
+
agentProgressSummaries: true,
|
|
2068
|
+
settingSources: ['project', 'user'],
|
|
2069
|
+
includePartialMessages: true,
|
|
2070
|
+
mcpServers: {
|
|
2071
|
+
...loadUserMcpServers(), // 用户配置的 MCP(stdio/http/sse),SDK 原生支持
|
|
2072
|
+
'cli-claw': mcpServerConfig, // 内置 SDK MCP 放最后,确保不被同名覆盖
|
|
2073
|
+
},
|
|
2074
|
+
hooks: {
|
|
2075
|
+
PreCompact: [
|
|
2076
|
+
{
|
|
2077
|
+
hooks: [
|
|
2078
|
+
createPreCompactHook(isHome, isAdminHome, {
|
|
2079
|
+
emit,
|
|
2080
|
+
getFullText: () => processor.getFullText(),
|
|
2081
|
+
resetFullText: () => processor.resetFullTextAccumulator(),
|
|
2082
|
+
}),
|
|
2083
|
+
],
|
|
2084
|
+
},
|
|
2085
|
+
],
|
|
2086
|
+
},
|
|
2087
|
+
agents: PREDEFINED_AGENTS,
|
|
2088
|
+
},
|
|
2089
|
+
});
|
|
2090
|
+
queryRef = q;
|
|
2091
|
+
if (shouldInterrupt()) {
|
|
2092
|
+
log(
|
|
2093
|
+
'Interrupt sentinel already present when query started, interrupting immediately',
|
|
2094
|
+
);
|
|
2095
|
+
interruptedDuringQuery = true;
|
|
2096
|
+
if (!visibleOutputStarted && resultCount === 0) {
|
|
2097
|
+
suppressOutputAfterInterrupt = true;
|
|
2098
|
+
}
|
|
2099
|
+
queryRef
|
|
2100
|
+
.interrupt()
|
|
2101
|
+
.catch((err: unknown) =>
|
|
2102
|
+
log(`Immediate interrupt call failed: ${err}`),
|
|
2103
|
+
);
|
|
2104
|
+
stream.end();
|
|
2105
|
+
ipcPolling = false;
|
|
2106
|
+
}
|
|
2107
|
+
for await (const message of q) {
|
|
2108
|
+
// 流式事件处理
|
|
2109
|
+
if (message.type === 'stream_event') {
|
|
2110
|
+
if (!suppressOutputAfterInterrupt) {
|
|
2111
|
+
visibleOutputStarted = true;
|
|
2112
|
+
}
|
|
2113
|
+
if (suppressOutputAfterInterrupt) {
|
|
2114
|
+
continue;
|
|
2115
|
+
}
|
|
2116
|
+
processor.processStreamEvent(message as any);
|
|
2117
|
+
continue;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
if (message.type === 'tool_progress') {
|
|
2121
|
+
if (!suppressOutputAfterInterrupt) {
|
|
2122
|
+
visibleOutputStarted = true;
|
|
2123
|
+
}
|
|
2124
|
+
if (suppressOutputAfterInterrupt) {
|
|
2125
|
+
continue;
|
|
2126
|
+
}
|
|
2127
|
+
processor.processToolProgress(message as any);
|
|
2128
|
+
continue;
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
if (message.type === 'tool_use_summary') {
|
|
2132
|
+
if (!suppressOutputAfterInterrupt) {
|
|
2133
|
+
visibleOutputStarted = true;
|
|
2134
|
+
}
|
|
2135
|
+
if (suppressOutputAfterInterrupt) {
|
|
2136
|
+
continue;
|
|
2137
|
+
}
|
|
2138
|
+
processor.processToolUseSummary(message as any);
|
|
2139
|
+
continue;
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
// Rate limit event — notify user and keep activity alive
|
|
2143
|
+
if (message.type === 'rate_limit_event') {
|
|
2144
|
+
const info = (message as any).rate_limit_info;
|
|
2145
|
+
if (info?.status === 'rejected') {
|
|
2146
|
+
const resetsAt = info.resetsAt
|
|
2147
|
+
? new Date(info.resetsAt * 1000).toLocaleTimeString()
|
|
2148
|
+
: '未知';
|
|
2149
|
+
processor.emitStatus(`API 限流中,预计 ${resetsAt} 恢复`);
|
|
2150
|
+
} else if (info?.status === 'allowed_warning') {
|
|
2151
|
+
processor.emitStatus(`接近 API 限流阈值`);
|
|
2152
|
+
}
|
|
2153
|
+
continue;
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// System messages
|
|
2157
|
+
if (message.type === 'system') {
|
|
2158
|
+
const sys = message as any;
|
|
2159
|
+
if (processor.processSystemMessage(sys)) {
|
|
2160
|
+
continue;
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
messageCount++;
|
|
2165
|
+
const msgType =
|
|
2166
|
+
message.type === 'system'
|
|
2167
|
+
? `system/${(message as { subtype?: string }).subtype}`
|
|
2168
|
+
: message.type;
|
|
2169
|
+
const msgParentToolUseId = (message as any).parent_tool_use_id ?? null;
|
|
2170
|
+
// 诊断:对所有 assistant/user 消息打印 parent_tool_use_id 和内容块类型
|
|
2171
|
+
if (message.type === 'assistant' || message.type === 'user') {
|
|
2172
|
+
const rawParent = (message as any).parent_tool_use_id;
|
|
2173
|
+
const contentTypes = Array.isArray((message as any).message?.content)
|
|
2174
|
+
? ((message as any).message.content as Array<{ type: string }>)
|
|
2175
|
+
.map((b) => b.type)
|
|
2176
|
+
.join(',')
|
|
2177
|
+
: typeof (message as any).message?.content === 'string'
|
|
2178
|
+
? 'string'
|
|
2179
|
+
: 'none';
|
|
2180
|
+
log(
|
|
2181
|
+
`[msg #${messageCount}] type=${msgType} parent_tool_use_id=${rawParent === undefined ? 'UNDEFINED' : rawParent === null ? 'NULL' : rawParent} content_types=[${contentTypes}] keys=[${Object.keys(message).join(',')}]`,
|
|
2182
|
+
);
|
|
2183
|
+
} else {
|
|
2184
|
+
log(
|
|
2185
|
+
`[msg #${messageCount}] type=${msgType}${msgParentToolUseId ? ` parent=${msgParentToolUseId.slice(0, 12)}` : ''}`,
|
|
2186
|
+
);
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
if (message.type !== 'system') {
|
|
2190
|
+
visibleOutputStarted = true;
|
|
2191
|
+
}
|
|
2192
|
+
if (suppressOutputAfterInterrupt && message.type !== 'system') {
|
|
2193
|
+
if (message.type === 'result') {
|
|
2194
|
+
resultCount++;
|
|
2195
|
+
resultReceivedAt = Date.now();
|
|
2196
|
+
}
|
|
2197
|
+
log(`[msg #${messageCount}] suppressed after early interrupt`);
|
|
2198
|
+
continue;
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
// ── 子 Agent 消息转 StreamEvent ──
|
|
2202
|
+
processor.processSubAgentMessage(message as any);
|
|
2203
|
+
|
|
2204
|
+
if (message.type === 'assistant' && 'uuid' in message) {
|
|
2205
|
+
lastAssistantUuid = (message as { uuid: string }).uuid;
|
|
2206
|
+
const assistantMsg = message as Record<string, unknown>;
|
|
2207
|
+
if ((assistantMsg.parent_tool_use_id ?? null) === null) {
|
|
2208
|
+
const msgContent = (
|
|
2209
|
+
assistantMsg.message as Record<string, unknown> | undefined
|
|
2210
|
+
)?.content;
|
|
2211
|
+
const topLevelText = Array.isArray(msgContent)
|
|
2212
|
+
? (msgContent as Array<{ type: string; text?: string }>)
|
|
2213
|
+
.filter(
|
|
2214
|
+
(block) =>
|
|
2215
|
+
block.type === 'text' && typeof block.text === 'string',
|
|
2216
|
+
)
|
|
2217
|
+
.map((block) => block.text!)
|
|
2218
|
+
.join('')
|
|
2219
|
+
: '';
|
|
2220
|
+
if (topLevelText) {
|
|
2221
|
+
canonicalAssistantText = topLevelText;
|
|
2222
|
+
canonicalAssistantUuid = assistantMsg.uuid as string;
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
processor.processAssistantMessage(message as any);
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
if (message.type === 'system' && message.subtype === 'init') {
|
|
2229
|
+
newSessionId = message.session_id;
|
|
2230
|
+
log(`Session initialized: ${newSessionId}`);
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
if (
|
|
2234
|
+
message.type === 'system' &&
|
|
2235
|
+
(message as { subtype?: string }).subtype === 'task_notification'
|
|
2236
|
+
) {
|
|
2237
|
+
const tn = message as unknown as {
|
|
2238
|
+
task_id: string;
|
|
2239
|
+
tool_use_id?: string;
|
|
2240
|
+
status: string;
|
|
2241
|
+
summary: string;
|
|
2242
|
+
};
|
|
2243
|
+
processor.processTaskNotification(tn);
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
if (message.type === 'result') {
|
|
2247
|
+
resultCount++;
|
|
2248
|
+
const textResult =
|
|
2249
|
+
'result' in message ? (message as { result?: string }).result : null;
|
|
2250
|
+
const resultSubtype = message.subtype;
|
|
2251
|
+
log(
|
|
2252
|
+
`Result #${resultCount}: subtype=${resultSubtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`,
|
|
2253
|
+
);
|
|
2254
|
+
|
|
2255
|
+
// SDK 在某些失败场景会返回 error_* subtype 且不抛异常。
|
|
2256
|
+
// 不能把这类结果当 success(null),否则前端会一直停留在"思考中"。
|
|
2257
|
+
// 匹配策略:显式枚举已知的 error subtype,并用 startsWith('error') 兜底未知的未来 error subtype。
|
|
2258
|
+
// 参考 SDK result subtype 约定:error_during_execution、error_max_turns 等均以 'error' 开头。
|
|
2259
|
+
if (
|
|
2260
|
+
typeof resultSubtype === 'string' &&
|
|
2261
|
+
(resultSubtype === 'error_during_execution' ||
|
|
2262
|
+
resultSubtype.startsWith('error'))
|
|
2263
|
+
) {
|
|
2264
|
+
// If session never initialized (no system/init), resume itself failed — report it
|
|
2265
|
+
// so the caller can retry with a fresh session instead of crashing.
|
|
2266
|
+
if (!newSessionId) {
|
|
2267
|
+
log(`Session resume failed (no init): ${resultSubtype}`);
|
|
2268
|
+
return {
|
|
2269
|
+
newSessionId,
|
|
2270
|
+
lastAssistantUuid,
|
|
2271
|
+
closedDuringQuery,
|
|
2272
|
+
interruptedDuringQuery,
|
|
2273
|
+
sessionResumeFailed: true,
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
2276
|
+
const detail = textResult?.trim()
|
|
2277
|
+
? textResult.trim()
|
|
2278
|
+
: `Claude Code execution failed (${resultSubtype})`;
|
|
2279
|
+
throw new Error(detail);
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
// SDK 将某些 API 错误包装为 subtype=success 的 result(不抛异常)
|
|
2283
|
+
if (textResult && isContextOverflowError(textResult)) {
|
|
2284
|
+
log(
|
|
2285
|
+
`Context overflow detected in result: ${textResult.slice(0, 100)}`,
|
|
2286
|
+
);
|
|
2287
|
+
// ── 发射已累积的部分回复,避免用户已看到的流式内容丢失 ──
|
|
2288
|
+
const partialText = processor.getFullText();
|
|
2289
|
+
if (partialText.trim()) {
|
|
2290
|
+
log(`Emitting overflow_partial with ${partialText.length} chars`);
|
|
2291
|
+
emit({
|
|
2292
|
+
status: 'success',
|
|
2293
|
+
result: partialText,
|
|
2294
|
+
newSessionId,
|
|
2295
|
+
sourceKind: 'overflow_partial',
|
|
2296
|
+
finalizationReason: 'error',
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2299
|
+
processor.resetFullTextAccumulator();
|
|
2300
|
+
return {
|
|
2301
|
+
newSessionId,
|
|
2302
|
+
lastAssistantUuid,
|
|
2303
|
+
closedDuringQuery,
|
|
2304
|
+
contextOverflow: true,
|
|
2305
|
+
interruptedDuringQuery,
|
|
2306
|
+
};
|
|
2307
|
+
}
|
|
2308
|
+
if (textResult && isUnrecoverableTranscriptError(textResult)) {
|
|
2309
|
+
log(
|
|
2310
|
+
`Unrecoverable transcript error in result: ${textResult.slice(0, 200)}`,
|
|
2311
|
+
);
|
|
2312
|
+
processor.resetFullTextAccumulator();
|
|
2313
|
+
return {
|
|
2314
|
+
newSessionId,
|
|
2315
|
+
lastAssistantUuid,
|
|
2316
|
+
closedDuringQuery,
|
|
2317
|
+
unrecoverableTranscriptError: true,
|
|
2318
|
+
interruptedDuringQuery,
|
|
2319
|
+
};
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
const { effectiveResult } = processor.processResult(textResult);
|
|
2323
|
+
const finalText = canonicalAssistantText || effectiveResult;
|
|
2324
|
+
emit({
|
|
2325
|
+
status: 'success',
|
|
2326
|
+
result: finalText,
|
|
2327
|
+
newSessionId,
|
|
2328
|
+
sdkMessageUuid: canonicalAssistantUuid || lastAssistantUuid,
|
|
2329
|
+
sourceKind: sourceKindOverride ?? 'sdk_final',
|
|
2330
|
+
finalizationReason: 'completed',
|
|
2331
|
+
});
|
|
2332
|
+
// After emitting an sdk_final result, rotate turnId so that if
|
|
2333
|
+
// another result is emitted within the same query (e.g. user sent
|
|
2334
|
+
// a follow-up via IPC mid-query), it won't overwrite this one (#214).
|
|
2335
|
+
containerInput.turnId = generateTurnId();
|
|
2336
|
+
|
|
2337
|
+
// Emit usage stream event with token counts and cost
|
|
2338
|
+
const resultMsg = message as Record<string, unknown>;
|
|
2339
|
+
const sdkUsage = resultMsg.usage as Record<string, number> | undefined;
|
|
2340
|
+
const sdkModelUsage = resultMsg.modelUsage as
|
|
2341
|
+
| Record<string, Record<string, number>>
|
|
2342
|
+
| undefined;
|
|
2343
|
+
if (sdkUsage) {
|
|
2344
|
+
const modelUsageSummary: Record<
|
|
2345
|
+
string,
|
|
2346
|
+
{ inputTokens: number; outputTokens: number; costUSD: number }
|
|
2347
|
+
> = {};
|
|
2348
|
+
if (sdkModelUsage && Object.keys(sdkModelUsage).length > 0) {
|
|
2349
|
+
for (const [model, mu] of Object.entries(sdkModelUsage)) {
|
|
2350
|
+
modelUsageSummary[model] = {
|
|
2351
|
+
inputTokens: mu.inputTokens || 0,
|
|
2352
|
+
outputTokens: mu.outputTokens || 0,
|
|
2353
|
+
costUSD: mu.costUSD || 0,
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
2356
|
+
} else {
|
|
2357
|
+
// Fallback: use session-level model name when SDK doesn't provide per-model breakdown
|
|
2358
|
+
modelUsageSummary[CLAUDE_MODEL] = {
|
|
2359
|
+
inputTokens: sdkUsage.input_tokens || 0,
|
|
2360
|
+
outputTokens: sdkUsage.output_tokens || 0,
|
|
2361
|
+
costUSD: (resultMsg.total_cost_usd as number) || 0,
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
2364
|
+
emit({
|
|
2365
|
+
status: 'stream',
|
|
2366
|
+
result: null,
|
|
2367
|
+
streamEvent: {
|
|
2368
|
+
eventType: 'usage',
|
|
2369
|
+
usage: {
|
|
2370
|
+
inputTokens: sdkUsage.input_tokens || 0,
|
|
2371
|
+
outputTokens: sdkUsage.output_tokens || 0,
|
|
2372
|
+
cacheReadInputTokens: sdkUsage.cache_read_input_tokens || 0,
|
|
2373
|
+
cacheCreationInputTokens:
|
|
2374
|
+
sdkUsage.cache_creation_input_tokens || 0,
|
|
2375
|
+
costUSD: (resultMsg.total_cost_usd as number) || 0,
|
|
2376
|
+
durationMs: (resultMsg.duration_ms as number) || 0,
|
|
2377
|
+
numTurns: (resultMsg.num_turns as number) || 0,
|
|
2378
|
+
modelUsage:
|
|
2379
|
+
Object.keys(modelUsageSummary).length > 0
|
|
2380
|
+
? modelUsageSummary
|
|
2381
|
+
: undefined,
|
|
2382
|
+
},
|
|
2383
|
+
},
|
|
2384
|
+
});
|
|
2385
|
+
log(
|
|
2386
|
+
`Usage: input=${sdkUsage.input_tokens} output=${sdkUsage.output_tokens} cost=$${resultMsg.total_cost_usd} turns=${resultMsg.num_turns}`,
|
|
2387
|
+
);
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
// ── 标记结果已收到 ──
|
|
2391
|
+
// pollIpcDuringQuery 会在 POST_RESULT_TIMEOUT_MS 后关闭 stream,
|
|
2392
|
+
// 期间仍可检测 _drain/_close/_interrupt sentinel。
|
|
2393
|
+
resultReceivedAt = Date.now();
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
// Cleanup residual state
|
|
2398
|
+
processor.cleanup();
|
|
2399
|
+
|
|
2400
|
+
ipcPolling = false;
|
|
2401
|
+
ipcQueryWatcher.close();
|
|
2402
|
+
log(
|
|
2403
|
+
`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}, interruptedDuringQuery: ${interruptedDuringQuery}`,
|
|
2404
|
+
);
|
|
2405
|
+
return {
|
|
2406
|
+
newSessionId,
|
|
2407
|
+
lastAssistantUuid,
|
|
2408
|
+
closedDuringQuery,
|
|
2409
|
+
interruptedDuringQuery,
|
|
2410
|
+
};
|
|
2411
|
+
} catch (err) {
|
|
2412
|
+
ipcPolling = false;
|
|
2413
|
+
ipcQueryWatcher.close();
|
|
2414
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
2415
|
+
|
|
2416
|
+
// 检测上下文溢出错误
|
|
2417
|
+
if (isContextOverflowError(errorMessage)) {
|
|
2418
|
+
log(`Context overflow detected: ${errorMessage}`);
|
|
2419
|
+
// ── 发射已累积的部分回复,避免用户已看到的流式内容丢失 ──
|
|
2420
|
+
const partialText = processor.getFullText();
|
|
2421
|
+
if (partialText.trim()) {
|
|
2422
|
+
log(
|
|
2423
|
+
`Emitting overflow_partial (catch) with ${partialText.length} chars`,
|
|
2424
|
+
);
|
|
2425
|
+
emit({
|
|
2426
|
+
status: 'success',
|
|
2427
|
+
result: partialText,
|
|
2428
|
+
newSessionId,
|
|
2429
|
+
sourceKind: 'overflow_partial',
|
|
2430
|
+
finalizationReason: 'error',
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
2433
|
+
return {
|
|
2434
|
+
newSessionId,
|
|
2435
|
+
lastAssistantUuid,
|
|
2436
|
+
closedDuringQuery,
|
|
2437
|
+
contextOverflow: true,
|
|
2438
|
+
interruptedDuringQuery,
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
// 检测不可恢复的转录错误
|
|
2443
|
+
if (isUnrecoverableTranscriptError(errorMessage)) {
|
|
2444
|
+
log(`Unrecoverable transcript error: ${errorMessage}`);
|
|
2445
|
+
return {
|
|
2446
|
+
newSessionId,
|
|
2447
|
+
lastAssistantUuid,
|
|
2448
|
+
closedDuringQuery,
|
|
2449
|
+
unrecoverableTranscriptError: true,
|
|
2450
|
+
interruptedDuringQuery,
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
// 中断导致的 SDK 错误(error_during_execution 等):正常返回,不抛出
|
|
2455
|
+
if (interruptedDuringQuery) {
|
|
2456
|
+
log(`runQuery error during interrupt (non-fatal): ${errorMessage}`);
|
|
2457
|
+
return {
|
|
2458
|
+
newSessionId,
|
|
2459
|
+
lastAssistantUuid,
|
|
2460
|
+
closedDuringQuery,
|
|
2461
|
+
interruptedDuringQuery,
|
|
2462
|
+
};
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
// SDK 在 yield result 后可能再抛异常(如检测到 result text 含错误内容),
|
|
2466
|
+
// 但此时 success 结果已通过 emit() 发送给调用方。再 re-throw 会导致
|
|
2467
|
+
// 外层 catch 额外发射一条 error output 并 exit(1),引发无意义的重试。
|
|
2468
|
+
// 如果已成功发射过结果,将后续 SDK 异常降级为警告。
|
|
2469
|
+
if (resultCount > 0) {
|
|
2470
|
+
log(
|
|
2471
|
+
`runQuery post-result SDK error (non-fatal, ${resultCount} result(s) already emitted): ${errorMessage}`,
|
|
2472
|
+
);
|
|
2473
|
+
if (err instanceof Error && err.stack) {
|
|
2474
|
+
log(`runQuery post-result error stack:\n${err.stack}`);
|
|
2475
|
+
}
|
|
2476
|
+
return {
|
|
2477
|
+
newSessionId,
|
|
2478
|
+
lastAssistantUuid,
|
|
2479
|
+
closedDuringQuery,
|
|
2480
|
+
interruptedDuringQuery,
|
|
2481
|
+
};
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
// 其他错误:记录完整堆栈后继续抛出
|
|
2485
|
+
log(
|
|
2486
|
+
`runQuery error [${(err as NodeJS.ErrnoException).code ?? 'unknown'}]: ${errorMessage}`,
|
|
2487
|
+
);
|
|
2488
|
+
if (err instanceof Error && err.stack) {
|
|
2489
|
+
log(`runQuery error stack:\n${err.stack}`);
|
|
2490
|
+
}
|
|
2491
|
+
// 继续抛出
|
|
2492
|
+
throw err;
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
/**
|
|
2497
|
+
* process.exit() with SIGKILL safety net.
|
|
2498
|
+
* When SDK has pending async resources (background Task tools, MCP connections),
|
|
2499
|
+
* process.exit() may hang indefinitely. Force SIGKILL after 5 seconds.
|
|
2500
|
+
* See GitHub issue #236.
|
|
2501
|
+
*
|
|
2502
|
+
* The timer must NOT use .unref() — if process.exit() silently fails to
|
|
2503
|
+
* terminate (observed with SDK MCP transports holding the event loop),
|
|
2504
|
+
* an unref'd timer won't keep the loop alive and the SIGKILL never fires.
|
|
2505
|
+
* Using a ref'd timer guarantees the safety net triggers.
|
|
2506
|
+
*/
|
|
2507
|
+
function forceExitWithSafetyNet(code: number): never {
|
|
2508
|
+
log(`Exiting with code ${code}, SIGKILL safety net in 5s`);
|
|
2509
|
+
setTimeout(() => {
|
|
2510
|
+
console.error(
|
|
2511
|
+
'[agent-runner] process.exit() did not terminate, forcing SIGKILL',
|
|
2512
|
+
);
|
|
2513
|
+
process.kill(process.pid, 'SIGKILL');
|
|
2514
|
+
}, 5000);
|
|
2515
|
+
process.exit(code);
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
function formatCodexRuntimeError(errorMessage: string): string {
|
|
2519
|
+
const normalized = errorMessage.replace(/\s+/g, ' ').trim();
|
|
2520
|
+
if (!normalized) return 'Codex CLI 运行失败,请稍后重试。';
|
|
2521
|
+
|
|
2522
|
+
const isCodexRuntime =
|
|
2523
|
+
activeRuntimeIdentity?.agentType === 'codex' ||
|
|
2524
|
+
/https:\/\/chatgpt\.com\/codex\/settings\/usage/i.test(normalized) ||
|
|
2525
|
+
/UsageLimitExceeded/i.test(normalized) ||
|
|
2526
|
+
/codex/i.test(normalized);
|
|
2527
|
+
if (!isCodexRuntime) return normalized;
|
|
2528
|
+
|
|
2529
|
+
if (
|
|
2530
|
+
/auth_required|login required|please login|not logged in/i.test(
|
|
2531
|
+
normalized,
|
|
2532
|
+
)
|
|
2533
|
+
) {
|
|
2534
|
+
return 'Codex CLI 未登录。请先在服务器上执行:codex login';
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
if (
|
|
2538
|
+
/UsageLimitExceeded/i.test(normalized) ||
|
|
2539
|
+
/purchase more credits/i.test(normalized) ||
|
|
2540
|
+
/https:\/\/chatgpt\.com\/codex\/settings\/usage/i.test(normalized)
|
|
2541
|
+
) {
|
|
2542
|
+
const usageUrl =
|
|
2543
|
+
normalized.match(/https:\/\/chatgpt\.com\/codex\/settings\/usage/i)?.[0] ||
|
|
2544
|
+
'https://chatgpt.com/codex/settings/usage';
|
|
2545
|
+
const retryAt = normalized.match(/try again at ([^.]+)\.?/i)?.[1]?.trim();
|
|
2546
|
+
return retryAt
|
|
2547
|
+
? `Codex CLI 用量已用尽。请前往 ${usageUrl} 购买额度,或在 ${retryAt} 后重试。`
|
|
2548
|
+
: `Codex CLI 用量已用尽。请前往 ${usageUrl} 购买额度,或稍后重试。`;
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
return normalized;
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
function buildVisibleRuntimeErrorOutput(
|
|
2555
|
+
errorMessage: string,
|
|
2556
|
+
sessionId?: string,
|
|
2557
|
+
): ContainerOutput {
|
|
2558
|
+
const friendlyError = formatCodexRuntimeError(errorMessage);
|
|
2559
|
+
return {
|
|
2560
|
+
status: 'error',
|
|
2561
|
+
result: friendlyError,
|
|
2562
|
+
error: friendlyError,
|
|
2563
|
+
alreadyStreamedError: true,
|
|
2564
|
+
finalizationReason: 'error',
|
|
2565
|
+
...(sessionId ? { newSessionId: sessionId } : {}),
|
|
2566
|
+
};
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
async function main(): Promise<void> {
|
|
2570
|
+
let containerInput: ContainerInput;
|
|
2571
|
+
|
|
2572
|
+
try {
|
|
2573
|
+
const stdinData = await readStdin();
|
|
2574
|
+
containerInput = JSON.parse(stdinData);
|
|
2575
|
+
const requestedAgentType = containerInput.agentType || 'claude';
|
|
2576
|
+
activeRuntimeIdentity = buildRuntimeIdentity(requestedAgentType, {
|
|
2577
|
+
model: containerInput.model ?? null,
|
|
2578
|
+
reasoningEffort: containerInput.reasoningEffort ?? null,
|
|
2579
|
+
});
|
|
2580
|
+
log(
|
|
2581
|
+
`Received input for group: ${containerInput.groupFolder}, chatJid: ${containerInput.chatJid}, agentType: ${requestedAgentType}, session: ${containerInput.sessionId || 'new'}, runnerPid: ${process.pid}`,
|
|
2582
|
+
);
|
|
2583
|
+
} catch (err) {
|
|
2584
|
+
writeOutput(
|
|
2585
|
+
buildVisibleRuntimeErrorOutput(
|
|
2586
|
+
`Failed to parse input: ${err instanceof Error ? err.message : String(err)}`,
|
|
2587
|
+
),
|
|
2588
|
+
);
|
|
2589
|
+
process.exit(1);
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
let sessionId = containerInput.sessionId;
|
|
2593
|
+
latestSessionId = sessionId;
|
|
2594
|
+
const { isHome, isAdminHome } = normalizeHomeFlags(containerInput);
|
|
2595
|
+
|
|
2596
|
+
if ((containerInput.agentType || 'claude') === 'codex') {
|
|
2597
|
+
log(`Selected runner: codex, runnerPid: ${process.pid}`);
|
|
2598
|
+
await runCodexLoop(containerInput);
|
|
2599
|
+
forceExitWithSafetyNet(0);
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
log(`Selected runner: claude, runnerPid: ${process.pid}`);
|
|
2603
|
+
|
|
2604
|
+
// Create in-process SDK MCP server (replaces the stdio subprocess)
|
|
2605
|
+
const mcpToolsConfig = {
|
|
2606
|
+
chatJid: containerInput.chatJid,
|
|
2607
|
+
groupFolder: containerInput.groupFolder,
|
|
2608
|
+
isHome,
|
|
2609
|
+
isAdminHome,
|
|
2610
|
+
isScheduledTask: containerInput.isScheduledTask || false,
|
|
2611
|
+
workspaceIpc: WORKSPACE_IPC,
|
|
2612
|
+
workspaceGroup: WORKSPACE_GROUP,
|
|
2613
|
+
workspaceGlobal: WORKSPACE_GLOBAL,
|
|
2614
|
+
workspaceMemory: WORKSPACE_MEMORY,
|
|
2615
|
+
};
|
|
2616
|
+
const buildMcpServerConfig = () =>
|
|
2617
|
+
createSdkMcpServer({
|
|
2618
|
+
name: 'cli-claw',
|
|
2619
|
+
version: '1.0.0',
|
|
2620
|
+
tools: createMcpTools(mcpToolsConfig),
|
|
2621
|
+
});
|
|
2622
|
+
let mcpServerConfig = buildMcpServerConfig();
|
|
2623
|
+
const memoryRecallPrompt = buildMemoryRecallPrompt(isHome, isAdminHome);
|
|
2624
|
+
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
|
|
2625
|
+
|
|
2626
|
+
// Clean up stale sentinels from previous container runs.
|
|
2627
|
+
// Note: _drain is NOT cleaned here — the host's cleanupIpcSentinels() in
|
|
2628
|
+
// runForGroup's finally block already removes stale sentinels between runs.
|
|
2629
|
+
// A _drain present at startup was written by registerProcess() for the
|
|
2630
|
+
// CURRENT run (indicating pending messages arrived during container boot).
|
|
2631
|
+
// Deleting it here causes those messages to be silently lost (#xxx).
|
|
2632
|
+
try {
|
|
2633
|
+
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
|
|
2634
|
+
} catch {
|
|
2635
|
+
/* ignore */
|
|
2636
|
+
}
|
|
2637
|
+
cleanupStartupInterruptSentinel();
|
|
2638
|
+
|
|
2639
|
+
// Build initial prompt (drain any pending IPC messages too)
|
|
2640
|
+
let prompt = containerInput.prompt;
|
|
2641
|
+
let promptImages = containerInput.images;
|
|
2642
|
+
if (containerInput.isScheduledTask) {
|
|
2643
|
+
const scheduledTaskPrefixLines = [
|
|
2644
|
+
'[定时任务 - 以下内容由系统自动发送,并非来自用户或群组的直接消息。]',
|
|
2645
|
+
'',
|
|
2646
|
+
'重要:你正在定时任务模式下运行。你的最终输出不会自动发送给用户。你必须使用 mcp__cli-claw__send_message 工具来发送消息,否则用户将收不到任何内容。',
|
|
2647
|
+
'',
|
|
2648
|
+
'注意:只在完成任务后调用一次 send_message 发送最终结果,不要发送中间状态或重复消息。',
|
|
2649
|
+
];
|
|
2650
|
+
const scheduledTaskPrefix = scheduledTaskPrefixLines.join('\n');
|
|
2651
|
+
prompt = scheduledTaskPrefix + '\n\n' + prompt;
|
|
2652
|
+
}
|
|
2653
|
+
const pendingDrain = drainIpcInput();
|
|
2654
|
+
if (pendingDrain.messages.length > 0) {
|
|
2655
|
+
log(
|
|
2656
|
+
`Draining ${pendingDrain.messages.length} pending IPC messages into initial prompt`,
|
|
2657
|
+
);
|
|
2658
|
+
prompt += '\n' + pendingDrain.messages.map((m) => m.text).join('\n');
|
|
2659
|
+
const pendingImages = pendingDrain.messages.flatMap((m) => m.images || []);
|
|
2660
|
+
if (pendingImages.length > 0) {
|
|
2661
|
+
promptImages = [...(promptImages || []), ...pendingImages];
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
// Query loop: run query -> wait for IPC message -> run new query -> repeat
|
|
2666
|
+
let resumeAt: string | undefined;
|
|
2667
|
+
let overflowRetryCount = 0;
|
|
2668
|
+
const MAX_OVERFLOW_RETRIES = 3;
|
|
2669
|
+
let consecutiveCompactions = 0;
|
|
2670
|
+
const MAX_CONSECUTIVE_COMPACTIONS = 3;
|
|
2671
|
+
try {
|
|
2672
|
+
while (true) {
|
|
2673
|
+
// 清理残留的 _interrupt sentinel(空闲期间写入的中断信号不应影响下一次 query)。
|
|
2674
|
+
// 注意:_drain 不在此处清理 — 如果 _drain 存在,说明有待处理的消息,
|
|
2675
|
+
// pollIpcDuringQuery 会在查询结果后检测到并正确退出容器。
|
|
2676
|
+
try {
|
|
2677
|
+
fs.unlinkSync(IPC_INPUT_INTERRUPT_SENTINEL);
|
|
2678
|
+
} catch {
|
|
2679
|
+
/* ignore */
|
|
2680
|
+
}
|
|
2681
|
+
clearInterruptRequested();
|
|
2682
|
+
|
|
2683
|
+
log(
|
|
2684
|
+
`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`,
|
|
2685
|
+
);
|
|
2686
|
+
|
|
2687
|
+
const queryResult = await runQuery(
|
|
2688
|
+
prompt,
|
|
2689
|
+
sessionId,
|
|
2690
|
+
mcpServerConfig,
|
|
2691
|
+
containerInput,
|
|
2692
|
+
memoryRecallPrompt,
|
|
2693
|
+
resumeAt,
|
|
2694
|
+
true,
|
|
2695
|
+
DEFAULT_ALLOWED_TOOLS,
|
|
2696
|
+
undefined,
|
|
2697
|
+
promptImages,
|
|
2698
|
+
);
|
|
2699
|
+
if (queryResult.newSessionId) {
|
|
2700
|
+
sessionId = queryResult.newSessionId;
|
|
2701
|
+
latestSessionId = sessionId;
|
|
2702
|
+
}
|
|
2703
|
+
if (queryResult.lastAssistantUuid) {
|
|
2704
|
+
resumeAt = queryResult.lastAssistantUuid;
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
// Session resume 失败(SDK 无法恢复旧会话):清除 session,以新会话重试
|
|
2708
|
+
if (queryResult.sessionResumeFailed) {
|
|
2709
|
+
log(
|
|
2710
|
+
`Session resume failed, retrying with fresh session (old: ${sessionId})`,
|
|
2711
|
+
);
|
|
2712
|
+
sessionId = undefined;
|
|
2713
|
+
latestSessionId = undefined;
|
|
2714
|
+
resumeAt = undefined;
|
|
2715
|
+
consecutiveCompactions = 0;
|
|
2716
|
+
// Rebuild MCP server to avoid "Already connected to a transport" error
|
|
2717
|
+
mcpServerConfig = buildMcpServerConfig();
|
|
2718
|
+
continue;
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
// 不可恢复的转录错误(如超大图片或 MIME 错配被固化在会话历史中)
|
|
2722
|
+
if (queryResult.unrecoverableTranscriptError) {
|
|
2723
|
+
const errorMsg =
|
|
2724
|
+
'会话历史中包含无法处理的数据(如超大图片或图片 MIME 错配),会话需要重置。';
|
|
2725
|
+
log(`Unrecoverable transcript error, signaling session reset`);
|
|
2726
|
+
writeOutput({
|
|
2727
|
+
status: 'error',
|
|
2728
|
+
result: null,
|
|
2729
|
+
error: `unrecoverable_transcript: ${errorMsg}`,
|
|
2730
|
+
newSessionId: sessionId,
|
|
2731
|
+
});
|
|
2732
|
+
process.exit(1);
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
// 检查上下文溢出
|
|
2736
|
+
if (queryResult.contextOverflow) {
|
|
2737
|
+
overflowRetryCount++;
|
|
2738
|
+
log(
|
|
2739
|
+
`Context overflow detected, retry ${overflowRetryCount}/${MAX_OVERFLOW_RETRIES}`,
|
|
2740
|
+
);
|
|
2741
|
+
|
|
2742
|
+
if (overflowRetryCount >= MAX_OVERFLOW_RETRIES) {
|
|
2743
|
+
const errorMsg = `上下文溢出错误:已重试 ${MAX_OVERFLOW_RETRIES} 次仍失败。请联系管理员检查 AGENTS.md 大小或减少会话历史。`;
|
|
2744
|
+
log(errorMsg);
|
|
2745
|
+
writeOutput({
|
|
2746
|
+
status: 'error',
|
|
2747
|
+
result: null,
|
|
2748
|
+
error: `context_overflow: ${errorMsg}`,
|
|
2749
|
+
newSessionId: sessionId,
|
|
2750
|
+
});
|
|
2751
|
+
process.exit(1);
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
// 未超过重试次数,等待后继续下一轮循环(会触发自动压缩)
|
|
2755
|
+
log(
|
|
2756
|
+
'Retrying query after context overflow (will trigger auto-compaction)...',
|
|
2757
|
+
);
|
|
2758
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
2759
|
+
continue;
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
// 成功执行后重置溢出重试计数器
|
|
2763
|
+
overflowRetryCount = 0;
|
|
2764
|
+
|
|
2765
|
+
// If _close was consumed during the query, exit immediately.
|
|
2766
|
+
// Don't emit a session-update marker (it would reset the host's
|
|
2767
|
+
// idle timer and cause a 30-min delay before the next _close).
|
|
2768
|
+
if (queryResult.closedDuringQuery) {
|
|
2769
|
+
log('Close sentinel consumed during query, exiting');
|
|
2770
|
+
// Notify host that this exit was due to _close, not a normal completion.
|
|
2771
|
+
// Without this marker the host treats the exit as silent success and
|
|
2772
|
+
// commits the message cursor, causing the in-flight IM message to be
|
|
2773
|
+
// consumed without a reply (the "swallowed message" bug).
|
|
2774
|
+
writeOutput({ status: 'closed', result: null });
|
|
2775
|
+
break;
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
// 中断后:跳过 memory flush 和 session update,等待下一条消息
|
|
2779
|
+
if (queryResult.interruptedDuringQuery) {
|
|
2780
|
+
log('Query interrupted by user, waiting for next message');
|
|
2781
|
+
// 中断后清除 resumeAt:被中断的 assistant 消息可能未完整提交到 session 历史。
|
|
2782
|
+
// 使用 undefined 让 SDK 自行选择恢复点,避免因指向不完整消息的 UUID 导致 resume 失败。
|
|
2783
|
+
resumeAt = undefined;
|
|
2784
|
+
writeOutput({
|
|
2785
|
+
status: 'stream',
|
|
2786
|
+
result: null,
|
|
2787
|
+
streamEvent: { eventType: 'status', statusText: 'interrupted' },
|
|
2788
|
+
newSessionId: sessionId, // 确保主进程持久化 session ID
|
|
2789
|
+
});
|
|
2790
|
+
// 清理可能残留的 _interrupt 文件
|
|
2791
|
+
try {
|
|
2792
|
+
fs.unlinkSync(IPC_INPUT_INTERRUPT_SENTINEL);
|
|
2793
|
+
} catch {
|
|
2794
|
+
/* ignore */
|
|
2795
|
+
}
|
|
2796
|
+
// 不 break,等待下一条消息
|
|
2797
|
+
const nextMessage = await waitForIpcMessage();
|
|
2798
|
+
if (nextMessage === null) {
|
|
2799
|
+
log('Close sentinel received after interrupt, exiting');
|
|
2800
|
+
// 退出前发送 session 更新,确保主进程持久化最新 session ID
|
|
2801
|
+
writeOutput({
|
|
2802
|
+
status: 'success',
|
|
2803
|
+
result: null,
|
|
2804
|
+
newSessionId: sessionId,
|
|
2805
|
+
});
|
|
2806
|
+
break;
|
|
2807
|
+
}
|
|
2808
|
+
clearInterruptRequested();
|
|
2809
|
+
consecutiveCompactions = 0;
|
|
2810
|
+
prompt = nextMessage.text;
|
|
2811
|
+
promptImages = nextMessage.images;
|
|
2812
|
+
containerInput.turnId = generateTurnId();
|
|
2813
|
+
continue;
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
// Memory Flush: run an extra query to let agent save durable memories (home containers only)
|
|
2817
|
+
// Skip flush when already in a compaction loop — context is too full for productive work.
|
|
2818
|
+
if (needsMemoryFlush && isHome && consecutiveCompactions === 0) {
|
|
2819
|
+
needsMemoryFlush = false;
|
|
2820
|
+
log('Running memory flush query after compaction...');
|
|
2821
|
+
|
|
2822
|
+
const today = new Date().toISOString().split('T')[0];
|
|
2823
|
+
const flushPrompt = [
|
|
2824
|
+
'上下文压缩前记忆刷新。',
|
|
2825
|
+
'**优先检查全局记忆**:先 Read /workspace/global/AGENTS.md,如果有「待记录」字段且你已获知对应信息(用户身份、偏好、常用项目等),用 Edit 工具立即填写。',
|
|
2826
|
+
'用户明确要求记住的内容,以及下次对话仍可能用到的信息,也写入全局记忆。',
|
|
2827
|
+
`然后使用 memory_append 将时效性记忆保存到 memory/${today}.md(今日进展、临时决策、待办等)。`,
|
|
2828
|
+
'如需确认上下文,可先用 memory_search/memory_get 查阅。',
|
|
2829
|
+
'如果没有值得保存的内容,回复一个字:OK。',
|
|
2830
|
+
].join(' ');
|
|
2831
|
+
|
|
2832
|
+
const flushResult = await runQuery(
|
|
2833
|
+
flushPrompt,
|
|
2834
|
+
sessionId,
|
|
2835
|
+
mcpServerConfig,
|
|
2836
|
+
containerInput,
|
|
2837
|
+
memoryRecallPrompt,
|
|
2838
|
+
resumeAt,
|
|
2839
|
+
false,
|
|
2840
|
+
MEMORY_FLUSH_ALLOWED_TOOLS,
|
|
2841
|
+
MEMORY_FLUSH_DISALLOWED_TOOLS,
|
|
2842
|
+
);
|
|
2843
|
+
if (flushResult.newSessionId) {
|
|
2844
|
+
sessionId = flushResult.newSessionId;
|
|
2845
|
+
latestSessionId = sessionId;
|
|
2846
|
+
}
|
|
2847
|
+
if (flushResult.lastAssistantUuid)
|
|
2848
|
+
resumeAt = flushResult.lastAssistantUuid;
|
|
2849
|
+
log('Memory flush completed');
|
|
2850
|
+
|
|
2851
|
+
if (flushResult.closedDuringQuery) {
|
|
2852
|
+
log('Close sentinel during memory flush, exiting');
|
|
2853
|
+
writeOutput({ status: 'closed', result: null });
|
|
2854
|
+
break;
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
// Emit session update so host can track it
|
|
2859
|
+
writeOutput({ status: 'success', result: null, newSessionId: sessionId });
|
|
2860
|
+
|
|
2861
|
+
// ── Non-blocking compaction: auto-continue after context compaction ──
|
|
2862
|
+
// Instead of waiting for user to send "继续", automatically start a
|
|
2863
|
+
// new query so the agent resumes seamlessly where it left off.
|
|
2864
|
+
// The query is tagged with sourceKind='auto_continue' so the host
|
|
2865
|
+
// process can suppress system-maintenance noise (memory flush "OK",
|
|
2866
|
+
// AGENTS.md update acks, etc.) that leaked into the agent's session
|
|
2867
|
+
// transcript — the host will only forward substantive user-facing
|
|
2868
|
+
// content to IM, preventing the bug described in issue #275.
|
|
2869
|
+
//
|
|
2870
|
+
// Guard: if compaction keeps firing repeatedly (e.g. system prompt alone
|
|
2871
|
+
// nearly fills the context window), stop auto-continuing to avoid an
|
|
2872
|
+
// infinite loop that burns API tokens without producing useful work.
|
|
2873
|
+
if (hadCompaction) {
|
|
2874
|
+
hadCompaction = false;
|
|
2875
|
+
consecutiveCompactions++;
|
|
2876
|
+
if (consecutiveCompactions <= MAX_CONSECUTIVE_COMPACTIONS) {
|
|
2877
|
+
log(
|
|
2878
|
+
`Auto-continuing after compaction (${consecutiveCompactions}/${MAX_CONSECUTIVE_COMPACTIONS})`,
|
|
2879
|
+
);
|
|
2880
|
+
const autoContinuePrompt = [
|
|
2881
|
+
'继续。',
|
|
2882
|
+
'注意:刚刚发生了上下文压缩,系统已自动执行了记忆刷新和 AGENTS.md 更新(这些是内部维护操作)。',
|
|
2883
|
+
'请**只关注与用户的实际对话**,从压缩前的最后一个对话话题自然衔接。',
|
|
2884
|
+
'如果压缩前你正在进行方案设计、讨论或等待用户确认,请简要回顾当前状态和待确认事项。',
|
|
2885
|
+
'如果压缩前已经在执行中,则继续执行。',
|
|
2886
|
+
'**重要**:不要提及、确认或重复任何系统维护相关的内容(如 "OK"、"已更新 AGENTS.md"、"记忆已刷新" 等),',
|
|
2887
|
+
'这些内部状态对用户不可见。如果你的回复中确实包含此类内容,请用 <internal>...</internal> 标签包裹。',
|
|
2888
|
+
].join('');
|
|
2889
|
+
containerInput.turnId = generateTurnId();
|
|
2890
|
+
const autoContResult = await runQuery(
|
|
2891
|
+
autoContinuePrompt,
|
|
2892
|
+
sessionId,
|
|
2893
|
+
mcpServerConfig,
|
|
2894
|
+
containerInput,
|
|
2895
|
+
memoryRecallPrompt,
|
|
2896
|
+
resumeAt,
|
|
2897
|
+
true,
|
|
2898
|
+
DEFAULT_ALLOWED_TOOLS,
|
|
2899
|
+
undefined,
|
|
2900
|
+
undefined,
|
|
2901
|
+
'auto_continue',
|
|
2902
|
+
);
|
|
2903
|
+
if (autoContResult.newSessionId) {
|
|
2904
|
+
sessionId = autoContResult.newSessionId;
|
|
2905
|
+
latestSessionId = sessionId;
|
|
2906
|
+
}
|
|
2907
|
+
if (autoContResult.lastAssistantUuid) {
|
|
2908
|
+
resumeAt = autoContResult.lastAssistantUuid;
|
|
2909
|
+
}
|
|
2910
|
+
if (autoContResult.closedDuringQuery) {
|
|
2911
|
+
log('Close sentinel during auto-continue, exiting');
|
|
2912
|
+
writeOutput({ status: 'closed', result: null });
|
|
2913
|
+
break;
|
|
2914
|
+
}
|
|
2915
|
+
// Handle abnormal states from auto-continue runQuery (these were
|
|
2916
|
+
// previously handled by the main loop's `continue` re-entry; now that
|
|
2917
|
+
// auto-continue is a standalone call we must check them explicitly).
|
|
2918
|
+
if (autoContResult.sessionResumeFailed) {
|
|
2919
|
+
log(
|
|
2920
|
+
'WARN: Session resume failed during auto-continue, clearing session',
|
|
2921
|
+
);
|
|
2922
|
+
sessionId = undefined;
|
|
2923
|
+
latestSessionId = undefined;
|
|
2924
|
+
resumeAt = undefined;
|
|
2925
|
+
mcpServerConfig = buildMcpServerConfig();
|
|
2926
|
+
// Fall through to wait for next IPC message with a fresh session.
|
|
2927
|
+
}
|
|
2928
|
+
if (autoContResult.unrecoverableTranscriptError) {
|
|
2929
|
+
log(
|
|
2930
|
+
'WARN: Unrecoverable transcript error during auto-continue, signaling reset',
|
|
2931
|
+
);
|
|
2932
|
+
writeOutput({
|
|
2933
|
+
status: 'error',
|
|
2934
|
+
result: null,
|
|
2935
|
+
error:
|
|
2936
|
+
'unrecoverable_transcript: 会话历史中包含无法处理的数据,会话需要重置。',
|
|
2937
|
+
newSessionId: sessionId,
|
|
2938
|
+
});
|
|
2939
|
+
process.exit(1);
|
|
2940
|
+
}
|
|
2941
|
+
if (autoContResult.contextOverflow) {
|
|
2942
|
+
log(
|
|
2943
|
+
'WARN: Context overflow during auto-continue, will be handled on next query',
|
|
2944
|
+
);
|
|
2945
|
+
// Don't retry here — the main loop's overflow-retry logic will
|
|
2946
|
+
// kick in on the next user-initiated query.
|
|
2947
|
+
}
|
|
2948
|
+
if (autoContResult.interruptedDuringQuery) {
|
|
2949
|
+
log('WARN: Auto-continue query was interrupted by user');
|
|
2950
|
+
resumeAt = undefined;
|
|
2951
|
+
try {
|
|
2952
|
+
fs.unlinkSync(IPC_INPUT_INTERRUPT_SENTINEL);
|
|
2953
|
+
} catch {
|
|
2954
|
+
/* ignore */
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
// After auto-continue, fall through to wait for next IPC message.
|
|
2958
|
+
} else {
|
|
2959
|
+
log(
|
|
2960
|
+
`Compaction loop detected (${consecutiveCompactions} consecutive), stopping auto-continue and waiting for user input`,
|
|
2961
|
+
);
|
|
2962
|
+
consecutiveCompactions = 0;
|
|
2963
|
+
}
|
|
2964
|
+
} else {
|
|
2965
|
+
consecutiveCompactions = 0;
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
log('Query ended, waiting for next IPC message...');
|
|
2969
|
+
|
|
2970
|
+
// Wait for the next message or _close sentinel
|
|
2971
|
+
const nextMessage = await waitForIpcMessage();
|
|
2972
|
+
if (nextMessage === null) {
|
|
2973
|
+
log('Close sentinel received, exiting');
|
|
2974
|
+
break;
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
log(
|
|
2978
|
+
`Got new message (${nextMessage.text.length} chars, ${nextMessage.images?.length || 0} images), starting new query`,
|
|
2979
|
+
);
|
|
2980
|
+
prompt = nextMessage.text;
|
|
2981
|
+
promptImages = nextMessage.images;
|
|
2982
|
+
containerInput.turnId = generateTurnId();
|
|
2983
|
+
}
|
|
2984
|
+
} catch (err) {
|
|
2985
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
2986
|
+
log(`Agent error: ${errorMessage}`);
|
|
2987
|
+
if (err instanceof Error && err.stack) {
|
|
2988
|
+
log(`Agent error stack:\n${err.stack}`);
|
|
2989
|
+
}
|
|
2990
|
+
// Log cause chain for SDK-wrapped errors (e.g. EPIPE from internal claude CLI)
|
|
2991
|
+
const cause =
|
|
2992
|
+
err instanceof Error
|
|
2993
|
+
? (err as NodeJS.ErrnoException & { cause?: unknown }).cause
|
|
2994
|
+
: undefined;
|
|
2995
|
+
if (cause) {
|
|
2996
|
+
const causeMsg =
|
|
2997
|
+
cause instanceof Error ? cause.stack || cause.message : String(cause);
|
|
2998
|
+
log(`Agent error cause:\n${causeMsg}`);
|
|
2999
|
+
}
|
|
3000
|
+
log(
|
|
3001
|
+
`Agent error errno: ${(err as NodeJS.ErrnoException).code ?? 'none'} exitCode: ${process.exitCode ?? 'none'}`,
|
|
3002
|
+
);
|
|
3003
|
+
// 不在 error output 中携带 sessionId:
|
|
3004
|
+
// 流式输出已通过 onOutput 回调传递了有效的 session 更新。
|
|
3005
|
+
// 如果这里携带的是 throw 前的旧 sessionId,会覆盖中间成功产生的新 session。
|
|
3006
|
+
writeOutput(buildVisibleRuntimeErrorOutput(errorMessage));
|
|
3007
|
+
forceExitWithSafetyNet(1);
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
// main() 正常结束后必须显式退出。
|
|
3011
|
+
// SDK 内部可能留有未关闭的异步资源(MCP 连接、定时器等),
|
|
3012
|
+
// 如果不调用 process.exit(),Node.js 事件循环不会自动退出,
|
|
3013
|
+
// 导致 agent-runner 进程以 0% CPU 挂起,阻塞队列。
|
|
3014
|
+
//
|
|
3015
|
+
// Safety net: 当 SDK 的后台 Task (run_in_background) 持有异步资源时,
|
|
3016
|
+
// process.exit() 可能无法终止进程。5 秒后强制 SIGKILL。
|
|
3017
|
+
// 参考 GitHub issue #236。
|
|
3018
|
+
forceExitWithSafetyNet(0);
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
// 处理管道断开(EPIPE):父进程关闭管道后仍有写入时,静默退出避免 code 1 错误输出
|
|
3022
|
+
(process.stdout as NodeJS.WriteStream & NodeJS.EventEmitter).on(
|
|
3023
|
+
'error',
|
|
3024
|
+
(err: NodeJS.ErrnoException) => {
|
|
3025
|
+
if (err.code === 'EPIPE') process.exit(0);
|
|
3026
|
+
},
|
|
3027
|
+
);
|
|
3028
|
+
(process.stderr as NodeJS.WriteStream & NodeJS.EventEmitter).on(
|
|
3029
|
+
'error',
|
|
3030
|
+
(err: NodeJS.ErrnoException) => {
|
|
3031
|
+
if (err.code === 'EPIPE') process.exit(0);
|
|
3032
|
+
},
|
|
3033
|
+
);
|
|
3034
|
+
|
|
3035
|
+
/**
|
|
3036
|
+
* 某些 SDK/底层 socket 会在管道断开后触发未捕获 EPIPE。
|
|
3037
|
+
* 这类错误通常发生在结果已输出之后,属于"收尾写入失败",
|
|
3038
|
+
* 不应把整个 host query 标记为启动失败(code 1)。
|
|
3039
|
+
*/
|
|
3040
|
+
process.on('SIGTERM', () => {
|
|
3041
|
+
log('Received SIGTERM, exiting gracefully');
|
|
3042
|
+
// Emit latest session ID so the host can persist it before we exit.
|
|
3043
|
+
// Without this, the host starts a fresh session on restart, losing context.
|
|
3044
|
+
if (latestSessionId) {
|
|
3045
|
+
try {
|
|
3046
|
+
writeOutput({
|
|
3047
|
+
status: 'success',
|
|
3048
|
+
result: null,
|
|
3049
|
+
newSessionId: latestSessionId,
|
|
3050
|
+
});
|
|
3051
|
+
} catch {
|
|
3052
|
+
/* stdout may be closed */
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
forceExitWithSafetyNet(0);
|
|
3056
|
+
});
|
|
3057
|
+
|
|
3058
|
+
process.on('SIGINT', () => {
|
|
3059
|
+
log('Received SIGINT, exiting gracefully');
|
|
3060
|
+
forceExitWithSafetyNet(0);
|
|
3061
|
+
});
|
|
3062
|
+
|
|
3063
|
+
process.on('uncaughtException', (err: unknown) => {
|
|
3064
|
+
const errno = err as NodeJS.ErrnoException;
|
|
3065
|
+
if (errno?.code === 'EPIPE') {
|
|
3066
|
+
process.exit(0);
|
|
3067
|
+
}
|
|
3068
|
+
if (isWithinInterruptGraceWindow() && isInterruptRelatedError(err)) {
|
|
3069
|
+
console.error('Suppressing interrupt-related uncaught exception:', err);
|
|
3070
|
+
process.exit(0);
|
|
3071
|
+
}
|
|
3072
|
+
console.error('Uncaught exception:', err);
|
|
3073
|
+
// 尝试输出结构化错误,让主进程能收到错误信息而非仅看到 exit code 1
|
|
3074
|
+
try {
|
|
3075
|
+
writeOutput(buildVisibleRuntimeErrorOutput(String(err), latestSessionId));
|
|
3076
|
+
} catch {
|
|
3077
|
+
/* ignore */
|
|
3078
|
+
}
|
|
3079
|
+
process.exit(1);
|
|
3080
|
+
});
|
|
3081
|
+
|
|
3082
|
+
process.on('unhandledRejection', (reason: unknown) => {
|
|
3083
|
+
const errno = reason as NodeJS.ErrnoException;
|
|
3084
|
+
if (errno?.code === 'EPIPE') {
|
|
3085
|
+
process.exit(0);
|
|
3086
|
+
}
|
|
3087
|
+
if (isWithinInterruptGraceWindow()) {
|
|
3088
|
+
console.error('Unhandled rejection during interrupt (non-fatal):', reason);
|
|
3089
|
+
return;
|
|
3090
|
+
}
|
|
3091
|
+
console.error('Unhandled rejection:', reason);
|
|
3092
|
+
try {
|
|
3093
|
+
writeOutput(buildVisibleRuntimeErrorOutput(String(reason), latestSessionId));
|
|
3094
|
+
} catch {
|
|
3095
|
+
/* ignore */
|
|
3096
|
+
}
|
|
3097
|
+
process.exit(1);
|
|
3098
|
+
});
|
|
3099
|
+
main().catch((err) => {
|
|
3100
|
+
console.error('Fatal error in main():', err);
|
|
3101
|
+
try {
|
|
3102
|
+
writeOutput(buildVisibleRuntimeErrorOutput(String(err), latestSessionId));
|
|
3103
|
+
} catch {
|
|
3104
|
+
/* ignore */
|
|
3105
|
+
}
|
|
3106
|
+
process.exit(1);
|
|
3107
|
+
});
|