@xyagent/cli 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/README.md +860 -0
- package/bin/agentlink +8585 -0
- package/bin/agentlink-agent +7 -0
- package/bin/agentlink-mcp-stdio +173 -0
- package/docs/LOGGING.md +159 -0
- package/package.json +38 -0
- package/src/core.mjs +320 -0
- package/src/tunnel.mjs +291 -0
- package/src/tunnel_proxy.mjs +913 -0
- package/src/tunnel_service.mjs +699 -0
- package/src/tunnel_state.mjs +301 -0
- package/src/unified_producer.mjs +259 -0
- package/src-ext/README.md +75 -0
- package/src-ext/bin.mjs +128 -0
- package/src-ext/commands/agent.mjs +66 -0
- package/src-ext/commands/help.mjs +31 -0
- package/src-ext/commands/pair.mjs +455 -0
- package/src-ext/commands/reload.mjs +78 -0
- package/src-ext/commands/service.mjs +64 -0
- package/src-ext/core/accountResolver.mjs +137 -0
- package/src-ext/core/activeRuns.mjs +76 -0
- package/src-ext/core/agentlinkToolsBootstrap.mjs +75 -0
- package/src-ext/core/agentlinkToolsRegistry.mjs +323 -0
- package/src-ext/core/agentlinkToolsServer.mjs +109 -0
- package/src-ext/core/approvalGateway.mjs +132 -0
- package/src-ext/core/attachAuditLog.mjs +79 -0
- package/src-ext/core/attachDenylist.mjs +128 -0
- package/src-ext/core/autoTunnelDetector.mjs +98 -0
- package/src-ext/core/autoTunnelGateway.mjs +393 -0
- package/src-ext/core/autoTunnelWiring.mjs +184 -0
- package/src-ext/core/boundedMap.mjs +98 -0
- package/src-ext/core/bridgeAttachAutoScanner.mjs +237 -0
- package/src-ext/core/bridgeHooks.mjs +168 -0
- package/src-ext/core/bridgeSelfUninstall.mjs +133 -0
- package/src-ext/core/claudeTrust.mjs +74 -0
- package/src-ext/core/codexTrust.mjs +77 -0
- package/src-ext/core/cosUploadClient.mjs +326 -0
- package/src-ext/core/createWorkspace.mjs +170 -0
- package/src-ext/core/daemonRestart.mjs +49 -0
- package/src-ext/core/defaultWorkspace.mjs +65 -0
- package/src-ext/core/ensureGitRepo.mjs +57 -0
- package/src-ext/core/hermesProfile.mjs +75 -0
- package/src-ext/core/interruptGateway.mjs +119 -0
- package/src-ext/core/interruptRunListener.mjs +106 -0
- package/src-ext/core/mcpRuntimeFanout.mjs +236 -0
- package/src-ext/core/mcpStdioClient.mjs +316 -0
- package/src-ext/core/openclawGatewayClient.mjs +230 -0
- package/src-ext/core/pairCodeClient.mjs +331 -0
- package/src-ext/core/pairInventory.mjs +59 -0
- package/src-ext/core/pathExpand.mjs +34 -0
- package/src-ext/core/preprocessAttachments.mjs +113 -0
- package/src-ext/core/relayWorker.mjs +284 -0
- package/src-ext/core/relayWorkerHttp.mjs +58 -0
- package/src-ext/core/scanWorkspaces.mjs +842 -0
- package/src-ext/core/sessionSlot.mjs +70 -0
- package/src-ext/core/stateMachine.mjs +85 -0
- package/src-ext/core/toolsServerLifecycle.mjs +169 -0
- package/src-ext/core/unifiedDispatchHandler.mjs +439 -0
- package/src-ext/core/usageReporter.mjs +207 -0
- package/src-ext/openclaw-plugin/envelope-builder.cjs +619 -0
- package/src-ext/openclaw-plugin/index.cjs +1919 -0
- package/src-ext/openclaw-plugin/openclaw.plugin.json +20 -0
- package/src-ext/openclaw-plugin/package.json +12 -0
- package/src-ext/openclaw-plugin/relay-defaults.json +3 -0
- package/src-ext/openclaw-plugin/todoTranslatorUtils.cjs +101 -0
- package/src-ext/runtime/_shared/cliExec.mjs +138 -0
- package/src-ext/runtime/_shared/cosTextRefs.mjs +120 -0
- package/src-ext/runtime/_shared/fetchAndBase64Encode.mjs +52 -0
- package/src-ext/runtime/_shared/relayObjectToBlock.mjs +307 -0
- package/src-ext/runtime/_shared/sessionPicker.mjs +418 -0
- package/src-ext/runtime/_shared/slashCommandRouter.mjs +1346 -0
- package/src-ext/runtime/_shared/todoTranslatorUtils.mjs +94 -0
- package/src-ext/runtime/claude/askUserQuestionTranslator.mjs +241 -0
- package/src-ext/runtime/claude/customCommandsCollector.mjs +132 -0
- package/src-ext/runtime/claude/handleRequest.mjs +1418 -0
- package/src-ext/runtime/claude/index.mjs +160 -0
- package/src-ext/runtime/claude/internalCommandNames.mjs +58 -0
- package/src-ext/runtime/claude/launcher.mjs +313 -0
- package/src-ext/runtime/claude/mcpConfigAdapter.mjs +174 -0
- package/src-ext/runtime/claude/mcpConfigWriter.mjs +57 -0
- package/src-ext/runtime/claude/modelContextWindow.mjs +30 -0
- package/src-ext/runtime/claude/modelScanner.mjs +257 -0
- package/src-ext/runtime/claude/permissionInterruptBlock.mjs +86 -0
- package/src-ext/runtime/claude/permissionPromptServer.mjs +170 -0
- package/src-ext/runtime/claude/permissionPromptServerWorker.mjs +214 -0
- package/src-ext/runtime/claude/preflight.mjs +62 -0
- package/src-ext/runtime/claude/stdoutParser.mjs +365 -0
- package/src-ext/runtime/claude/todoTranslator.mjs +66 -0
- package/src-ext/runtime/claude/usageProbe.mjs +576 -0
- package/src-ext/runtime/claude/usageProbeStrip.mjs +14 -0
- package/src-ext/runtime/codex/codexCustomCommandsCollector.mjs +87 -0
- package/src-ext/runtime/codex/elicitationHandler.mjs +91 -0
- package/src-ext/runtime/codex/eventMapper.mjs +154 -0
- package/src-ext/runtime/codex/handleRequest.mjs +1152 -0
- package/src-ext/runtime/codex/index.mjs +142 -0
- package/src-ext/runtime/codex/mcpConfigAdapter.mjs +155 -0
- package/src-ext/runtime/codex/modelScanner.mjs +305 -0
- package/src-ext/runtime/codex/planNotificationHandler.mjs +61 -0
- package/src-ext/runtime/codex/preflight.mjs +67 -0
- package/src-ext/runtime/hermes/commandBackend.mjs +117 -0
- package/src-ext/runtime/hermes/envSetup.mjs +229 -0
- package/src-ext/runtime/hermes/gatewayManager.mjs +625 -0
- package/src-ext/runtime/hermes/handleRequest.mjs +1257 -0
- package/src-ext/runtime/hermes/httpBackend.mjs +219 -0
- package/src-ext/runtime/hermes/index.mjs +196 -0
- package/src-ext/runtime/hermes/mcpConfigAdapter.mjs +189 -0
- package/src-ext/runtime/hermes/preflight.mjs +359 -0
- package/src-ext/runtime/hermes/responsesBackend.mjs +276 -0
- package/src-ext/runtime/hermes/sessionStore.mjs +78 -0
- package/src-ext/runtime/hermes/todoTranslator.mjs +90 -0
- package/src-ext/runtime/hermes/workspaceRpc.mjs +124 -0
- package/src-ext/runtime/openclaw/buildOpenclawDaemonInput.mjs +143 -0
- package/src-ext/runtime/openclaw/mcpConfigAdapter.mjs +91 -0
- package/src-ext/runtime/openclaw/openclawConfig.mjs +145 -0
- package/src-ext/runtime/picoclaw/constants.mjs +39 -0
- package/src-ext/runtime/picoclaw/handleRequest.mjs +289 -0
- package/src-ext/runtime/picoclaw/index.mjs +314 -0
- package/src-ext/runtime/picoclaw/pairFlow.mjs +224 -0
- package/src-ext/runtime/picoclaw/state.mjs +78 -0
- package/src-ext/runtime/picoclaw/todoTranslator.mjs +67 -0
- package/src-ext/runtime/picoclaw/translator.mjs +272 -0
- package/src-ext/runtime/picoclaw/wsClient.mjs +129 -0
- package/src-ext/service/serviceManager.mjs +521 -0
- package/src-shared/README.md +44 -0
- package/src-shared/chat_push_normalizer.mjs +113 -0
- package/src-shared/correlation.mjs +25 -0
- package/src-shared/envelope_builder.mjs +612 -0
- package/src-shared/logger.mjs +167 -0
- package/src-shared/relay-defaults.json +3 -0
- package/src-shared/relay_revoke.mjs +46 -0
- package/src-shared/vendor/README.md +60 -0
- package/src-shared/vendor/smol-toml/LICENSE +24 -0
- package/src-shared/vendor/smol-toml/README.md +236 -0
- package/src-shared/vendor/smol-toml/VENDORED.md +37 -0
- package/src-shared/vendor/smol-toml/dist/date.d.ts +41 -0
- package/src-shared/vendor/smol-toml/dist/date.js +127 -0
- package/src-shared/vendor/smol-toml/dist/error.d.ts +38 -0
- package/src-shared/vendor/smol-toml/dist/error.js +63 -0
- package/src-shared/vendor/smol-toml/dist/extract.d.ts +30 -0
- package/src-shared/vendor/smol-toml/dist/extract.js +109 -0
- package/src-shared/vendor/smol-toml/dist/index.cjs +900 -0
- package/src-shared/vendor/smol-toml/dist/index.d.ts +43 -0
- package/src-shared/vendor/smol-toml/dist/index.js +33 -0
- package/src-shared/vendor/smol-toml/dist/parse.d.ts +37 -0
- package/src-shared/vendor/smol-toml/dist/parse.js +148 -0
- package/src-shared/vendor/smol-toml/dist/primitive.d.ts +31 -0
- package/src-shared/vendor/smol-toml/dist/primitive.js +178 -0
- package/src-shared/vendor/smol-toml/dist/stringify.d.ts +31 -0
- package/src-shared/vendor/smol-toml/dist/stringify.js +163 -0
- package/src-shared/vendor/smol-toml/dist/struct.d.ts +32 -0
- package/src-shared/vendor/smol-toml/dist/struct.js +194 -0
- package/src-shared/vendor/smol-toml/dist/util.d.ts +42 -0
- package/src-shared/vendor/smol-toml/dist/util.js +100 -0
- package/src-shared/vendor/smol-toml/package.json +54 -0
- package/src-shared/vendor/yaml/LICENSE +13 -0
- package/src-shared/vendor/yaml/README.md +155 -0
- package/src-shared/vendor/yaml/VENDORED.md +29 -0
- package/src-shared/vendor/yaml/bin.mjs +11 -0
- package/src-shared/vendor/yaml/browser/dist/compose/compose-collection.js +88 -0
- package/src-shared/vendor/yaml/browser/dist/compose/compose-doc.js +42 -0
- package/src-shared/vendor/yaml/browser/dist/compose/compose-node.js +92 -0
- package/src-shared/vendor/yaml/browser/dist/compose/compose-scalar.js +80 -0
- package/src-shared/vendor/yaml/browser/dist/compose/composer.js +217 -0
- package/src-shared/vendor/yaml/browser/dist/compose/resolve-block-map.js +113 -0
- package/src-shared/vendor/yaml/browser/dist/compose/resolve-block-scalar.js +198 -0
- package/src-shared/vendor/yaml/browser/dist/compose/resolve-block-seq.js +47 -0
- package/src-shared/vendor/yaml/browser/dist/compose/resolve-end.js +37 -0
- package/src-shared/vendor/yaml/browser/dist/compose/resolve-flow-collection.js +203 -0
- package/src-shared/vendor/yaml/browser/dist/compose/resolve-flow-scalar.js +223 -0
- package/src-shared/vendor/yaml/browser/dist/compose/resolve-props.js +148 -0
- package/src-shared/vendor/yaml/browser/dist/compose/util-contains-newline.js +34 -0
- package/src-shared/vendor/yaml/browser/dist/compose/util-empty-scalar-position.js +27 -0
- package/src-shared/vendor/yaml/browser/dist/compose/util-flow-indent-check.js +15 -0
- package/src-shared/vendor/yaml/browser/dist/compose/util-map-includes.js +17 -0
- package/src-shared/vendor/yaml/browser/dist/doc/Document.js +334 -0
- package/src-shared/vendor/yaml/browser/dist/doc/anchors.js +72 -0
- package/src-shared/vendor/yaml/browser/dist/doc/applyReviver.js +55 -0
- package/src-shared/vendor/yaml/browser/dist/doc/createNode.js +89 -0
- package/src-shared/vendor/yaml/browser/dist/doc/directives.js +176 -0
- package/src-shared/vendor/yaml/browser/dist/errors.js +57 -0
- package/src-shared/vendor/yaml/browser/dist/index.js +17 -0
- package/src-shared/vendor/yaml/browser/dist/log.js +14 -0
- package/src-shared/vendor/yaml/browser/dist/nodes/Alias.js +101 -0
- package/src-shared/vendor/yaml/browser/dist/nodes/Collection.js +147 -0
- package/src-shared/vendor/yaml/browser/dist/nodes/Node.js +38 -0
- package/src-shared/vendor/yaml/browser/dist/nodes/Pair.js +36 -0
- package/src-shared/vendor/yaml/browser/dist/nodes/Scalar.js +24 -0
- package/src-shared/vendor/yaml/browser/dist/nodes/YAMLMap.js +144 -0
- package/src-shared/vendor/yaml/browser/dist/nodes/YAMLSeq.js +113 -0
- package/src-shared/vendor/yaml/browser/dist/nodes/addPairToJSMap.js +104 -0
- package/src-shared/vendor/yaml/browser/dist/nodes/identity.js +36 -0
- package/src-shared/vendor/yaml/browser/dist/nodes/toJS.js +37 -0
- package/src-shared/vendor/yaml/browser/dist/parse/cst-scalar.js +214 -0
- package/src-shared/vendor/yaml/browser/dist/parse/cst-stringify.js +61 -0
- package/src-shared/vendor/yaml/browser/dist/parse/cst-visit.js +97 -0
- package/src-shared/vendor/yaml/browser/dist/parse/cst.js +98 -0
- package/src-shared/vendor/yaml/browser/dist/parse/lexer.js +717 -0
- package/src-shared/vendor/yaml/browser/dist/parse/line-counter.js +39 -0
- package/src-shared/vendor/yaml/browser/dist/parse/parser.js +954 -0
- package/src-shared/vendor/yaml/browser/dist/public-api.js +99 -0
- package/src-shared/vendor/yaml/browser/dist/schema/Schema.js +38 -0
- package/src-shared/vendor/yaml/browser/dist/schema/common/map.js +17 -0
- package/src-shared/vendor/yaml/browser/dist/schema/common/null.js +15 -0
- package/src-shared/vendor/yaml/browser/dist/schema/common/seq.js +17 -0
- package/src-shared/vendor/yaml/browser/dist/schema/common/string.js +14 -0
- package/src-shared/vendor/yaml/browser/dist/schema/core/bool.js +19 -0
- package/src-shared/vendor/yaml/browser/dist/schema/core/float.js +43 -0
- package/src-shared/vendor/yaml/browser/dist/schema/core/int.js +38 -0
- package/src-shared/vendor/yaml/browser/dist/schema/core/schema.js +23 -0
- package/src-shared/vendor/yaml/browser/dist/schema/json/schema.js +62 -0
- package/src-shared/vendor/yaml/browser/dist/schema/tags.js +83 -0
- package/src-shared/vendor/yaml/browser/dist/schema/yaml-1.1/binary.js +66 -0
- package/src-shared/vendor/yaml/browser/dist/schema/yaml-1.1/bool.js +26 -0
- package/src-shared/vendor/yaml/browser/dist/schema/yaml-1.1/float.js +46 -0
- package/src-shared/vendor/yaml/browser/dist/schema/yaml-1.1/int.js +71 -0
- package/src-shared/vendor/yaml/browser/dist/schema/yaml-1.1/omap.js +74 -0
- package/src-shared/vendor/yaml/browser/dist/schema/yaml-1.1/pairs.js +78 -0
- package/src-shared/vendor/yaml/browser/dist/schema/yaml-1.1/schema.js +37 -0
- package/src-shared/vendor/yaml/browser/dist/schema/yaml-1.1/set.js +93 -0
- package/src-shared/vendor/yaml/browser/dist/schema/yaml-1.1/timestamp.js +101 -0
- package/src-shared/vendor/yaml/browser/dist/stringify/foldFlowLines.js +146 -0
- package/src-shared/vendor/yaml/browser/dist/stringify/stringify.js +124 -0
- package/src-shared/vendor/yaml/browser/dist/stringify/stringifyCollection.js +143 -0
- package/src-shared/vendor/yaml/browser/dist/stringify/stringifyComment.js +20 -0
- package/src-shared/vendor/yaml/browser/dist/stringify/stringifyDocument.js +85 -0
- package/src-shared/vendor/yaml/browser/dist/stringify/stringifyNumber.js +24 -0
- package/src-shared/vendor/yaml/browser/dist/stringify/stringifyPair.js +150 -0
- package/src-shared/vendor/yaml/browser/dist/stringify/stringifyString.js +328 -0
- package/src-shared/vendor/yaml/browser/dist/util.js +11 -0
- package/src-shared/vendor/yaml/browser/dist/visit.js +233 -0
- package/src-shared/vendor/yaml/browser/index.js +5 -0
- package/src-shared/vendor/yaml/browser/package.json +3 -0
- package/src-shared/vendor/yaml/dist/cli.d.ts +8 -0
- package/src-shared/vendor/yaml/dist/cli.mjs +199 -0
- package/src-shared/vendor/yaml/dist/compose/compose-collection.d.ts +11 -0
- package/src-shared/vendor/yaml/dist/compose/compose-collection.js +90 -0
- package/src-shared/vendor/yaml/dist/compose/compose-doc.d.ts +7 -0
- package/src-shared/vendor/yaml/dist/compose/compose-doc.js +44 -0
- package/src-shared/vendor/yaml/dist/compose/compose-node.d.ts +28 -0
- package/src-shared/vendor/yaml/dist/compose/compose-node.js +95 -0
- package/src-shared/vendor/yaml/dist/compose/compose-scalar.d.ts +5 -0
- package/src-shared/vendor/yaml/dist/compose/compose-scalar.js +82 -0
- package/src-shared/vendor/yaml/dist/compose/composer.d.ts +62 -0
- package/src-shared/vendor/yaml/dist/compose/composer.js +221 -0
- package/src-shared/vendor/yaml/dist/compose/resolve-block-map.d.ts +6 -0
- package/src-shared/vendor/yaml/dist/compose/resolve-block-map.js +115 -0
- package/src-shared/vendor/yaml/dist/compose/resolve-block-scalar.d.ts +11 -0
- package/src-shared/vendor/yaml/dist/compose/resolve-block-scalar.js +200 -0
- package/src-shared/vendor/yaml/dist/compose/resolve-block-seq.d.ts +6 -0
- package/src-shared/vendor/yaml/dist/compose/resolve-block-seq.js +49 -0
- package/src-shared/vendor/yaml/dist/compose/resolve-end.d.ts +6 -0
- package/src-shared/vendor/yaml/dist/compose/resolve-end.js +39 -0
- package/src-shared/vendor/yaml/dist/compose/resolve-flow-collection.d.ts +7 -0
- package/src-shared/vendor/yaml/dist/compose/resolve-flow-collection.js +205 -0
- package/src-shared/vendor/yaml/dist/compose/resolve-flow-scalar.d.ts +10 -0
- package/src-shared/vendor/yaml/dist/compose/resolve-flow-scalar.js +225 -0
- package/src-shared/vendor/yaml/dist/compose/resolve-props.d.ts +23 -0
- package/src-shared/vendor/yaml/dist/compose/resolve-props.js +150 -0
- package/src-shared/vendor/yaml/dist/compose/util-contains-newline.d.ts +2 -0
- package/src-shared/vendor/yaml/dist/compose/util-contains-newline.js +36 -0
- package/src-shared/vendor/yaml/dist/compose/util-empty-scalar-position.d.ts +2 -0
- package/src-shared/vendor/yaml/dist/compose/util-empty-scalar-position.js +29 -0
- package/src-shared/vendor/yaml/dist/compose/util-flow-indent-check.d.ts +3 -0
- package/src-shared/vendor/yaml/dist/compose/util-flow-indent-check.js +17 -0
- package/src-shared/vendor/yaml/dist/compose/util-map-includes.d.ts +4 -0
- package/src-shared/vendor/yaml/dist/compose/util-map-includes.js +19 -0
- package/src-shared/vendor/yaml/dist/doc/Document.d.ts +141 -0
- package/src-shared/vendor/yaml/dist/doc/Document.js +336 -0
- package/src-shared/vendor/yaml/dist/doc/anchors.d.ts +24 -0
- package/src-shared/vendor/yaml/dist/doc/anchors.js +77 -0
- package/src-shared/vendor/yaml/dist/doc/applyReviver.d.ts +9 -0
- package/src-shared/vendor/yaml/dist/doc/applyReviver.js +57 -0
- package/src-shared/vendor/yaml/dist/doc/createNode.d.ts +17 -0
- package/src-shared/vendor/yaml/dist/doc/createNode.js +91 -0
- package/src-shared/vendor/yaml/dist/doc/directives.d.ts +49 -0
- package/src-shared/vendor/yaml/dist/doc/directives.js +178 -0
- package/src-shared/vendor/yaml/dist/errors.d.ts +21 -0
- package/src-shared/vendor/yaml/dist/errors.js +62 -0
- package/src-shared/vendor/yaml/dist/index.d.ts +22 -0
- package/src-shared/vendor/yaml/dist/index.js +50 -0
- package/src-shared/vendor/yaml/dist/log.d.ts +3 -0
- package/src-shared/vendor/yaml/dist/log.js +17 -0
- package/src-shared/vendor/yaml/dist/nodes/Alias.d.ts +28 -0
- package/src-shared/vendor/yaml/dist/nodes/Alias.js +103 -0
- package/src-shared/vendor/yaml/dist/nodes/Collection.d.ts +73 -0
- package/src-shared/vendor/yaml/dist/nodes/Collection.js +151 -0
- package/src-shared/vendor/yaml/dist/nodes/Node.d.ts +47 -0
- package/src-shared/vendor/yaml/dist/nodes/Node.js +40 -0
- package/src-shared/vendor/yaml/dist/nodes/Pair.d.ts +21 -0
- package/src-shared/vendor/yaml/dist/nodes/Pair.js +39 -0
- package/src-shared/vendor/yaml/dist/nodes/Scalar.d.ts +42 -0
- package/src-shared/vendor/yaml/dist/nodes/Scalar.js +27 -0
- package/src-shared/vendor/yaml/dist/nodes/YAMLMap.d.ts +53 -0
- package/src-shared/vendor/yaml/dist/nodes/YAMLMap.js +147 -0
- package/src-shared/vendor/yaml/dist/nodes/YAMLSeq.d.ts +60 -0
- package/src-shared/vendor/yaml/dist/nodes/YAMLSeq.js +115 -0
- package/src-shared/vendor/yaml/dist/nodes/addPairToJSMap.d.ts +4 -0
- package/src-shared/vendor/yaml/dist/nodes/addPairToJSMap.js +106 -0
- package/src-shared/vendor/yaml/dist/nodes/identity.d.ts +23 -0
- package/src-shared/vendor/yaml/dist/nodes/identity.js +53 -0
- package/src-shared/vendor/yaml/dist/nodes/toJS.d.ts +27 -0
- package/src-shared/vendor/yaml/dist/nodes/toJS.js +39 -0
- package/src-shared/vendor/yaml/dist/options.d.ts +338 -0
- package/src-shared/vendor/yaml/dist/parse/cst-scalar.d.ts +64 -0
- package/src-shared/vendor/yaml/dist/parse/cst-scalar.js +218 -0
- package/src-shared/vendor/yaml/dist/parse/cst-stringify.d.ts +8 -0
- package/src-shared/vendor/yaml/dist/parse/cst-stringify.js +63 -0
- package/src-shared/vendor/yaml/dist/parse/cst-visit.d.ts +39 -0
- package/src-shared/vendor/yaml/dist/parse/cst-visit.js +99 -0
- package/src-shared/vendor/yaml/dist/parse/cst.d.ts +108 -0
- package/src-shared/vendor/yaml/dist/parse/cst.js +112 -0
- package/src-shared/vendor/yaml/dist/parse/lexer.d.ts +87 -0
- package/src-shared/vendor/yaml/dist/parse/lexer.js +719 -0
- package/src-shared/vendor/yaml/dist/parse/line-counter.d.ts +22 -0
- package/src-shared/vendor/yaml/dist/parse/line-counter.js +41 -0
- package/src-shared/vendor/yaml/dist/parse/parser.d.ts +84 -0
- package/src-shared/vendor/yaml/dist/parse/parser.js +958 -0
- package/src-shared/vendor/yaml/dist/public-api.d.ts +43 -0
- package/src-shared/vendor/yaml/dist/public-api.js +104 -0
- package/src-shared/vendor/yaml/dist/schema/Schema.d.ts +18 -0
- package/src-shared/vendor/yaml/dist/schema/Schema.js +40 -0
- package/src-shared/vendor/yaml/dist/schema/common/map.d.ts +2 -0
- package/src-shared/vendor/yaml/dist/schema/common/map.js +19 -0
- package/src-shared/vendor/yaml/dist/schema/common/null.d.ts +4 -0
- package/src-shared/vendor/yaml/dist/schema/common/null.js +17 -0
- package/src-shared/vendor/yaml/dist/schema/common/seq.d.ts +2 -0
- package/src-shared/vendor/yaml/dist/schema/common/seq.js +19 -0
- package/src-shared/vendor/yaml/dist/schema/common/string.d.ts +2 -0
- package/src-shared/vendor/yaml/dist/schema/common/string.js +16 -0
- package/src-shared/vendor/yaml/dist/schema/core/bool.d.ts +4 -0
- package/src-shared/vendor/yaml/dist/schema/core/bool.js +21 -0
- package/src-shared/vendor/yaml/dist/schema/core/float.d.ts +4 -0
- package/src-shared/vendor/yaml/dist/schema/core/float.js +47 -0
- package/src-shared/vendor/yaml/dist/schema/core/int.d.ts +4 -0
- package/src-shared/vendor/yaml/dist/schema/core/int.js +42 -0
- package/src-shared/vendor/yaml/dist/schema/core/schema.d.ts +1 -0
- package/src-shared/vendor/yaml/dist/schema/core/schema.js +25 -0
- package/src-shared/vendor/yaml/dist/schema/json/schema.d.ts +2 -0
- package/src-shared/vendor/yaml/dist/schema/json/schema.js +64 -0
- package/src-shared/vendor/yaml/dist/schema/json-schema.d.ts +69 -0
- package/src-shared/vendor/yaml/dist/schema/tags.d.ts +40 -0
- package/src-shared/vendor/yaml/dist/schema/tags.js +86 -0
- package/src-shared/vendor/yaml/dist/schema/types.d.ts +90 -0
- package/src-shared/vendor/yaml/dist/schema/yaml-1.1/binary.d.ts +2 -0
- package/src-shared/vendor/yaml/dist/schema/yaml-1.1/binary.js +68 -0
- package/src-shared/vendor/yaml/dist/schema/yaml-1.1/bool.d.ts +7 -0
- package/src-shared/vendor/yaml/dist/schema/yaml-1.1/bool.js +29 -0
- package/src-shared/vendor/yaml/dist/schema/yaml-1.1/float.d.ts +4 -0
- package/src-shared/vendor/yaml/dist/schema/yaml-1.1/float.js +50 -0
- package/src-shared/vendor/yaml/dist/schema/yaml-1.1/int.d.ts +5 -0
- package/src-shared/vendor/yaml/dist/schema/yaml-1.1/int.js +76 -0
- package/src-shared/vendor/yaml/dist/schema/yaml-1.1/omap.d.ts +28 -0
- package/src-shared/vendor/yaml/dist/schema/yaml-1.1/omap.js +77 -0
- package/src-shared/vendor/yaml/dist/schema/yaml-1.1/pairs.d.ts +10 -0
- package/src-shared/vendor/yaml/dist/schema/yaml-1.1/pairs.js +82 -0
- package/src-shared/vendor/yaml/dist/schema/yaml-1.1/schema.d.ts +1 -0
- package/src-shared/vendor/yaml/dist/schema/yaml-1.1/schema.js +39 -0
- package/src-shared/vendor/yaml/dist/schema/yaml-1.1/set.d.ts +28 -0
- package/src-shared/vendor/yaml/dist/schema/yaml-1.1/set.js +96 -0
- package/src-shared/vendor/yaml/dist/schema/yaml-1.1/timestamp.d.ts +6 -0
- package/src-shared/vendor/yaml/dist/schema/yaml-1.1/timestamp.js +105 -0
- package/src-shared/vendor/yaml/dist/stringify/foldFlowLines.d.ts +34 -0
- package/src-shared/vendor/yaml/dist/stringify/foldFlowLines.js +151 -0
- package/src-shared/vendor/yaml/dist/stringify/stringify.d.ts +21 -0
- package/src-shared/vendor/yaml/dist/stringify/stringify.js +127 -0
- package/src-shared/vendor/yaml/dist/stringify/stringifyCollection.d.ts +17 -0
- package/src-shared/vendor/yaml/dist/stringify/stringifyCollection.js +145 -0
- package/src-shared/vendor/yaml/dist/stringify/stringifyComment.d.ts +10 -0
- package/src-shared/vendor/yaml/dist/stringify/stringifyComment.js +24 -0
- package/src-shared/vendor/yaml/dist/stringify/stringifyDocument.d.ts +4 -0
- package/src-shared/vendor/yaml/dist/stringify/stringifyDocument.js +87 -0
- package/src-shared/vendor/yaml/dist/stringify/stringifyNumber.d.ts +2 -0
- package/src-shared/vendor/yaml/dist/stringify/stringifyNumber.js +26 -0
- package/src-shared/vendor/yaml/dist/stringify/stringifyPair.d.ts +3 -0
- package/src-shared/vendor/yaml/dist/stringify/stringifyPair.js +152 -0
- package/src-shared/vendor/yaml/dist/stringify/stringifyString.d.ts +9 -0
- package/src-shared/vendor/yaml/dist/stringify/stringifyString.js +330 -0
- package/src-shared/vendor/yaml/dist/test-events.d.ts +4 -0
- package/src-shared/vendor/yaml/dist/test-events.js +134 -0
- package/src-shared/vendor/yaml/dist/util.d.ts +12 -0
- package/src-shared/vendor/yaml/dist/util.js +28 -0
- package/src-shared/vendor/yaml/dist/visit.d.ts +102 -0
- package/src-shared/vendor/yaml/dist/visit.js +236 -0
- package/src-shared/vendor/yaml/package.json +96 -0
- package/src-shared/vendor/yaml/util.js +2 -0
|
@@ -0,0 +1,1919 @@
|
|
|
1
|
+
// Agentlink OpenClaw hooks plugin.
|
|
2
|
+
//
|
|
3
|
+
// Uses api.on() (typed hook registration) to capture tool call, subagent and
|
|
4
|
+
// agent_end lifecycle events, then POSTs them as **unified Agent Protocol
|
|
5
|
+
// envelopes** to relay /v1/agents/threads/<thread_id>/events.
|
|
6
|
+
//
|
|
7
|
+
// (Pre-2026-05 this plugin posted to the legacy /v1/gateways/<gw>/chat-events
|
|
8
|
+
// endpoint, which has been retired server-side; see PLUGIN_ACTIVE_RUNS.md.)
|
|
9
|
+
//
|
|
10
|
+
// Loaded by OpenClaw via `openclaw plugins install --link <plugin-dir>` which
|
|
11
|
+
// is wrapped by `agentlink pair` / `agentlink setup` / `ensureAgentlinkHooksPlugin`.
|
|
12
|
+
//
|
|
13
|
+
// Credential source: ~/.agentlink/config.json (written by `agentlink pair`).
|
|
14
|
+
//
|
|
15
|
+
// Verbose dump: set `agentlinkHooksVerbose: true` in the same config file
|
|
16
|
+
// to dump every hook event to OpenClaw log. Auto-expires after 24h
|
|
17
|
+
// (set `agentlinkHooksVerboseUntil: <unix-ms>` to override).
|
|
18
|
+
//
|
|
19
|
+
// Hardening (vs. the first cut):
|
|
20
|
+
// • fetch() has a 5s AbortController timeout — no more dangling promises.
|
|
21
|
+
// • Failed POSTs are throttled to ≤1 warn / 60s — silent before, blind ops.
|
|
22
|
+
// • Large payloads are truncated to ~256KB — server LimitReader is 2MB.
|
|
23
|
+
// • This plugin always uses OpenClaw credentials (relayGatewayId / Token)
|
|
24
|
+
// regardless of state.runtimeKind: it loads inside the OpenClaw daemon,
|
|
25
|
+
// so hooks only fire when OpenClaw is the active runtime — sending to
|
|
26
|
+
// hermes credentials would be incorrect.
|
|
27
|
+
// • Per-callId serial queue keeps before/after order under network jitter.
|
|
28
|
+
// • Verbose dumps redact obvious secrets (Bearer ..., sk-..., AKIA*).
|
|
29
|
+
// • On 401 we re-read config.json once (token may have been rotated by
|
|
30
|
+
// `agentlink pair` while OpenClaw is still up); reset retry guard on success.
|
|
31
|
+
|
|
32
|
+
"use strict";
|
|
33
|
+
|
|
34
|
+
const fs = require("node:fs");
|
|
35
|
+
const path = require("node:path");
|
|
36
|
+
const os = require("node:os");
|
|
37
|
+
const crypto = require("node:crypto");
|
|
38
|
+
|
|
39
|
+
// Auto-generated by scripts/build-openclaw-plugin.mjs. Wrapped in try/catch so a
|
|
40
|
+
// missing build artifact degrades gracefully instead of crashing the daemon.
|
|
41
|
+
let envelopeBuilder = null;
|
|
42
|
+
let envelopeBuilderLoadError = null;
|
|
43
|
+
try {
|
|
44
|
+
envelopeBuilder = require("./envelope-builder.cjs");
|
|
45
|
+
} catch (err) {
|
|
46
|
+
envelopeBuilderLoadError = err;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// todoTranslatorUtils.cjs is generated alongside envelope-builder.cjs by the
|
|
50
|
+
// same build script. Required for update_plan → todo_list block translation.
|
|
51
|
+
// Degrades gracefully: if the artifact is missing, update_plan falls through
|
|
52
|
+
// to the normal tool_use path (no crash).
|
|
53
|
+
let todoUtils = null;
|
|
54
|
+
try {
|
|
55
|
+
todoUtils = require("./todoTranslatorUtils.cjs");
|
|
56
|
+
} catch {
|
|
57
|
+
// Build artifact missing — update_plan will transparently fall through.
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// relay-defaults is copied alongside envelope-builder by the same build script
|
|
61
|
+
// (was previously a forbidden cross-dir require("../../src-shared/...")).
|
|
62
|
+
let DEFAULT_RELAY_URL = "";
|
|
63
|
+
try {
|
|
64
|
+
({ defaultRelayUrl: DEFAULT_RELAY_URL } = require("./relay-defaults.json"));
|
|
65
|
+
} catch {
|
|
66
|
+
// Plugin will warn at register() time if defaults are missing.
|
|
67
|
+
}
|
|
68
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
69
|
+
const PAYLOAD_TRUNCATE_BYTES = 256 * 1024; // 256 KB per payload
|
|
70
|
+
const WARN_THROTTLE_MS = 60_000; // ≤ 1 warn / minute
|
|
71
|
+
const VERBOSE_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
72
|
+
const SECRET_PATTERNS = [
|
|
73
|
+
/Bearer\s+[A-Za-z0-9._\-]+/g,
|
|
74
|
+
/\bsk-[A-Za-z0-9_-]{16,}/g,
|
|
75
|
+
/\bAKIA[0-9A-Z]{12,}/g,
|
|
76
|
+
/\b[A-Za-z0-9]{20,}\.[A-Za-z0-9]{20,}\.[A-Za-z0-9_-]{20,}/g, // JWT-shaped
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
function configPath() {
|
|
80
|
+
return path.join(os.homedir(), ".agentlink", "config.json");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readAgentlinkState() {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(fs.readFileSync(configPath(), "utf8"));
|
|
86
|
+
} catch {
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function pickGatewayCreds(state) {
|
|
92
|
+
// Always OpenClaw credentials — this plugin only sees events that fire
|
|
93
|
+
// inside the OpenClaw daemon, regardless of which runtime the user picked
|
|
94
|
+
// for their App-side conversation.
|
|
95
|
+
return {
|
|
96
|
+
gatewayId: String(state?.relayGatewayId || "").trim(),
|
|
97
|
+
gatewayToken: String(state?.relayGatewayToken || "").trim(),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function resolveRelayUrl(state) {
|
|
102
|
+
const fromState = typeof state?.relayUrl === "string" ? state.relayUrl.trim() : "";
|
|
103
|
+
return fromState || DEFAULT_RELAY_URL;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isVerboseEnabled(state) {
|
|
107
|
+
if (state?.agentlinkHooksVerbose !== true) return false;
|
|
108
|
+
// Optional explicit expiry; default to 24h after the bridge process started
|
|
109
|
+
// (effectively turning off after a daemon restart > 24h with the flag still
|
|
110
|
+
// on but never refreshed). The conservative choice: respect the flag while
|
|
111
|
+
// process is up — users debugging want it sticky during their session.
|
|
112
|
+
const until = Number(state?.agentlinkHooksVerboseUntil);
|
|
113
|
+
if (Number.isFinite(until) && until > 0) {
|
|
114
|
+
return Date.now() < until;
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function redactSecrets(text) {
|
|
120
|
+
let out = text;
|
|
121
|
+
for (const re of SECRET_PATTERNS) {
|
|
122
|
+
out = out.replace(re, "[REDACTED]");
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Truncate any field whose JSON-encoded form exceeds the byte budget.
|
|
128
|
+
// Preserves shape: replaces big string with prefix + tag, big object with
|
|
129
|
+
// {_truncated, _size, _preview}.
|
|
130
|
+
function truncatePayload(value, budget = PAYLOAD_TRUNCATE_BYTES) {
|
|
131
|
+
try {
|
|
132
|
+
const encoded = JSON.stringify(value);
|
|
133
|
+
if (encoded == null || Buffer.byteLength(encoded, "utf8") <= budget) {
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
const preview = encoded.slice(0, Math.max(1024, Math.floor(budget / 4)));
|
|
137
|
+
return {
|
|
138
|
+
_truncated: true,
|
|
139
|
+
_originalSize: Buffer.byteLength(encoded, "utf8"),
|
|
140
|
+
_preview: preview,
|
|
141
|
+
};
|
|
142
|
+
} catch {
|
|
143
|
+
return { _truncated: true, _serializeFailed: true };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function hostHash() {
|
|
148
|
+
try {
|
|
149
|
+
return crypto.createHash("sha256")
|
|
150
|
+
.update(os.hostname() + ":" + os.userInfo().username)
|
|
151
|
+
.digest("hex").slice(0, 12);
|
|
152
|
+
} catch {
|
|
153
|
+
return "";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Auto-attach: 扫 assistant 文本里的本机路径 → 调 agentlink_attach_file ──
|
|
158
|
+
//
|
|
159
|
+
// 跑在 OpenClaw daemon 进程,从 ~/.agentlink/tools-server.json 读 bridge 起的
|
|
160
|
+
// tools server URL+auth,POST /tools/call 走 attach_file 工具。bridge 没在跑
|
|
161
|
+
// (discovery 缺失) → 安静跳过,等 bridge 起来后下一轮自然生效。
|
|
162
|
+
//
|
|
163
|
+
// dedup: 模块级 Map<absPath, mtimeMs>; mtime 变了重传 (自然支持 agent 改文件)。
|
|
164
|
+
//
|
|
165
|
+
// 与 ESM 侧 bridgeAttachAutoScanner.mjs 的正则保持一致 (人工同步)。
|
|
166
|
+
const AUTO_ATTACH_PATH_REGEX =
|
|
167
|
+
/(?<![A-Za-z0-9_.+\-/~])(?:file:\/\/)?(?:~|\/)(?:\/?[A-Za-z0-9_.+\-]+)+/g;
|
|
168
|
+
|
|
169
|
+
const _autoAttachDedup = new Map(); // absPath → mtimeMs
|
|
170
|
+
const _autoAttachDiscoveryPath = path.join(os.homedir(), ".agentlink", "tools-server.json");
|
|
171
|
+
|
|
172
|
+
function extractLocalPathsFromText(text) {
|
|
173
|
+
const out = new Set();
|
|
174
|
+
if (!text || typeof text !== "string") return out;
|
|
175
|
+
AUTO_ATTACH_PATH_REGEX.lastIndex = 0;
|
|
176
|
+
let m;
|
|
177
|
+
while ((m = AUTO_ATTACH_PATH_REGEX.exec(text)) !== null) {
|
|
178
|
+
let raw = m[0];
|
|
179
|
+
if (raw.startsWith("file://")) raw = raw.slice(7);
|
|
180
|
+
while (raw.length > 1 && /[.,;:!?)\]}"'<>\/]$/.test(raw)) raw = raw.slice(0, -1);
|
|
181
|
+
if (!raw || raw === "~") continue;
|
|
182
|
+
const lastSlash = raw.lastIndexOf("/");
|
|
183
|
+
const basename = lastSlash >= 0 ? raw.slice(lastSlash + 1) : raw;
|
|
184
|
+
if (!basename.includes(".") || basename === "." || basename === "..") continue;
|
|
185
|
+
if (raw.startsWith("~/")) raw = path.join(os.homedir(), raw.slice(2));
|
|
186
|
+
if (!raw.startsWith("/")) continue;
|
|
187
|
+
try { out.add(path.resolve(raw)); } catch { /* swallow */ }
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function readAutoAttachDiscoverySync() {
|
|
193
|
+
try {
|
|
194
|
+
const raw = fs.readFileSync(_autoAttachDiscoveryPath, "utf8");
|
|
195
|
+
const doc = JSON.parse(raw);
|
|
196
|
+
if (typeof doc?.url === "string" && typeof doc?.auth === "string") return doc;
|
|
197
|
+
} catch { /* missing or malformed → null */ }
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// fire-and-forget;caller 永远 void this。所有 path err 内部吞掉只 log。
|
|
202
|
+
async function autoAttachScanAndDispatch(assistantText, { log, warnThrottled }) {
|
|
203
|
+
const candidates = extractLocalPathsFromText(assistantText);
|
|
204
|
+
if (candidates.size === 0) return;
|
|
205
|
+
log.info(`auto-attach.scan.matched count=${candidates.size} paths=${JSON.stringify([...candidates])}`);
|
|
206
|
+
const disc = readAutoAttachDiscoverySync();
|
|
207
|
+
if (!disc) {
|
|
208
|
+
log.info(`auto-attach.skip: tools-server discovery missing (bridge not running?) candidates=${[...candidates].join(",")}`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
for (const rawAbsPath of candidates) {
|
|
212
|
+
let absPath = rawAbsPath;
|
|
213
|
+
let st;
|
|
214
|
+
try {
|
|
215
|
+
st = fs.statSync(absPath);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
if (err && err.code === "ENOENT") {
|
|
218
|
+
// ★ workspace fallback: agent 经常把路径写错(`~/hello.txt`)而实际文件
|
|
219
|
+
// 在 `~/.openclaw/workspace/hello.txt`。这里取 basename 去 workspace
|
|
220
|
+
// 目录里找,命中就用真实路径继续。这对 agent 行为容错很关键。
|
|
221
|
+
const resolved = resolveInOpenClawWorkspace(absPath);
|
|
222
|
+
if (resolved) {
|
|
223
|
+
try {
|
|
224
|
+
st = fs.statSync(resolved);
|
|
225
|
+
absPath = resolved;
|
|
226
|
+
log.info(`auto-attach.workspace_fallback hit path=${rawAbsPath} resolved=${resolved}`);
|
|
227
|
+
} catch {
|
|
228
|
+
log.info(`auto-attach.skip.not_found path=${rawAbsPath} (workspace fallback also missing: ${resolved})`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
log.info(`auto-attach.skip.not_found path=${rawAbsPath}`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
log.info(`auto-attach.skip.stat_failed path=${absPath} err=${err?.code || err?.message}`);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (!st.isFile()) {
|
|
241
|
+
log.info(`auto-attach.skip.not_file path=${absPath}`);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const prev = _autoAttachDedup.get(absPath);
|
|
245
|
+
if (prev === st.mtimeMs) {
|
|
246
|
+
log.info(`auto-attach.skip.unchanged path=${absPath} mtimeMs=${st.mtimeMs}`);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
_autoAttachDedup.set(absPath, st.mtimeMs);
|
|
250
|
+
log.info(`auto-attach.start path=${absPath} size=${st.size}`);
|
|
251
|
+
|
|
252
|
+
// ── 预测式 sidecar 写入 ─────────────────────────────────────────
|
|
253
|
+
// 真正 PUT 完成前, 用 prefix cache + 本地 sha256 算 publicUrl, 立刻写
|
|
254
|
+
// sidecar。这样 App 第一次 fetch workspace file 时就有 publicUrl,
|
|
255
|
+
// 即便上传还在进行中。如果 PUT 还没完, App fetch COS URL 拿到 404,
|
|
256
|
+
// App 端 loadText 会 fallback 到 inline content/loadWorkspaceFile。
|
|
257
|
+
// 上传完成后, real publicUrl 会再次覆写 sidecar(同一对象 URL,无害)。
|
|
258
|
+
try {
|
|
259
|
+
const prefix = readCosPrefixCacheSync();
|
|
260
|
+
if (prefix) {
|
|
261
|
+
const buf = fs.readFileSync(absPath);
|
|
262
|
+
const sha256 = crypto.createHash("sha256").update(buf).digest("hex");
|
|
263
|
+
const ext = inferExtForCos(absPath);
|
|
264
|
+
const predictedUrl = `${prefix}/${sha256}${ext ? "." + ext : ""}`;
|
|
265
|
+
writeWorkspaceAttachSidecar({
|
|
266
|
+
absPath,
|
|
267
|
+
publicUrl: predictedUrl,
|
|
268
|
+
sha256,
|
|
269
|
+
mtimeMs: st.mtimeMs,
|
|
270
|
+
size: st.size,
|
|
271
|
+
});
|
|
272
|
+
log.info(`auto-attach.sidecar.predicted path=${absPath} url=${predictedUrl}`);
|
|
273
|
+
} else {
|
|
274
|
+
log.debug(`auto-attach.sidecar.predict.skip: no prefix cache yet (waiting for first upload)`);
|
|
275
|
+
}
|
|
276
|
+
} catch (err) {
|
|
277
|
+
log.warn(`auto-attach.sidecar.predict.failed path=${absPath} err=${err?.message || err}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let res, body;
|
|
281
|
+
const ctl = new AbortController();
|
|
282
|
+
const tid = setTimeout(() => ctl.abort(), 30_000);
|
|
283
|
+
try {
|
|
284
|
+
res = await fetch(`${disc.url.replace(/\/+$/, "")}/tools/call`, {
|
|
285
|
+
method: "POST",
|
|
286
|
+
headers: {
|
|
287
|
+
"Content-Type": "application/json",
|
|
288
|
+
Authorization: `Bearer ${disc.auth}`,
|
|
289
|
+
},
|
|
290
|
+
body: JSON.stringify({
|
|
291
|
+
name: "agentlink_attach_file",
|
|
292
|
+
arguments: { path: absPath, reason: "auto-scan" },
|
|
293
|
+
}),
|
|
294
|
+
signal: ctl.signal,
|
|
295
|
+
});
|
|
296
|
+
body = await res.json();
|
|
297
|
+
} catch (err) {
|
|
298
|
+
_autoAttachDedup.delete(absPath); // 允许下次重试
|
|
299
|
+
warnThrottled(`auto-attach.fetch.threw path=${absPath} err=${err?.message || err}`);
|
|
300
|
+
continue;
|
|
301
|
+
} finally {
|
|
302
|
+
clearTimeout(tid);
|
|
303
|
+
}
|
|
304
|
+
// ⚠ 注意:此处用**完整** text parse JSON,不能 slice。result 字符串通常
|
|
305
|
+
// 250+ 字符(URL 130 + sha256 64 + 其它),slice(200) 会在 url 中间截断
|
|
306
|
+
// 导致 JSON.parse 抛 "Unterminated string"。log 那条另用 truncated summary。
|
|
307
|
+
const resultText = String(body?.content?.[0]?.text || "");
|
|
308
|
+
const logSummary = resultText.slice(0, 400);
|
|
309
|
+
if (body?.isError) {
|
|
310
|
+
_autoAttachDedup.delete(absPath); // 工具拒了 → 允许重试 (e.g. denylist 后续改)
|
|
311
|
+
log.warn(`auto-attach.failed path=${absPath} reason=${logSummary}`);
|
|
312
|
+
} else {
|
|
313
|
+
log.info(`auto-attach.ok path=${absPath} result=${logSummary}`);
|
|
314
|
+
// 把 publicUrl / sha256 / mtime 写到 sidecar 文件让 server 端能 inject
|
|
315
|
+
// 进 workspace file DTO。sidecar 位于同目录 .agentlink-cos/<basename>.json,
|
|
316
|
+
// 命中后 App 拿到的 ChatWorkspaceFileMeta 就会带 publicUrl, preview 走 COS。
|
|
317
|
+
try {
|
|
318
|
+
const parsed = JSON.parse(resultText);
|
|
319
|
+
if (parsed?.ok && parsed?.url) {
|
|
320
|
+
writeWorkspaceAttachSidecar({
|
|
321
|
+
absPath,
|
|
322
|
+
publicUrl: String(parsed.url),
|
|
323
|
+
sha256: String(parsed.sha256 || ""),
|
|
324
|
+
mtimeMs: st.mtimeMs,
|
|
325
|
+
size: st.size,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
} catch (err) {
|
|
329
|
+
log.warn(`auto-attach.sidecar.failed path=${absPath} err=${err?.message || err} raw=${resultText.slice(0, 120)}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 读 cosUploadClient 学习并 cache 的 publicUrl prefix(到 userID 一级,不含
|
|
336
|
+
// trailing /)。plugin 拿它本地拼 sha256+ext 预测 publicUrl,在真传完成前就把
|
|
337
|
+
// sidecar 写好。CJS 不能 import ESM,直接读 ESM 那边的 cache 文件即可——格式
|
|
338
|
+
// 一致(见 cosUploadClient.mjs writeCosPrefixCache)。
|
|
339
|
+
const COS_PREFIX_CACHE_PATH = path.join(os.homedir(), ".agentlink", "cos-prefix-cache.json");
|
|
340
|
+
|
|
341
|
+
// Workspace fallback: agent 经常把绝对路径写错(典型场景 `~/hello.txt` 但实
|
|
342
|
+
// 际文件在 `~/.openclaw/workspace/hello.txt`)。当 regex 抓到的 path
|
|
343
|
+
// fs.stat 抛 ENOENT 时, 用 basename 去 OpenClaw workspace 目录里再找一次。
|
|
344
|
+
// workspace 目录: OPENCLAW_HOME/workspace (默认 ~/.openclaw/workspace)。
|
|
345
|
+
//
|
|
346
|
+
// 返回的是 workspace 内的真实绝对路径(如果同 basename 存在), 否则 null。
|
|
347
|
+
function resolveInOpenClawWorkspace(rawPath) {
|
|
348
|
+
try {
|
|
349
|
+
const base = path.basename(rawPath);
|
|
350
|
+
if (!base || base === "." || base === ".." || base.includes("/")) return null;
|
|
351
|
+
const openclawHome = (process.env.OPENCLAW_HOME || path.join(os.homedir(), ".openclaw")).trim();
|
|
352
|
+
if (!openclawHome) return null;
|
|
353
|
+
const candidate = path.join(openclawHome, "workspace", base);
|
|
354
|
+
// 防 path traversal:确保 candidate 真在 workspace 子树里
|
|
355
|
+
const workspaceRoot = path.join(openclawHome, "workspace");
|
|
356
|
+
const resolved = path.resolve(candidate);
|
|
357
|
+
if (!resolved.startsWith(workspaceRoot + path.sep) && resolved !== workspaceRoot) {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
return resolved;
|
|
361
|
+
} catch { return null; }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function readCosPrefixCacheSync() {
|
|
365
|
+
try {
|
|
366
|
+
const raw = fs.readFileSync(COS_PREFIX_CACHE_PATH, "utf8");
|
|
367
|
+
const parsed = JSON.parse(raw);
|
|
368
|
+
const p = typeof parsed?.prefix === "string" ? parsed.prefix.trim() : "";
|
|
369
|
+
if (!p || !/^https?:\/\//.test(p)) return null;
|
|
370
|
+
return p.replace(/\/+$/, "");
|
|
371
|
+
} catch { return null; }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// 跟 cosUploadClient.mjs / EXT_TO_MIME 对应的扩展名探测(简化版)。无扩展返回
|
|
375
|
+
// 空串,与 server buildSHA256ObjectKey 行为对齐(无 ext = leaf 不带点)。
|
|
376
|
+
function inferExtForCos(absPath) {
|
|
377
|
+
const base = path.basename(absPath);
|
|
378
|
+
const dot = base.lastIndexOf(".");
|
|
379
|
+
if (dot <= 0 || dot === base.length - 1) return "";
|
|
380
|
+
return base.slice(dot + 1).toLowerCase();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// 把 attach 结果写到 sidecar JSON 文件: <dir>/.agentlink-cos/<basename>.json。
|
|
384
|
+
// server (go_relay) 在 fetchAgentWorkspaceFile 时读这个文件,把 publicUrl 注入 DTO。
|
|
385
|
+
// 同步写,失败只 swallow——丢一次 sidecar = 这次 App 不走 COS, 下次自然重试。
|
|
386
|
+
function writeWorkspaceAttachSidecar({ absPath, publicUrl, sha256, mtimeMs, size }) {
|
|
387
|
+
const dir = path.dirname(absPath);
|
|
388
|
+
const base = path.basename(absPath);
|
|
389
|
+
const sidecarDir = path.join(dir, ".agentlink-cos");
|
|
390
|
+
const sidecarPath = path.join(sidecarDir, base + ".json");
|
|
391
|
+
try {
|
|
392
|
+
fs.mkdirSync(sidecarDir, { recursive: true });
|
|
393
|
+
fs.writeFileSync(
|
|
394
|
+
sidecarPath,
|
|
395
|
+
JSON.stringify({ publicUrl, sha256, mtimeMs, size, ts: Date.now() }),
|
|
396
|
+
{ mode: 0o600 },
|
|
397
|
+
);
|
|
398
|
+
} catch {
|
|
399
|
+
// best-effort; 下次 attach 再写
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Bridge-managed run-context lookup. CLI bridge writes
|
|
404
|
+
// `~/.agentlink/active-runs/<runId>.json` at dispatch entry; plugin reads it
|
|
405
|
+
// on every hook so it can POST envelopes to the matching thread_id.
|
|
406
|
+
//
|
|
407
|
+
// Fallback by key: OpenClaw exposes the original session key (often the
|
|
408
|
+
// unified thread_id) under both `ctx.sessionKey` and `ctx.runId` for
|
|
409
|
+
// different dispatch flows; we try matching files for both keys before
|
|
410
|
+
// giving up.
|
|
411
|
+
//
|
|
412
|
+
// Any failure (missing file, parse error, expired) returns null silently —
|
|
413
|
+
// the caller drops that hook with no log spam.
|
|
414
|
+
function loadRunContextFile(key) {
|
|
415
|
+
if (!key) return null;
|
|
416
|
+
try {
|
|
417
|
+
const dir = path.join(os.homedir(), ".agentlink", "active-runs");
|
|
418
|
+
const file = path.join(dir, `${key}.json`);
|
|
419
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
420
|
+
const ctx = JSON.parse(raw);
|
|
421
|
+
if (typeof ctx?.expires_at === "number" && ctx.expires_at < Date.now()) {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
if (!ctx?.thread_id) return null;
|
|
425
|
+
return ctx;
|
|
426
|
+
} catch {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Resolve the unified thread_id + run_id + relay_url for the current hook ctx.
|
|
432
|
+
//
|
|
433
|
+
// CLI bridge writes `~/.agentlink/active-runs/<key>.json` under **two** keys
|
|
434
|
+
// — thread_id AND run_id — pointing to the same JSON body. The OpenClaw
|
|
435
|
+
// dispatch enqueue sets `SessionKey = run_id` on the relay request, so
|
|
436
|
+
// `ctx.sessionKey` in plugin hooks is `run_<ULID>` (NOT thread_id), but
|
|
437
|
+
// some dispatch flows may set sessionKey to thread_id directly. We try
|
|
438
|
+
// each candidate from ctx in priority order and the first hit wins.
|
|
439
|
+
//
|
|
440
|
+
// Returns { thread_id, run_id, relay_url, isFallback } or null.
|
|
441
|
+
//
|
|
442
|
+
// isFallback:true 表示 active-runs/<key>.json 已经不存在(bridge 把那一轮
|
|
443
|
+
// dispatch cleanup 了),但能从某个 ctx 字段里还原出 thr_<ULID> thread_id。
|
|
444
|
+
// 这往往意味着这次 hook fire 是 OpenClaw daemon 在主 dispatch 结束后**自发**
|
|
445
|
+
// 触发的(subagent 完成后 daemon 内部投递 task-completion user message + 自起
|
|
446
|
+
// 新一轮 LLM 调用,daemon UI 通过 WS 收到,但 bridge 那条 /v1/responses
|
|
447
|
+
// SSE 已 EOF 不再监听)。调用方应据此把这一轮 reply 也补发到 relay。
|
|
448
|
+
//
|
|
449
|
+
// ctx.sessionKey 在 daemon 自发轮里有两种已知形态:
|
|
450
|
+
// 1. 主链路: 干净 "thr_01KRK..." (ULID 全大写)
|
|
451
|
+
// 2. 自发轮: "agent:main:thr_01krk..." (带 agent 前缀,且 daemon **小写化**了 ULID)
|
|
452
|
+
// 必须同时支持两种:从字符串里**抽取** thr_<26 chars> 子串、并按 active-runs/
|
|
453
|
+
// 文件实际命名 (server-issued ULID 原样,即大写) 还原。
|
|
454
|
+
const THREAD_ID_PATTERN = /thr_[0-9A-Za-z]{26}/;
|
|
455
|
+
function extractThreadId(s) {
|
|
456
|
+
if (!s) return "";
|
|
457
|
+
const m = THREAD_ID_PATTERN.exec(s);
|
|
458
|
+
return m ? m[0] : "";
|
|
459
|
+
}
|
|
460
|
+
// thread_id 在 server-issued 时是 "thr_" 小写前缀 + 26 字符 Crockford Base32
|
|
461
|
+
// **大写** ULID。daemon 在某些场景 (例如 ctx.sessionKey = "agent:main:thr_xxx")
|
|
462
|
+
// 把整个串小写化,需要还原 ULID 部分到大写而保持前缀小写。
|
|
463
|
+
function canonicalThreadId(threadId) {
|
|
464
|
+
if (!threadId) return "";
|
|
465
|
+
if (threadId.startsWith("thr_")) return "thr_" + threadId.slice(4).toUpperCase();
|
|
466
|
+
if (threadId.startsWith("THR_")) return "thr_" + threadId.slice(4).toUpperCase();
|
|
467
|
+
return threadId;
|
|
468
|
+
}
|
|
469
|
+
// active-runs 文件名是 server-issued ULID 原样 (Crockford Base32,全大写);
|
|
470
|
+
// daemon 给 plugin 的 ctx.sessionKey 里 thr_ 子串可能被小写化,这里两种大小写
|
|
471
|
+
// 都试一遍,谁命中就用谁。
|
|
472
|
+
function loadRunContextByThreadId(threadId) {
|
|
473
|
+
if (!threadId) return null;
|
|
474
|
+
return loadRunContextFile(threadId)
|
|
475
|
+
|| loadRunContextFile(canonicalThreadId(threadId))
|
|
476
|
+
|| loadRunContextFile(threadId.toLowerCase());
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function resolveHookContext(ctx, fallbackRelayUrl) {
|
|
480
|
+
const candidates = [
|
|
481
|
+
String(ctx?.sessionKey || "").trim(),
|
|
482
|
+
String(ctx?.runId || "").trim(),
|
|
483
|
+
String(ctx?.sessionId || "").trim(),
|
|
484
|
+
].filter(Boolean);
|
|
485
|
+
|
|
486
|
+
// 第一遍:把任意 candidate 当 key 直接试 — 主链路 ctx.sessionKey 就是
|
|
487
|
+
// 干净 thr_xxx 时这条命中。
|
|
488
|
+
for (const key of candidates) {
|
|
489
|
+
const file = loadRunContextFile(key);
|
|
490
|
+
if (file && file.thread_id) {
|
|
491
|
+
return {
|
|
492
|
+
thread_id: file.thread_id,
|
|
493
|
+
run_id: file.run_id || ctx?.runId || "",
|
|
494
|
+
relay_url: file.relay_url || fallbackRelayUrl,
|
|
495
|
+
isFallback: false,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// 第二遍:从任意 candidate 里抽出 thr_<ULID> 子串再试 — 兼容 daemon 自发轮
|
|
500
|
+
// ctx.sessionKey = "agent:main:thr_xxx" 这种带前缀形态。命中 file 表示
|
|
501
|
+
// dispatch 还没 cleanup,仍走主链路。
|
|
502
|
+
for (const key of candidates) {
|
|
503
|
+
const tid = extractThreadId(key);
|
|
504
|
+
if (!tid) continue;
|
|
505
|
+
const file = loadRunContextByThreadId(tid);
|
|
506
|
+
if (file && file.thread_id) {
|
|
507
|
+
return {
|
|
508
|
+
thread_id: file.thread_id,
|
|
509
|
+
run_id: file.run_id || ctx?.runId || "",
|
|
510
|
+
relay_url: file.relay_url || fallbackRelayUrl,
|
|
511
|
+
isFallback: false,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// 第三遍 (fallback): file 都不在了,但能从 candidate 里抽到 thr_<ULID> 子串
|
|
516
|
+
// → 这就是「daemon 自发轮」信号,caller 应把这一轮 reply 也补发到 relay。
|
|
517
|
+
// thread_id 用大写形式 (与 server-issued ULID 一致;daemon 内部小写只是输出
|
|
518
|
+
// 习惯),否则 server 校验 envelope.thread_id 会失败。
|
|
519
|
+
for (const key of candidates) {
|
|
520
|
+
const tid = extractThreadId(key);
|
|
521
|
+
if (tid) {
|
|
522
|
+
return {
|
|
523
|
+
thread_id: canonicalThreadId(tid),
|
|
524
|
+
run_id: ctx?.runId || "",
|
|
525
|
+
relay_url: fallbackRelayUrl,
|
|
526
|
+
isFallback: true,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Deterministic child run_id derivation: subagent_spawning + subagent_ended
|
|
534
|
+
// for the same childSessionKey must produce the same run_id so the server-side
|
|
535
|
+
// ingester groups them under one logical sub-run.
|
|
536
|
+
//
|
|
537
|
+
// Server schema requires `run_<26 Crockford Base32 chars>` (envelope.schema
|
|
538
|
+
// `run_id` pattern `^run_[0-9A-HJKMNP-TV-Z]{26}$`). We seed the encoding with
|
|
539
|
+
// sha1 of the childSessionKey to keep determinism, then transcode the top
|
|
540
|
+
// 16 bytes into 26 Crockford chars (same algorithm as src-shared
|
|
541
|
+
// envelope_builder.mjs `newBlockId`). Excluded letters: I L O U.
|
|
542
|
+
const _CROCKFORD_CHARS = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
543
|
+
|
|
544
|
+
function deriveSubRunId(childSessionKey) {
|
|
545
|
+
if (!childSessionKey) return null;
|
|
546
|
+
const hashBytes = crypto.createHash("sha1").update(String(childSessionKey)).digest();
|
|
547
|
+
// Pack first 16 bytes (128 bits) into a BigInt then pull 5-bit groups
|
|
548
|
+
// from the MSB; 128/5 = 25.6 → 26 chars (top 2 bits zero).
|
|
549
|
+
let n = BigInt(0);
|
|
550
|
+
for (let i = 0; i < 16; i++) n = (n << BigInt(8)) | BigInt(hashBytes[i]);
|
|
551
|
+
const chars = new Array(26);
|
|
552
|
+
for (let i = 25; i >= 0; i--) {
|
|
553
|
+
chars[i] = _CROCKFORD_CHARS[Number(n & BigInt(31))];
|
|
554
|
+
n >>= BigInt(5);
|
|
555
|
+
}
|
|
556
|
+
return "run_" + chars.join("");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function register(api) {
|
|
560
|
+
// Plugin runs inside OpenClaw daemon — any console.* writes pollute the
|
|
561
|
+
// host's structured log stream. Always use api.logger. When api.logger is
|
|
562
|
+
// unavailable (should be impossible per OpenClaw contract but defensive)
|
|
563
|
+
// silently drop log entries; track a counter and write **one** stderr line
|
|
564
|
+
// at beforeExit so we don't lose all diagnostic signal.
|
|
565
|
+
let suppressedLogs = 0;
|
|
566
|
+
function logInfo(m) { if (api.logger?.info) api.logger.info(`[agentlink-hooks] ${m}`); else suppressedLogs += 1; }
|
|
567
|
+
function logWarn(m) { if (api.logger?.warn) api.logger.warn(`[agentlink-hooks] ${m}`); else suppressedLogs += 1; }
|
|
568
|
+
function logDebug(m) { if (api.logger?.debug) api.logger.debug(`[agentlink-hooks] ${m}`); }
|
|
569
|
+
process.on("beforeExit", () => {
|
|
570
|
+
if (suppressedLogs > 0) {
|
|
571
|
+
try { process.stderr.write(`[agentlink-hooks] ${suppressedLogs} logs suppressed: api.logger unavailable\n`); } catch {}
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
const log = { info: logInfo, warn: logWarn, debug: logDebug };
|
|
575
|
+
|
|
576
|
+
if (envelopeBuilderLoadError || !envelopeBuilder) {
|
|
577
|
+
log.warn(
|
|
578
|
+
`envelope-builder missing — run \`npm run build:plugin\`. ` +
|
|
579
|
+
`Tool events disabled. ${envelopeBuilderLoadError ? envelopeBuilderLoadError.message : ""}`,
|
|
580
|
+
);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (typeof api.on !== "function") {
|
|
585
|
+
log.warn("api.on unavailable — typed hooks not registered. Tool events disabled.");
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Defense-in-depth env probe (debug-only; OpenClaw daemon may legitimately
|
|
590
|
+
// configure its own global dispatcher — we just record it for later forensics).
|
|
591
|
+
log.debug(
|
|
592
|
+
`env probe: undiciDispatcher=${!!globalThis[Symbol.for("undici.globalDispatcher.1")]} pid=${process.pid}`,
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
// ── Mutable runtime state (re-readable on 401) ─────────────────────────────
|
|
596
|
+
|
|
597
|
+
let state = readAgentlinkState();
|
|
598
|
+
let relayUrl = resolveRelayUrl(state);
|
|
599
|
+
let { gatewayId, gatewayToken } = pickGatewayCreds(state);
|
|
600
|
+
let verbose = isVerboseEnabled(state);
|
|
601
|
+
const HOST = hostHash();
|
|
602
|
+
|
|
603
|
+
function reloadConfig(reason) {
|
|
604
|
+
try {
|
|
605
|
+
const nextState = readAgentlinkState();
|
|
606
|
+
const nextCreds = pickGatewayCreds(nextState);
|
|
607
|
+
const nextRelay = resolveRelayUrl(nextState);
|
|
608
|
+
const changed =
|
|
609
|
+
nextCreds.gatewayId !== gatewayId
|
|
610
|
+
|| nextCreds.gatewayToken !== gatewayToken
|
|
611
|
+
|| nextRelay !== relayUrl;
|
|
612
|
+
if (changed) {
|
|
613
|
+
log.info(`reloaded creds (${reason}): relay=${nextRelay} gateway=${nextCreds.gatewayId.slice(0, 8)}…`);
|
|
614
|
+
}
|
|
615
|
+
state = nextState;
|
|
616
|
+
gatewayId = nextCreds.gatewayId;
|
|
617
|
+
gatewayToken = nextCreds.gatewayToken;
|
|
618
|
+
relayUrl = nextRelay;
|
|
619
|
+
verbose = isVerboseEnabled(state);
|
|
620
|
+
return changed;
|
|
621
|
+
} catch {
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (!gatewayId || !gatewayToken) {
|
|
627
|
+
log.warn("missing relay credentials — tool events disabled. Run `agentlink pair` to configure.");
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
log.info(`registered. relay=${relayUrl} gateway=${gatewayId.slice(0, 8)}… host=${HOST} verbose=${verbose}`);
|
|
632
|
+
|
|
633
|
+
// ── Throttled warning so we surface failure without spamming the log ─────
|
|
634
|
+
|
|
635
|
+
let lastWarnAt = 0;
|
|
636
|
+
let warnsSuppressed = 0;
|
|
637
|
+
function warnThrottled(msg) {
|
|
638
|
+
const now = Date.now();
|
|
639
|
+
if (now - lastWarnAt < WARN_THROTTLE_MS) {
|
|
640
|
+
warnsSuppressed += 1;
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (warnsSuppressed > 0) {
|
|
644
|
+
log.warn(`(${warnsSuppressed} similar warning(s) suppressed) ${msg}`);
|
|
645
|
+
warnsSuppressed = 0;
|
|
646
|
+
} else {
|
|
647
|
+
log.warn(msg);
|
|
648
|
+
}
|
|
649
|
+
lastWarnAt = now;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ── POST queue (per-callId serial; cross-callId concurrent) ───────────────
|
|
653
|
+
// Guarantees that a single tool's started → completed envelopes arrive in
|
|
654
|
+
// order even if HTTP latency varies. Different toolCallIds run concurrently.
|
|
655
|
+
|
|
656
|
+
const queues = new Map(); // callId → Promise<void>
|
|
657
|
+
let credReloadInflight = false; // single-flight guard for 401 retry
|
|
658
|
+
|
|
659
|
+
async function postOnce(url, body) {
|
|
660
|
+
const ctrl = new AbortController();
|
|
661
|
+
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
662
|
+
try {
|
|
663
|
+
return await fetch(url, {
|
|
664
|
+
method: "POST",
|
|
665
|
+
headers: {
|
|
666
|
+
"content-type": "application/json",
|
|
667
|
+
"x-agent-bridge-token": gatewayToken,
|
|
668
|
+
},
|
|
669
|
+
body,
|
|
670
|
+
signal: ctrl.signal,
|
|
671
|
+
});
|
|
672
|
+
} finally {
|
|
673
|
+
clearTimeout(timer);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Enrich plugin-produced envelope with dispatch-context required fields
|
|
678
|
+
// (thread_id / role / source). Mirrors bridge's enrichEnvelope in
|
|
679
|
+
// bin/agentlink — the server envelope schema requires these at the top
|
|
680
|
+
// level for every event. role/source are inferred by type.
|
|
681
|
+
function enrichEnvelope(env, threadId) {
|
|
682
|
+
const type = String(env?.type || "");
|
|
683
|
+
const isSystem = type === "heartbeat" || type === "error";
|
|
684
|
+
return {
|
|
685
|
+
thread_id: threadId,
|
|
686
|
+
role: isSystem ? "system" : "assistant",
|
|
687
|
+
source: isSystem ? "relay" : "runtime",
|
|
688
|
+
...env,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ── bridge-auto-tunnel hook chain (CJS → ESM dynamic import) ──────────────
|
|
693
|
+
// Plugin 与 bridge 是两条互不相交的 envelope POST 通道:
|
|
694
|
+
// - bridge (bin/agentlink): dispatch SSE → translate → POST
|
|
695
|
+
// - plugin (本文件): daemon hook → POST (尤其是 autonomous follow-up
|
|
696
|
+
// 自发轮,bridge 的 SSE 这条路压根收不到内容)
|
|
697
|
+
// 之前 auto-tunnel URL 改写只挂在 bridge 那条链上,plugin 这条直接 POST 给
|
|
698
|
+
// relay → App 永远看到原 localhost URL。这里把 autoTunnelGateway 也接入
|
|
699
|
+
// plugin postEnvelopes,与 bridge 共享同一份 detect/start tunnel/replace 逻辑。
|
|
700
|
+
//
|
|
701
|
+
// 失败语义:any error → pass-through (envelopes 原样 POST),不阻断主链路。
|
|
702
|
+
let _autoTunnelHooksMod = null;
|
|
703
|
+
let _autoTunnelHooksTried = false;
|
|
704
|
+
async function _ensureAutoTunnelHooks(effectiveRelay) {
|
|
705
|
+
if (_autoTunnelHooksMod) return _autoTunnelHooksMod;
|
|
706
|
+
if (_autoTunnelHooksTried) return _autoTunnelHooksMod;
|
|
707
|
+
_autoTunnelHooksTried = true;
|
|
708
|
+
try {
|
|
709
|
+
const path = require("node:path");
|
|
710
|
+
const { pathToFileURL } = require("node:url");
|
|
711
|
+
// 本文件在 npm_cli/src-ext/openclaw-plugin/index.cjs
|
|
712
|
+
// autoTunnelWiring / bridgeHooks 在 npm_cli/src-ext/core/
|
|
713
|
+
const wiringPath = path.resolve(__dirname, "..", "core", "autoTunnelWiring.mjs");
|
|
714
|
+
const hooksPath = path.resolve(__dirname, "..", "core", "bridgeHooks.mjs");
|
|
715
|
+
const { installAutoTunnel } = await import(pathToFileURL(wiringPath).href);
|
|
716
|
+
installAutoTunnel({ relayUrl: effectiveRelay, log: null });
|
|
717
|
+
_autoTunnelHooksMod = await import(pathToFileURL(hooksPath).href);
|
|
718
|
+
} catch (err) {
|
|
719
|
+
warnThrottled(`auto-tunnel hooks load failed (pass-through): ${err?.message || err}`);
|
|
720
|
+
}
|
|
721
|
+
return _autoTunnelHooksMod;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// POST a batch of unified envelopes to /v1/agents/threads/<tid>/events.
|
|
725
|
+
// hookCtx is the ctx object OpenClaw hands to typed hook handlers; we
|
|
726
|
+
// resolve thread_id + relay_url from it via resolveHookContext().
|
|
727
|
+
// Silently drops the batch if thread_id is not resolvable.
|
|
728
|
+
async function postEnvelopes(hookCtx, envelopes) {
|
|
729
|
+
if (!Array.isArray(envelopes) || envelopes.length === 0) return;
|
|
730
|
+
const rc = resolveHookContext(hookCtx, relayUrl);
|
|
731
|
+
if (!rc || !rc.thread_id) {
|
|
732
|
+
// No way to know which thread these envelopes belong to — drop silently.
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const effectiveRelay = rc.relay_url || relayUrl;
|
|
736
|
+
|
|
737
|
+
// bridge-auto-tunnel: 把 envelope 内 content_block.completed 的 loopback URL
|
|
738
|
+
// 改写为公网 tunnel URL。失败 pass-through。
|
|
739
|
+
let processedEnvelopes = envelopes;
|
|
740
|
+
try {
|
|
741
|
+
const hooks = await _ensureAutoTunnelHooks(effectiveRelay);
|
|
742
|
+
if (hooks && typeof hooks.applyOutgoingHooks === "function") {
|
|
743
|
+
const out = await hooks.applyOutgoingHooks(envelopes, {
|
|
744
|
+
thread_id: rc.thread_id,
|
|
745
|
+
});
|
|
746
|
+
if (Array.isArray(out) && out.length > 0) {
|
|
747
|
+
processedEnvelopes = out;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
} catch (err) {
|
|
751
|
+
warnThrottled(`auto-tunnel hook chain failed (pass-through): ${err?.message || err}`);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const url = `${effectiveRelay.replace(/\/+$/, "")}/v1/agents/threads/${encodeURIComponent(rc.thread_id)}/events`;
|
|
755
|
+
const enriched = processedEnvelopes.map((env) => enrichEnvelope(env, rc.thread_id));
|
|
756
|
+
const body = JSON.stringify({ events: enriched });
|
|
757
|
+
|
|
758
|
+
try {
|
|
759
|
+
const resp = await postOnce(url, body);
|
|
760
|
+
if (resp.ok) return;
|
|
761
|
+
|
|
762
|
+
// 401 = token rejected entirely. 404 = token valid but resolves to a
|
|
763
|
+
// bridge_user_id that doesn't own this thread — happens when CLI
|
|
764
|
+
// re-paired (new gateway/token) between plugin's last config load and
|
|
765
|
+
// this POST. Both signals trigger a one-shot config reload + retry.
|
|
766
|
+
if ((resp.status === 401 || resp.status === 404) && !credReloadInflight) {
|
|
767
|
+
credReloadInflight = true;
|
|
768
|
+
try {
|
|
769
|
+
const changed = reloadConfig(`${resp.status} from relay`);
|
|
770
|
+
if (changed) {
|
|
771
|
+
// Re-resolve thread_id too: the active-runs file might have been
|
|
772
|
+
// rewritten by bridge under a new key after re-pair.
|
|
773
|
+
const rc2 = resolveHookContext(hookCtx, relayUrl);
|
|
774
|
+
const newThreadId = rc2?.thread_id || rc.thread_id;
|
|
775
|
+
const newRelay = rc2?.relay_url || effectiveRelay;
|
|
776
|
+
const newUrl = `${newRelay.replace(/\/+$/, "")}/v1/agents/threads/${encodeURIComponent(newThreadId)}/events`;
|
|
777
|
+
// Re-enrich envelopes (already auto-tunnel-rewritten) with the
|
|
778
|
+
// new thread_id (if changed).
|
|
779
|
+
const reEnriched = JSON.stringify({
|
|
780
|
+
events: processedEnvelopes.map((env) => enrichEnvelope(env, newThreadId)),
|
|
781
|
+
});
|
|
782
|
+
const retry = await postOnce(newUrl, reEnriched);
|
|
783
|
+
if (retry.ok) return;
|
|
784
|
+
warnThrottled(`relay POST /events ${retry.status} after creds reload — token may have been reset; run \`agentlink pair\` again`);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
} finally { credReloadInflight = false; }
|
|
788
|
+
warnThrottled(`relay POST /events ${resp.status} — token rejected or thread ownership mismatch. Run \`agentlink pair <code>\` to reconfigure.`);
|
|
789
|
+
} else if (resp.status === 207) {
|
|
790
|
+
// partial accept: log the first rejection but don't retry
|
|
791
|
+
try {
|
|
792
|
+
const decoded = await resp.json();
|
|
793
|
+
const rejected = Array.isArray(decoded?.rejected) ? decoded.rejected : [];
|
|
794
|
+
if (rejected.length > 0) {
|
|
795
|
+
const first = rejected[0] || {};
|
|
796
|
+
warnThrottled(`relay POST /events 207 partial: ${rejected.length} rejected; first code=${first.code || "?"}`);
|
|
797
|
+
}
|
|
798
|
+
} catch {
|
|
799
|
+
warnThrottled(`relay POST /events 207 partial (response not JSON)`);
|
|
800
|
+
}
|
|
801
|
+
} else if (resp.status === 401 || resp.status === 404) {
|
|
802
|
+
// Another reload+retry is already in flight from a concurrent hook;
|
|
803
|
+
// attempting another would race / risk infinite recursion. Log
|
|
804
|
+
// generically so user knows this isn't a fresh failure mode.
|
|
805
|
+
warnThrottled(`relay POST /events ${resp.status} — auth/ownership; reload already inflight, this batch dropped`);
|
|
806
|
+
} else {
|
|
807
|
+
warnThrottled(`relay POST /events ${resp.status} — server rejected (${envelopes.length} envelope(s))`);
|
|
808
|
+
}
|
|
809
|
+
} catch (err) {
|
|
810
|
+
const msg = err && err.name === "AbortError"
|
|
811
|
+
? `relay POST /events timed out after ${FETCH_TIMEOUT_MS}ms (relay=${effectiveRelay})`
|
|
812
|
+
: `relay POST /events failed: ${err && err.message ? err.message : String(err)}`;
|
|
813
|
+
warnThrottled(msg);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function enqueuePost(callId, hookCtx, envelopes) {
|
|
818
|
+
const key = String(callId || `_run_${hookCtx?.runId || "anon"}`);
|
|
819
|
+
const prev = queues.get(key) || Promise.resolve();
|
|
820
|
+
const next = prev.then(
|
|
821
|
+
() => postEnvelopes(hookCtx, envelopes),
|
|
822
|
+
() => postEnvelopes(hookCtx, envelopes),
|
|
823
|
+
);
|
|
824
|
+
queues.set(key, next);
|
|
825
|
+
next.finally(() => {
|
|
826
|
+
if (queues.get(key) === next) queues.delete(key);
|
|
827
|
+
});
|
|
828
|
+
return next;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function safeOn(hookName, handler) {
|
|
832
|
+
api.on(hookName, (event, ctx) => {
|
|
833
|
+
try { handler(event, ctx); } catch (err) {
|
|
834
|
+
warnThrottled(`hook ${hookName} threw: ${err && err.message ? err.message : String(err)}`);
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function dump(name, event, ctx) {
|
|
840
|
+
if (!verbose) return;
|
|
841
|
+
try {
|
|
842
|
+
const evJson = redactSecrets(JSON.stringify(event));
|
|
843
|
+
const ctxJson = redactSecrets(JSON.stringify(ctx));
|
|
844
|
+
// Cap dump length so a single hook can't write a 10MB log line.
|
|
845
|
+
const cap = 16 * 1024;
|
|
846
|
+
const ev = evJson.length > cap ? evJson.slice(0, cap) + `…(+${evJson.length - cap}B)` : evJson;
|
|
847
|
+
const c = ctxJson.length > cap ? ctxJson.slice(0, cap) + `…(+${ctxJson.length - cap}B)` : ctxJson;
|
|
848
|
+
log.info(`hook=${name} event=${ev} ctx=${c}`);
|
|
849
|
+
} catch {
|
|
850
|
+
// event/ctx may contain circular refs; ignore
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// ── Method A2: subagent_ref lifecycle state ─────────────────────────────
|
|
855
|
+
// Three Maps implement the after_tool_call-driven topology for subagent_ref
|
|
856
|
+
// envelope emission. All maps are scoped to this register() invocation and
|
|
857
|
+
// do NOT leak outside (they share lifetime with the plugin registration).
|
|
858
|
+
//
|
|
859
|
+
// pendingSessionsSpawnByRun: FIFO queue mapping parentRunId -> callId[]
|
|
860
|
+
// before_tool_call(sessions_spawn) enqueues; after_tool_call dequeues.
|
|
861
|
+
// Used to verify FIFO ordering between buffer and result events.
|
|
862
|
+
//
|
|
863
|
+
// pendingToolUseBufferByCallId: tool_use envelope buffer, key=callId, value=envelope[]
|
|
864
|
+
// before_tool_call(sessions_spawn) stores the tool_use envelope here instead
|
|
865
|
+
// of immediately posting. after_tool_call flushes it AFTER subagent_ref.started
|
|
866
|
+
// + run.started, so relay sees the SDK signal (subagent_ref) before tool_use.
|
|
867
|
+
//
|
|
868
|
+
// subagentRefByChildKey: subagent_ref state table, key=childSessionKey
|
|
869
|
+
// Written by after_tool_call(sessions_spawn) when childKey is extracted.
|
|
870
|
+
// Read + deleted by agent_end(isFallback=true) to emit subagent_ref.completed.
|
|
871
|
+
// Purged for a parentRunId when that parent's agent_end(A) fires.
|
|
872
|
+
|
|
873
|
+
/** FIFO queue: runId -> callId[] */
|
|
874
|
+
const pendingSessionsSpawnByRun = new Map();
|
|
875
|
+
|
|
876
|
+
/** Buffer: callId -> RawEnvelope[] */
|
|
877
|
+
const pendingToolUseBufferByCallId = new Map();
|
|
878
|
+
|
|
879
|
+
/** State table: childSessionKey -> { refBlockId, callId, parentRunId, agent, subRunId } */
|
|
880
|
+
const subagentRefByChildKey = new Map();
|
|
881
|
+
|
|
882
|
+
// ── per-run content_block.index counter ────────────────────────────────
|
|
883
|
+
// Unified envelope spec uses an optional per-run monotonic block index.
|
|
884
|
+
// Plugin owns its own counter; server tolerates the index being absent
|
|
885
|
+
// (set on best-effort basis).
|
|
886
|
+
const blockIndexes = new Map(); // runId → number
|
|
887
|
+
function nextBlockIndex(runId) {
|
|
888
|
+
const cur = blockIndexes.get(runId) || 0;
|
|
889
|
+
blockIndexes.set(runId, cur + 1);
|
|
890
|
+
return cur;
|
|
891
|
+
}
|
|
892
|
+
function dropBlockIndex(runId) { blockIndexes.delete(runId); }
|
|
893
|
+
|
|
894
|
+
// ── toolCallId → blk_<ULID> mapping ────────────────────────────────────
|
|
895
|
+
// Server schema enforces block.id to be exactly `blk_<26 Crockford chars>`.
|
|
896
|
+
// OpenClaw's `toolCallId` is `call_<hex>` (different prefix + length), so
|
|
897
|
+
// we generate a fresh blk_ id at before_tool_call and reuse it at
|
|
898
|
+
// after_tool_call (both tool_use completion and the paired tool_result
|
|
899
|
+
// block need a stable, schema-valid id).
|
|
900
|
+
//
|
|
901
|
+
// Two-level memory: `toolCallBlockIds` per call_id + `runToolCalls` reverse
|
|
902
|
+
// index `runId → Set<callId>` so that if after_tool_call never fires (rare
|
|
903
|
+
// — daemon crash mid-tool, partial outage), agent_end can drain the whole
|
|
904
|
+
// run's stale entries at turn end. Without the reverse index, entries
|
|
905
|
+
// accumulate forever in long-lived daemons.
|
|
906
|
+
const toolCallBlockIds = new Map(); // toolCallId → { useBlkId, resultBlkId }
|
|
907
|
+
const runToolCalls = new Map(); // runId → Set<callId>
|
|
908
|
+
function getOrCreateToolBlockIds(callId) {
|
|
909
|
+
if (!callId) {
|
|
910
|
+
// No toolCallId from OpenClaw: still generate ids so envelopes pass
|
|
911
|
+
// schema, but pair will be transient (started + completed map by their
|
|
912
|
+
// own callIds in the queue, not by toolCallId map).
|
|
913
|
+
return { useBlkId: envelopeBuilder._newBlockId?.() || undefined, resultBlkId: undefined, transient: true };
|
|
914
|
+
}
|
|
915
|
+
let entry = toolCallBlockIds.get(callId);
|
|
916
|
+
if (!entry) {
|
|
917
|
+
// Generate blk_ ids via the same shape buildBlockStarted would use
|
|
918
|
+
// when block.id is absent — but we need to know the id eagerly so
|
|
919
|
+
// we can pass it to all 3 envelopes for this tool call.
|
|
920
|
+
// Call buildBlockStarted with no block.id and read it back.
|
|
921
|
+
const probe = envelopeBuilder.buildBlockStarted("_", { type: "tool_use" });
|
|
922
|
+
const probeResult = envelopeBuilder.buildBlockStarted("_", { type: "tool_result" });
|
|
923
|
+
entry = {
|
|
924
|
+
useBlkId: probe.payload.block.id,
|
|
925
|
+
resultBlkId: probeResult.payload.block.id,
|
|
926
|
+
};
|
|
927
|
+
toolCallBlockIds.set(callId, entry);
|
|
928
|
+
}
|
|
929
|
+
return entry;
|
|
930
|
+
}
|
|
931
|
+
function dropToolBlockIds(callId) { if (callId) toolCallBlockIds.delete(callId); }
|
|
932
|
+
function trackToolCallForRun(runId, callId) {
|
|
933
|
+
if (!runId || !callId) return;
|
|
934
|
+
let set = runToolCalls.get(runId);
|
|
935
|
+
if (!set) { set = new Set(); runToolCalls.set(runId, set); }
|
|
936
|
+
set.add(callId);
|
|
937
|
+
}
|
|
938
|
+
function dropAllToolCallsForRun(runId) {
|
|
939
|
+
const set = runToolCalls.get(runId);
|
|
940
|
+
if (!set) return;
|
|
941
|
+
for (const cid of set) toolCallBlockIds.delete(cid);
|
|
942
|
+
runToolCalls.delete(runId);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// ── Tool-call lifecycle → tool_use + tool_result content blocks ────────
|
|
946
|
+
// before_tool_call event: { toolName, params, runId?, toolCallId? }
|
|
947
|
+
// after_tool_call event: { ..., result?, error?, durationMs? }
|
|
948
|
+
// ctx: { agentId?, sessionKey?, sessionId?, runId?, toolName, toolCallId? }
|
|
949
|
+
|
|
950
|
+
safeOn("before_tool_call", (event, ctx) => {
|
|
951
|
+
dump("before_tool_call", event, ctx);
|
|
952
|
+
const rc = resolveHookContext(ctx, relayUrl);
|
|
953
|
+
if (!rc) return;
|
|
954
|
+
const runId = rc.run_id || ctx?.runId || event?.runId || "";
|
|
955
|
+
if (!runId) return;
|
|
956
|
+
const callId = ctx?.toolCallId || event?.toolCallId || "";
|
|
957
|
+
const toolName = event?.toolName || ctx?.toolName || "";
|
|
958
|
+
|
|
959
|
+
// ── update_plan intercept: emit todo_list block instead of tool_use ────
|
|
960
|
+
// OpenClaw calls update_plan as a native tool call; we suppress the
|
|
961
|
+
// tool_use / tool_result pair and replace it with a single todo_list block
|
|
962
|
+
// (started + completed emitted together in before_tool_call). after_tool_call
|
|
963
|
+
// also returns early so the tool_use_completed + tool_result are never sent.
|
|
964
|
+
if (toolName === "update_plan" && todoUtils) {
|
|
965
|
+
const rawPlan = event?.params?.plan;
|
|
966
|
+
const items = todoUtils.buildTodoItems(Array.isArray(rawPlan) ? rawPlan : []);
|
|
967
|
+
const idx = nextBlockIndex(runId);
|
|
968
|
+
const todoBlock = {
|
|
969
|
+
type: "todo_list",
|
|
970
|
+
status: "in_progress",
|
|
971
|
+
items,
|
|
972
|
+
};
|
|
973
|
+
const todoStarted = envelopeBuilder.buildBlockStarted(runId, todoBlock, idx);
|
|
974
|
+
const blockId = todoStarted.payload.block.id;
|
|
975
|
+
const todoCompleted = envelopeBuilder.buildBlockCompleted(
|
|
976
|
+
runId,
|
|
977
|
+
{ id: blockId, type: "todo_list", status: "completed", items },
|
|
978
|
+
idx,
|
|
979
|
+
);
|
|
980
|
+
enqueuePost(callId, ctx, [todoStarted, todoCompleted]);
|
|
981
|
+
trackToolCallForRun(runId, callId);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const input = truncatePayload(event?.params ?? null);
|
|
986
|
+
const idx = nextBlockIndex(runId);
|
|
987
|
+
const { useBlkId } = getOrCreateToolBlockIds(callId);
|
|
988
|
+
trackToolCallForRun(runId, callId); // for GC at agent_end
|
|
989
|
+
// tool_use block fields are flat on the block per blocks/tool_use.schema.json
|
|
990
|
+
// (no nested `body`). The OpenClaw call_xxx id goes into tool_call_id.
|
|
991
|
+
const env = envelopeBuilder.buildBlockStarted(
|
|
992
|
+
runId,
|
|
993
|
+
{
|
|
994
|
+
id: useBlkId,
|
|
995
|
+
type: "tool_use",
|
|
996
|
+
status: "in_progress",
|
|
997
|
+
tool_call_id: callId || "",
|
|
998
|
+
tool_name: toolName,
|
|
999
|
+
input: (input && typeof input === "object" && !Array.isArray(input)) ? input : {},
|
|
1000
|
+
input_complete: false,
|
|
1001
|
+
},
|
|
1002
|
+
idx,
|
|
1003
|
+
);
|
|
1004
|
+
|
|
1005
|
+
// Method A2: sessions_spawn tool_use goes into buffer instead of immediate POST.
|
|
1006
|
+
// We need to flush it AFTER subagent_ref.started + run.started in after_tool_call,
|
|
1007
|
+
// so relay's scanSDKSignals sees the SDK signal (subagent_ref) before synthStarted.
|
|
1008
|
+
if (toolName === "sessions_spawn") {
|
|
1009
|
+
// Buffer the tool_use envelope
|
|
1010
|
+
const buf = pendingToolUseBufferByCallId.get(callId) || [];
|
|
1011
|
+
buf.push(env);
|
|
1012
|
+
pendingToolUseBufferByCallId.set(callId, buf);
|
|
1013
|
+
// FIFO enqueue: parentRunId -> callId[]
|
|
1014
|
+
const q = pendingSessionsSpawnByRun.get(runId) || [];
|
|
1015
|
+
q.push(callId);
|
|
1016
|
+
pendingSessionsSpawnByRun.set(runId, q);
|
|
1017
|
+
// Do NOT enqueuePost — return early
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
enqueuePost(callId, ctx, [env]);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
safeOn("after_tool_call", (event, ctx) => {
|
|
1025
|
+
dump("after_tool_call", event, ctx);
|
|
1026
|
+
const rc = resolveHookContext(ctx, relayUrl);
|
|
1027
|
+
if (!rc) return;
|
|
1028
|
+
const runId = rc.run_id || ctx?.runId || event?.runId || "";
|
|
1029
|
+
if (!runId) return;
|
|
1030
|
+
const callId = ctx?.toolCallId || event?.toolCallId || "";
|
|
1031
|
+
const toolName = event?.toolName || ctx?.toolName || "";
|
|
1032
|
+
|
|
1033
|
+
// ── update_plan intercept: todo_list block was already fully emitted in
|
|
1034
|
+
// before_tool_call (started + completed). Skip tool_use_completed + tool_result.
|
|
1035
|
+
if (toolName === "update_plan" && todoUtils) {
|
|
1036
|
+
dropToolBlockIds(callId);
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const errorText = typeof event?.error === "string" ? event.error.trim() : "";
|
|
1041
|
+
const isError = errorText !== "";
|
|
1042
|
+
const duration =
|
|
1043
|
+
typeof event?.durationMs === "number" ? event.durationMs : null;
|
|
1044
|
+
|
|
1045
|
+
const { useBlkId, resultBlkId } = getOrCreateToolBlockIds(callId);
|
|
1046
|
+
|
|
1047
|
+
// Reserve two consecutive per-run indexes upfront so:
|
|
1048
|
+
// - toolUseCompleted reuses the same index as the prior toolUseStarted
|
|
1049
|
+
// (close out the block at its original slot)
|
|
1050
|
+
// - toolResultStarted + toolResultCompleted share a fresh slot
|
|
1051
|
+
// (We can't reuse the started's exact index without remembering it from
|
|
1052
|
+
// before_tool_call; using nextBlockIndex here gives monotonic order
|
|
1053
|
+
// which is sufficient for App-side delta replay.)
|
|
1054
|
+
const useCompletedIdx = nextBlockIndex(runId);
|
|
1055
|
+
const resultIdx = nextBlockIndex(runId);
|
|
1056
|
+
|
|
1057
|
+
// tool_use block: flat fields per blocks/tool_use.schema.json.
|
|
1058
|
+
const paramsInput = truncatePayload(event?.params ?? null);
|
|
1059
|
+
const toolUseCompleted = envelopeBuilder.buildBlockCompleted(
|
|
1060
|
+
runId,
|
|
1061
|
+
{
|
|
1062
|
+
id: useBlkId,
|
|
1063
|
+
type: "tool_use",
|
|
1064
|
+
status: isError ? "failed" : "completed",
|
|
1065
|
+
tool_call_id: callId || "",
|
|
1066
|
+
tool_name: toolName,
|
|
1067
|
+
input: (paramsInput && typeof paramsInput === "object" && !Array.isArray(paramsInput)) ? paramsInput : {},
|
|
1068
|
+
input_complete: true,
|
|
1069
|
+
},
|
|
1070
|
+
useCompletedIdx,
|
|
1071
|
+
);
|
|
1072
|
+
|
|
1073
|
+
// Helper: every block inside tool_result.output must satisfy
|
|
1074
|
+
// block.schema.json `required: ["id", "type", "status"]` — including text
|
|
1075
|
+
// children. We build them by calling buildBlockStarted to auto-fill
|
|
1076
|
+
// schema-required fields (id, status, format, text), then strip the
|
|
1077
|
+
// envelope wrapper to get back just the block object.
|
|
1078
|
+
function newOutputTextBlock(text) {
|
|
1079
|
+
const env = envelopeBuilder.buildBlockStarted("_unused", {
|
|
1080
|
+
type: "text",
|
|
1081
|
+
format: "plain",
|
|
1082
|
+
text: String(text == null ? "" : text),
|
|
1083
|
+
status: "completed",
|
|
1084
|
+
});
|
|
1085
|
+
return env.payload.block;
|
|
1086
|
+
}
|
|
1087
|
+
function normalizeOutputBlock(b) {
|
|
1088
|
+
if (!b || typeof b !== "object") return null;
|
|
1089
|
+
// OpenClaw text shape → schema-valid text block
|
|
1090
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
1091
|
+
return newOutputTextBlock(truncatePayload(b.text, PAYLOAD_TRUNCATE_BYTES));
|
|
1092
|
+
}
|
|
1093
|
+
// Other types: fill missing required fields if possible
|
|
1094
|
+
const out = { ...b };
|
|
1095
|
+
if (!out.id) {
|
|
1096
|
+
const probe = envelopeBuilder.buildBlockStarted("_unused", { type: out.type || "text" });
|
|
1097
|
+
out.id = probe.payload.block.id;
|
|
1098
|
+
}
|
|
1099
|
+
if (!out.status) out.status = "completed";
|
|
1100
|
+
return out;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// tool_result.output is an **array of nested blocks** per
|
|
1104
|
+
// blocks/tool_result.schema.json. We always wrap the OpenClaw result
|
|
1105
|
+
// (which is typically already `{content:[{type:"text",text:"..."}]}` or a
|
|
1106
|
+
// raw object) into one or more text blocks. summary captures duration
|
|
1107
|
+
// when available so it's discoverable client-side.
|
|
1108
|
+
const rawResult = event?.result;
|
|
1109
|
+
let outputBlocks;
|
|
1110
|
+
if (Array.isArray(rawResult?.content)) {
|
|
1111
|
+
outputBlocks = rawResult.content.map(normalizeOutputBlock).filter(Boolean);
|
|
1112
|
+
} else if (typeof rawResult === "string") {
|
|
1113
|
+
outputBlocks = [newOutputTextBlock(truncatePayload(rawResult, PAYLOAD_TRUNCATE_BYTES))];
|
|
1114
|
+
} else if (rawResult != null) {
|
|
1115
|
+
outputBlocks = [newOutputTextBlock(JSON.stringify(truncatePayload(rawResult)))];
|
|
1116
|
+
} else if (isError) {
|
|
1117
|
+
outputBlocks = [newOutputTextBlock(errorText || "tool error")];
|
|
1118
|
+
} else {
|
|
1119
|
+
outputBlocks = [];
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
const toolResultStarted = envelopeBuilder.buildBlockStarted(
|
|
1123
|
+
runId,
|
|
1124
|
+
{
|
|
1125
|
+
id: resultBlkId,
|
|
1126
|
+
type: "tool_result",
|
|
1127
|
+
status: "in_progress",
|
|
1128
|
+
tool_call_id: callId || "",
|
|
1129
|
+
is_error: isError,
|
|
1130
|
+
output: outputBlocks,
|
|
1131
|
+
summary: typeof duration === "number" ? `duration=${duration}ms` : undefined,
|
|
1132
|
+
},
|
|
1133
|
+
resultIdx,
|
|
1134
|
+
);
|
|
1135
|
+
const toolResultCompleted = envelopeBuilder.buildBlockCompleted(
|
|
1136
|
+
runId,
|
|
1137
|
+
{
|
|
1138
|
+
id: resultBlkId,
|
|
1139
|
+
type: "tool_result",
|
|
1140
|
+
status: isError ? "failed" : "completed",
|
|
1141
|
+
tool_call_id: callId || "",
|
|
1142
|
+
is_error: isError,
|
|
1143
|
+
output: outputBlocks,
|
|
1144
|
+
summary: typeof duration === "number" ? `duration=${duration}ms` : undefined,
|
|
1145
|
+
},
|
|
1146
|
+
resultIdx,
|
|
1147
|
+
);
|
|
1148
|
+
|
|
1149
|
+
// Method A2: sessions_spawn after_tool_call — emit subagent_ref.started + run.started
|
|
1150
|
+
// BEFORE flushing the buffered tool_use, so relay's scanSDKSignals writes
|
|
1151
|
+
// sdkMode before synthStarted sees the tool_use block.
|
|
1152
|
+
if (toolName === "sessions_spawn") {
|
|
1153
|
+
// Extract childSessionKey: try multiple shapes
|
|
1154
|
+
// 1. 顶层字段(极少;如果 OpenClaw 升级把 result 摊平到顶层)
|
|
1155
|
+
// 2. result.content[0].text 内嵌 JSON(典型 OpenClaw sessions_spawn 形态)
|
|
1156
|
+
// 3. result.output[0].text 内嵌 JSON(少数 OpenClaw 版本的旧形态)
|
|
1157
|
+
// 4. result 本身就是 JSON 字符串
|
|
1158
|
+
function parseEmbeddedJSON(text) {
|
|
1159
|
+
if (!text) return null;
|
|
1160
|
+
try { return JSON.parse(text); } catch { return null; }
|
|
1161
|
+
}
|
|
1162
|
+
let childKey = "";
|
|
1163
|
+
if (typeof rawResult?.childSessionKey === "string") {
|
|
1164
|
+
childKey = rawResult.childSessionKey;
|
|
1165
|
+
} else if (Array.isArray(rawResult?.content) && rawResult.content[0]?.text) {
|
|
1166
|
+
const parsed = parseEmbeddedJSON(rawResult.content[0].text);
|
|
1167
|
+
if (parsed?.childSessionKey) childKey = parsed.childSessionKey;
|
|
1168
|
+
} else if (Array.isArray(rawResult?.output) && rawResult.output[0]?.text) {
|
|
1169
|
+
const parsed = parseEmbeddedJSON(rawResult.output[0].text);
|
|
1170
|
+
if (parsed?.childSessionKey) childKey = parsed.childSessionKey;
|
|
1171
|
+
} else if (typeof rawResult === "string") {
|
|
1172
|
+
const parsed = parseEmbeddedJSON(rawResult);
|
|
1173
|
+
if (parsed?.childSessionKey) childKey = parsed.childSessionKey;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// Dequeue FIFO (align before_tool_call callId with after_tool_call callId)
|
|
1177
|
+
const fifoQ = pendingSessionsSpawnByRun.get(runId) || [];
|
|
1178
|
+
fifoQ.shift(); // pop the head (should correspond to this callId)
|
|
1179
|
+
if (fifoQ.length === 0) {
|
|
1180
|
+
pendingSessionsSpawnByRun.delete(runId);
|
|
1181
|
+
} else {
|
|
1182
|
+
pendingSessionsSpawnByRun.set(runId, fifoQ);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
if (!childKey) {
|
|
1186
|
+
// Fallback: childKey not extractable. Flush buffer and let relay synth handle it.
|
|
1187
|
+
// Log result shape hint for debugging without dumping full content (may be sensitive).
|
|
1188
|
+
const shapeHint = (() => {
|
|
1189
|
+
if (rawResult == null) return "null";
|
|
1190
|
+
if (typeof rawResult === "string") return "string(len=" + rawResult.length + ")";
|
|
1191
|
+
if (Array.isArray(rawResult)) return "array";
|
|
1192
|
+
if (typeof rawResult === "object") {
|
|
1193
|
+
return "object(keys=" + Object.keys(rawResult).slice(0, 6).join(",") + ")";
|
|
1194
|
+
}
|
|
1195
|
+
return typeof rawResult;
|
|
1196
|
+
})();
|
|
1197
|
+
log.warn(
|
|
1198
|
+
"[agentlink-hooks] after_tool_call(sessions_spawn): cannot extract childSessionKey " +
|
|
1199
|
+
"from result, callId=" + callId + " runId=" + runId + " resultShape=" + shapeHint +
|
|
1200
|
+
", falling back to relay synth",
|
|
1201
|
+
);
|
|
1202
|
+
// Flush buffered tool_use
|
|
1203
|
+
const fallbackBuf = pendingToolUseBufferByCallId.get(callId) || [];
|
|
1204
|
+
pendingToolUseBufferByCallId.delete(callId);
|
|
1205
|
+
if (fallbackBuf.length > 0) {
|
|
1206
|
+
enqueuePost(callId, ctx, fallbackBuf);
|
|
1207
|
+
}
|
|
1208
|
+
// Then tool_use completed + tool_result (normal path)
|
|
1209
|
+
enqueuePost(callId, ctx, [toolUseCompleted, toolResultStarted, toolResultCompleted]);
|
|
1210
|
+
dropToolBlockIds(callId);
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// Core path: childKey extracted. Emit in strict order using a single
|
|
1215
|
+
// enqueuePost call so all envelopes share one serial queue slot:
|
|
1216
|
+
// ① subagent_ref.started ② run.started ③ buffer flush ④ tool_result
|
|
1217
|
+
// This guarantees relay sees the SDK signal (subagent_ref.started) first.
|
|
1218
|
+
const subRunId = deriveSubRunId(childKey);
|
|
1219
|
+
const parentRunId = runId;
|
|
1220
|
+
|
|
1221
|
+
// ① subagent_ref.started
|
|
1222
|
+
const refStarted = envelopeBuilder.buildBlockStarted(
|
|
1223
|
+
parentRunId,
|
|
1224
|
+
{
|
|
1225
|
+
type: "subagent_ref",
|
|
1226
|
+
child_run_id: subRunId,
|
|
1227
|
+
spawn_tool_use_id: callId,
|
|
1228
|
+
agent: "", // openclaw after_tool_call has no agentId in result
|
|
1229
|
+
summary: "",
|
|
1230
|
+
},
|
|
1231
|
+
);
|
|
1232
|
+
const refBlockId = refStarted.payload.block.id;
|
|
1233
|
+
|
|
1234
|
+
// ② run.started for the child sub-run
|
|
1235
|
+
const runStarted = envelopeBuilder.buildRunStarted(subRunId, {
|
|
1236
|
+
spawnToolUseId: callId,
|
|
1237
|
+
model: "",
|
|
1238
|
+
options: { mode: rawResult?.mode || "" },
|
|
1239
|
+
});
|
|
1240
|
+
runStarted.parent_run_id = parentRunId;
|
|
1241
|
+
stampChildSessionKey(runStarted, childKey);
|
|
1242
|
+
|
|
1243
|
+
// ③ buffered tool_use (from before_tool_call)
|
|
1244
|
+
const buf = pendingToolUseBufferByCallId.get(callId) || [];
|
|
1245
|
+
pendingToolUseBufferByCallId.delete(callId);
|
|
1246
|
+
|
|
1247
|
+
// ④ tool_result (all in one batch, in order)
|
|
1248
|
+
const orderedBatch = [
|
|
1249
|
+
refStarted,
|
|
1250
|
+
runStarted,
|
|
1251
|
+
...buf,
|
|
1252
|
+
toolUseCompleted,
|
|
1253
|
+
toolResultStarted,
|
|
1254
|
+
toolResultCompleted,
|
|
1255
|
+
];
|
|
1256
|
+
enqueuePost(callId, ctx, orderedBatch);
|
|
1257
|
+
dropToolBlockIds(callId);
|
|
1258
|
+
|
|
1259
|
+
// Write to state table for agent_end(isFallback=true) to consume
|
|
1260
|
+
subagentRefByChildKey.set(childKey, {
|
|
1261
|
+
refBlockId,
|
|
1262
|
+
callId,
|
|
1263
|
+
parentRunId,
|
|
1264
|
+
agent: "",
|
|
1265
|
+
subRunId,
|
|
1266
|
+
});
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
enqueuePost(callId, ctx, [toolUseCompleted, toolResultStarted, toolResultCompleted]);
|
|
1271
|
+
dropToolBlockIds(callId); // free per-call state after completion
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
// ── Subagent lifecycle ──────────────────────────────────────────────────
|
|
1275
|
+
// subagent_spawning event:
|
|
1276
|
+
// { childSessionKey, agentId, label?, mode, requester?, threadRequested }
|
|
1277
|
+
// subagent_ended event:
|
|
1278
|
+
// { targetSessionKey, targetKind, reason, outcome?, error?, runId?, endedAt? }
|
|
1279
|
+
// ctx: { runId?, childSessionKey?, requesterSessionKey? }
|
|
1280
|
+
|
|
1281
|
+
// Helper: stamp the daemon-side OpenClaw childSessionKey onto envelope
|
|
1282
|
+
// extensions so server-side subagent_synth can correlate child run.{started,
|
|
1283
|
+
// completed,failed} back to the parent's subagent_ref block by key (the
|
|
1284
|
+
// unified subRunId is derived locally via sha1(childKey) and not visible to
|
|
1285
|
+
// server, but childKey itself appears in both sessions_spawn's tool_result
|
|
1286
|
+
// output AND the hook event — so it's the only stable join key across the
|
|
1287
|
+
// parent's tool_result and the child's terminal envelope).
|
|
1288
|
+
function stampChildSessionKey(env, childKey) {
|
|
1289
|
+
if (!childKey) return env;
|
|
1290
|
+
const ext = (env.extensions && typeof env.extensions === "object") ? env.extensions : {};
|
|
1291
|
+
const oc = (ext.openclaw && typeof ext.openclaw === "object") ? ext.openclaw : {};
|
|
1292
|
+
env.extensions = {
|
|
1293
|
+
...ext,
|
|
1294
|
+
openclaw: { ...oc, child_session_key: childKey },
|
|
1295
|
+
};
|
|
1296
|
+
return env;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// DEAD CODE (2026-05-17): subagent_spawning hook does NOT fire in current
|
|
1300
|
+
// OpenClaw daemon version. This was verified via e2e DEBUG log — OpenClaw
|
|
1301
|
+
// executes sessions_spawn via before_tool_call / after_tool_call instead.
|
|
1302
|
+
// This handler was the intended core of the original plan B design.
|
|
1303
|
+
// Kept for reference — remove or re-activate if OpenClaw upstream revives
|
|
1304
|
+
// this hook in a future daemon version.
|
|
1305
|
+
safeOn("subagent_spawning", (event, ctx) => {
|
|
1306
|
+
dump("subagent_spawning", event, ctx);
|
|
1307
|
+
const rc = resolveHookContext(ctx, relayUrl);
|
|
1308
|
+
if (!rc) return;
|
|
1309
|
+
const parentRunId = rc.run_id || ctx?.runId || "";
|
|
1310
|
+
if (!parentRunId) return;
|
|
1311
|
+
const childKey = event?.childSessionKey || ctx?.childSessionKey || "";
|
|
1312
|
+
const subRunId = deriveSubRunId(childKey);
|
|
1313
|
+
if (!subRunId) return;
|
|
1314
|
+
// buildRunStarted does not natively set parent_run_id; we splice it in.
|
|
1315
|
+
const env = envelopeBuilder.buildRunStarted(subRunId, {
|
|
1316
|
+
model: "",
|
|
1317
|
+
options: {
|
|
1318
|
+
agent_id: event?.agentId || "",
|
|
1319
|
+
label: event?.label || "",
|
|
1320
|
+
mode: event?.mode || "",
|
|
1321
|
+
},
|
|
1322
|
+
});
|
|
1323
|
+
env.parent_run_id = parentRunId;
|
|
1324
|
+
stampChildSessionKey(env, childKey);
|
|
1325
|
+
enqueuePost(childKey || subRunId, ctx, [env]);
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
// DEAD CODE (2026-05-17): subagent_ended hook does NOT fire in current
|
|
1329
|
+
// OpenClaw daemon version (same reason as subagent_spawning above).
|
|
1330
|
+
// Subagent completion is signaled via agent_end(isFallback=true) instead.
|
|
1331
|
+
// Kept for reference — remove or re-activate if OpenClaw upstream revives
|
|
1332
|
+
// this hook in a future daemon version.
|
|
1333
|
+
safeOn("subagent_ended", (event, ctx) => {
|
|
1334
|
+
dump("subagent_ended", event, ctx);
|
|
1335
|
+
const rc = resolveHookContext(ctx, relayUrl);
|
|
1336
|
+
if (!rc) return;
|
|
1337
|
+
const parentRunId = rc.run_id || ctx?.runId || event?.runId || "";
|
|
1338
|
+
if (!parentRunId) return;
|
|
1339
|
+
const childKey = event?.targetSessionKey || ctx?.childSessionKey || "";
|
|
1340
|
+
const subRunId = deriveSubRunId(childKey);
|
|
1341
|
+
if (!subRunId) return;
|
|
1342
|
+
const outcome = typeof event?.outcome === "string" ? event.outcome : "";
|
|
1343
|
+
const isError = outcome !== "" && outcome !== "ok";
|
|
1344
|
+
const errorText = typeof event?.error === "string" ? event.error.trim() : "";
|
|
1345
|
+
|
|
1346
|
+
let env;
|
|
1347
|
+
if (isError) {
|
|
1348
|
+
env = envelopeBuilder.buildRunFailed(
|
|
1349
|
+
subRunId,
|
|
1350
|
+
outcome || "runtime",
|
|
1351
|
+
errorText || outcome || "subagent ended with non-ok outcome",
|
|
1352
|
+
);
|
|
1353
|
+
} else {
|
|
1354
|
+
const stopReason =
|
|
1355
|
+
outcome === "ok" ? "end_turn" : outcome || "end_turn";
|
|
1356
|
+
env = envelopeBuilder.buildRunCompleted(subRunId, stopReason);
|
|
1357
|
+
}
|
|
1358
|
+
env.parent_run_id = parentRunId;
|
|
1359
|
+
stampChildSessionKey(env, childKey);
|
|
1360
|
+
enqueuePost(childKey || subRunId, ctx, [env]);
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
// ── before_agent_reply ────────────────────────────────────────────────
|
|
1364
|
+
// OpenClaw 2026.3.x treats this name as an unknown typed hook and never
|
|
1365
|
+
// fires it. Even if a future OpenClaw revives it, the server SSE adapter
|
|
1366
|
+
// already emits run.started from response.started, so plugin emitting
|
|
1367
|
+
// another run.started here would be redundant. We register a no-op handler
|
|
1368
|
+
// purely for verbose-dump visibility if/when the hook resurfaces.
|
|
1369
|
+
|
|
1370
|
+
safeOn("before_agent_reply", (event, ctx) => {
|
|
1371
|
+
dump("before_agent_reply", event, ctx);
|
|
1372
|
+
// Intentionally no envelope: redundant with server-side SSE adapter.
|
|
1373
|
+
// Remove this handler entirely once we confirm OpenClaw upstream has
|
|
1374
|
+
// permanently retired the `before_agent_reply` hook name (the typed
|
|
1375
|
+
// hook 已被标 unknown, see openclaw plugins inspect Diagnostics).
|
|
1376
|
+
log.debug("before_agent_reply: no-op (server SSE adapter covers this)");
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
// ── agent_end → run.completed / run.failed (+ autonomous follow-up replay) ──
|
|
1380
|
+
// event: { messages, success, error?, durationMs? }
|
|
1381
|
+
//
|
|
1382
|
+
// 两种 fire 模式(用 rc.isFallback 区分):
|
|
1383
|
+
//
|
|
1384
|
+
// A. isFallback=false — bridge dispatch 那轮主回复结束:
|
|
1385
|
+
// bridge 自己已经在 /v1/responses SSE 把 text content_blocks + 收尾
|
|
1386
|
+
// run.completed 发给 relay,plugin **不应**再重复发文本(会双倍
|
|
1387
|
+
// content_block),只需发 run.completed 作为 hook coverage。
|
|
1388
|
+
//
|
|
1389
|
+
// B. isFallback=true — daemon 在原 dispatch 关闭 SSE 后**自发**起的
|
|
1390
|
+
// follow-up 轮(典型场景: subagent 完成后 daemon 内部投递 task-
|
|
1391
|
+
// completion user_message 给主 agent + 自动跑新一轮 LLM 调用):
|
|
1392
|
+
// bridge 没在听,relay 永远收不到这一轮 reply → App 永远停在
|
|
1393
|
+
// "已启动子任务,稍等..." 那条。plugin 在这一刻是 daemon 进程里**
|
|
1394
|
+
// 唯一**能看见这次 reply 的代码,必须把它补发出去。
|
|
1395
|
+
//
|
|
1396
|
+
// 由于 daemon native runId 是 resp_<uuid>,server schema 只接
|
|
1397
|
+
// run_<26 Crockford>,这里用 deriveSubRunId(stable seed) 生成一个
|
|
1398
|
+
// 合规且**确定性**的 follow-up run_id(同一 ctx 多次 fire 不会双发)。
|
|
1399
|
+
// 不挂 parent_run_id(这不是 subagent 而是 thread 顶层 follow-up),
|
|
1400
|
+
// 但带 extensions.openclaw.autonomous_followup 标记便于 server / App
|
|
1401
|
+
// 区分这是 daemon 自发产物。
|
|
1402
|
+
|
|
1403
|
+
function extractAssistantText(messages) {
|
|
1404
|
+
if (!Array.isArray(messages)) return "";
|
|
1405
|
+
let combined = "";
|
|
1406
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1407
|
+
const m = messages[i];
|
|
1408
|
+
if (!m || m.role !== "assistant") continue;
|
|
1409
|
+
const content = m.content;
|
|
1410
|
+
if (typeof content === "string") {
|
|
1411
|
+
combined = content;
|
|
1412
|
+
break;
|
|
1413
|
+
}
|
|
1414
|
+
if (Array.isArray(content)) {
|
|
1415
|
+
const parts = [];
|
|
1416
|
+
for (const c of content) {
|
|
1417
|
+
if (!c || typeof c !== "object") continue;
|
|
1418
|
+
// 跳过 thinking/toolCall 等;只拿 user-facing 文本
|
|
1419
|
+
if (c.type === "text" && typeof c.text === "string") parts.push(c.text);
|
|
1420
|
+
else if (c.type === "markdown" && typeof c.markdown === "string") parts.push(c.markdown);
|
|
1421
|
+
}
|
|
1422
|
+
combined = parts.join("");
|
|
1423
|
+
break;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
return combined;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
function deriveAutonomousFollowupRunId(threadId, daemonRunId) {
|
|
1430
|
+
// 同 thread + 同 daemon run 派生同 unified run_id(确定性,防重);
|
|
1431
|
+
// 不同 daemon run(例如同 thread 内多次 follow-up)派生不同 unified run_id。
|
|
1432
|
+
const seed = `autonomous-followup:${threadId}:${daemonRunId || "anon"}`;
|
|
1433
|
+
return deriveSubRunId(seed);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// OpenClaw daemon 用 `announce:v1:...` 命名 autonomous follow-up turn 的
|
|
1437
|
+
// run_id(subagent 完成事件回灌主 agent 后内部触发的新一轮 LLM 调用)。
|
|
1438
|
+
// 这种轮的 ctx.runId 不是 resp_<uuid> 也不是 unified run_<ULID>,是
|
|
1439
|
+
// `announce:v1:agent:<name>:subagent:<uuid>:<parent-resp-id>` 这种合成 id。
|
|
1440
|
+
// 当 ctx.runId 以 `announce:` 开头时,**无论 active-runs file 在不在**,
|
|
1441
|
+
// 这一轮必然是 bridge dispatch 没在监听的自发轮 — bridge dispatch 只接
|
|
1442
|
+
// **首轮** /v1/responses SSE,后续 daemon 自发产物通过 daemon-UI 自己的 WS
|
|
1443
|
+
// chat.history channel 推,不在 bridge 通道里。
|
|
1444
|
+
function isDaemonAutonomousTurn(ctx) {
|
|
1445
|
+
const rid = String(ctx?.runId || "").trim();
|
|
1446
|
+
// 只认 announce: 前缀。**不要**再用 "sessionKey 形如 agent:main:thr_xxx"
|
|
1447
|
+
// 兜底:主轮(bridge dispatch 的那一轮)的 ctx.sessionKey 同样是
|
|
1448
|
+
// `agent:main:thr_xxx`,旧的 sessionKey 启发式会把主轮误判成 autonomous,
|
|
1449
|
+
// 于是 agent_end 走 path B 把已经流式发过的文本又补发一遍 → App 每条
|
|
1450
|
+
// 回复显示两条相同消息。真正的 daemon 自发轮由 announce: 前缀(subagent)
|
|
1451
|
+
// 或 isFallback=true(active-runs 文件已不在 = bridge 没在处理)识别,
|
|
1452
|
+
// 不需要 sessionKey 启发式。
|
|
1453
|
+
return rid.startsWith("announce:");
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
safeOn("agent_end", (event, ctx) => {
|
|
1457
|
+
dump("agent_end", event, ctx);
|
|
1458
|
+
|
|
1459
|
+
// 自动 attach: 扫这一轮 assistant 文本里的本机文件路径 → 走 agentlink_attach_file
|
|
1460
|
+
// 上传 COS。fire-and-forget,所有错误内部吞掉,不影响下面 run.completed 发送。
|
|
1461
|
+
// path A / B 都需要扫(两条路上都有完整 assistant 文本)。
|
|
1462
|
+
try {
|
|
1463
|
+
const assistantText = extractAssistantText(event?.messages);
|
|
1464
|
+
if (assistantText) {
|
|
1465
|
+
void autoAttachScanAndDispatch(assistantText, { log, warnThrottled });
|
|
1466
|
+
}
|
|
1467
|
+
} catch (err) {
|
|
1468
|
+
log.warn(`auto-attach.agent_end.threw: ${err?.message || err}`);
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
const rc = resolveHookContext(ctx, relayUrl);
|
|
1472
|
+
if (!rc) return;
|
|
1473
|
+
|
|
1474
|
+
const ok = event?.success === true;
|
|
1475
|
+
|
|
1476
|
+
// ── A. 主链路 fire — 走原行为,只发 run.completed ──────────────────
|
|
1477
|
+
// 即使 active-runs file 还在 (bridge dispatch SSE 没 EOF),只要 ctx 表明
|
|
1478
|
+
// 这是 daemon 自发的 announce turn,就走 fallback 而不是 A —— bridge
|
|
1479
|
+
// 那条 SSE 通道根本收不到这一轮的内容,不补发就丢。
|
|
1480
|
+
//
|
|
1481
|
+
// 补充判定(Bug fix: 主轮双发问题):
|
|
1482
|
+
// bridge SSE EOF 后会 cleanup active-runs 文件,但 daemon 的 agent_end
|
|
1483
|
+
// hook 在那之后才触发 → 文件已删 → isFallback=true。此时若 ctx.runId 是
|
|
1484
|
+
// 原生 "resp_<uuid>" 格式,说明这是 bridge 曾 dispatch 过的主轮(daemon
|
|
1485
|
+
// 原生 run id 与 bridge 下发的 unified run_<ULID> 一一对应,主轮的
|
|
1486
|
+
// ctx.runId 从不是 announce: 或其他合成格式)。bridge 已通过 SSE 把所有
|
|
1487
|
+
// content_blocks 发给 relay,plugin 不应再重发文本,否则 App 会看到两条
|
|
1488
|
+
// 完全相同的 assistant 消息。
|
|
1489
|
+
// 判据依据:active-runs 文件写入时存的 run_id 是 unified run_<ULID>,
|
|
1490
|
+
// 而 daemon 内部给 ctx.runId 赋的是原生 resp_<uuid>。两者格式不同,可
|
|
1491
|
+
// 可靠区分。真正需要 plugin 补发的自发轮:
|
|
1492
|
+
// - B1 (subagent announce): ctx.runId 以 "announce:" 开头 → isDaemonAutonomousTurn=true
|
|
1493
|
+
// - B2 (cron/系统自发等): ctx.runId 既不是 resp_ 也不是 announce:
|
|
1494
|
+
// → isBridgeDispatchedMainRun=false → 走 B2 路径正确补发
|
|
1495
|
+
// isBridgeDispatchedMainRun: bridge SSE EOF 后 active-runs 文件已删,
|
|
1496
|
+
// 但 ctx.runId 仍是 daemon 原生 "resp_<uuid>" → 这是主轮,bridge 已全量
|
|
1497
|
+
// 发过内容,plugin 直接跳过,不发任何 envelope(尤其不走 B2 补发路径)。
|
|
1498
|
+
const isBridgeDispatchedMainRun =
|
|
1499
|
+
rc.isFallback && String(ctx?.runId || "").startsWith("resp_");
|
|
1500
|
+
if (isBridgeDispatchedMainRun) {
|
|
1501
|
+
log.info(
|
|
1502
|
+
`agent_end: skip main-run (bridge already streamed): ` +
|
|
1503
|
+
`thread=${rc.thread_id} daemon_run=${ctx?.runId}`,
|
|
1504
|
+
);
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
const autonomous = isDaemonAutonomousTurn(ctx);
|
|
1508
|
+
if (!rc.isFallback && !autonomous) {
|
|
1509
|
+
const runId = rc.run_id || ctx?.runId || "";
|
|
1510
|
+
if (!runId) return;
|
|
1511
|
+
let env;
|
|
1512
|
+
if (ok) {
|
|
1513
|
+
env = envelopeBuilder.buildRunCompleted(runId, "end_turn");
|
|
1514
|
+
} else {
|
|
1515
|
+
const message =
|
|
1516
|
+
(typeof event?.error === "string" && event.error.trim()) || "agent failed";
|
|
1517
|
+
env = envelopeBuilder.buildRunFailed(runId, "runtime", message);
|
|
1518
|
+
}
|
|
1519
|
+
enqueuePost(null, ctx, [env]);
|
|
1520
|
+
dropBlockIndex(runId);
|
|
1521
|
+
// Method A2 Task 6 (修订 2026-05-17): 仅清理 FIFO + buffer。
|
|
1522
|
+
// **不要**清 subagentRefByChildKey ——
|
|
1523
|
+
// OpenClaw 的实际行为是:主 run 结束 (path A) 后子 agent 仍在跑,
|
|
1524
|
+
// 之后子 agent 完成时 daemon 自起 follow-up 触发 path B (isFallback=true),
|
|
1525
|
+
// path B 需要查 subagentRefByChildKey 把 subagent_ref.completed 发出。
|
|
1526
|
+
// path A 在这里清就把 state 提前抹掉了,导致 path B "no subagentRef state" warn。
|
|
1527
|
+
// subagentRefByChildKey 的清理由 path B 末尾 line ~1256 完成;若 path B 永远
|
|
1528
|
+
// 不来(子 agent crash),state 泄漏一直到 daemon 重启——可接受的损失。
|
|
1529
|
+
pendingSessionsSpawnByRun.delete(runId);
|
|
1530
|
+
const staleCallIds = runToolCalls.get(runId);
|
|
1531
|
+
if (staleCallIds) {
|
|
1532
|
+
for (const cid of staleCallIds) {
|
|
1533
|
+
pendingToolUseBufferByCallId.delete(cid);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
dropAllToolCallsForRun(runId);
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// ── B. daemon 自发 follow-up ─────────────────────────────────────────
|
|
1541
|
+
// OpenClaw 在 subagent announce 完成后,把 subagent 的输出当作一条 user 内部消息
|
|
1542
|
+
// 投回 parent session,parent 跑一轮 announce-LLM-call 把结果"以助手口吻"转给用户。
|
|
1543
|
+
// 这一轮的 agent_end:
|
|
1544
|
+
// ctx.runId = "announce:v1:agent:main:subagent:<childKey-uuid>:<daemonRunId>"
|
|
1545
|
+
// ctx.sessionKey = "agent:main:<lowercase-thr_xxx>"
|
|
1546
|
+
// event.messages[last assistant].content = subagent 实际答复(被 parent 转述)
|
|
1547
|
+
//
|
|
1548
|
+
// 协议视角:这段文本既是 parent 的"最终自然语言答复"(parent 第二轮 LLM 调用的输出),
|
|
1549
|
+
// 也是用户看见的"subagent 最终结果"。validator 主视图基于单 rootRunId 锁单棵 run tree,
|
|
1550
|
+
// 没办法把派生的孤儿 followup top-level run 渲染进主流。所以采取双发策略:
|
|
1551
|
+
// - text 作为 content_block 追加到 **parent run**:主流显示自然语言答复("计算结果:33")
|
|
1552
|
+
// - text 同样作为 content_block 挂到 **child sub-run**:点 subagent_ref 进详情看完整结果
|
|
1553
|
+
// - subagent_ref.completed.summary 同段 text:父流 subagent_ref 卡片内联预览
|
|
1554
|
+
// parent run 此时通常已经 run.completed (yield 后 parent 主流的 LLM 调用结束),aggregator
|
|
1555
|
+
// 容忍 run.completed 后继续追加 content_block,UI 会把它渲染到 parent run 的 blocks 末尾。
|
|
1556
|
+
const daemonRun = String(ctx?.runId || "");
|
|
1557
|
+
// daemon_run format: "announce:v1:agent:main:subagent:<childKey-uuid>:<daemonRunId>"
|
|
1558
|
+
// childKey = "agent:main:subagent:<uuid>" (has colons), daemonRunId = UUID (no colons)
|
|
1559
|
+
const announceMatch = /^announce:v1:agent:main:subagent:(.+):([^:]+)$/.exec(daemonRun);
|
|
1560
|
+
const childKeyFromDaemonRun = announceMatch ? ("agent:main:subagent:" + announceMatch[1]) : "";
|
|
1561
|
+
|
|
1562
|
+
const refState = childKeyFromDaemonRun ? subagentRefByChildKey.get(childKeyFromDaemonRun) : undefined;
|
|
1563
|
+
|
|
1564
|
+
// 提取 announce-轮 assistant 文本(= subagent 最终答复,被 parent 转述)
|
|
1565
|
+
const announceText = extractAssistantText(event?.messages);
|
|
1566
|
+
|
|
1567
|
+
if (childKeyFromDaemonRun && refState) {
|
|
1568
|
+
// ── B1. subagent announce 命中:文本同时挂 parent + child,parent 内联预览 summary ──
|
|
1569
|
+
const status = ok ? "completed" : "failed";
|
|
1570
|
+
const stopReason = ok ? "end_turn" : "error";
|
|
1571
|
+
log.info(
|
|
1572
|
+
`agent_end subagent-announce: thread=${rc.thread_id} ` +
|
|
1573
|
+
`daemon_run=${daemonRun} childKey=${childKeyFromDaemonRun} ` +
|
|
1574
|
+
`subRunId=${refState.subRunId} text_len=${announceText.length} ok=${ok}`,
|
|
1575
|
+
);
|
|
1576
|
+
|
|
1577
|
+
const envs = [];
|
|
1578
|
+
|
|
1579
|
+
// ① text content_block on PARENT run — 主流可见的"最终自然语言答复"
|
|
1580
|
+
if (announceText) {
|
|
1581
|
+
const parentIdx = nextBlockIndex(refState.parentRunId);
|
|
1582
|
+
const parentStarted = envelopeBuilder.buildBlockStarted(
|
|
1583
|
+
refState.parentRunId,
|
|
1584
|
+
{ type: "text", format: "markdown", text: "" },
|
|
1585
|
+
parentIdx,
|
|
1586
|
+
);
|
|
1587
|
+
const parentBlockId = parentStarted.payload.block.id;
|
|
1588
|
+
envs.push(parentStarted);
|
|
1589
|
+
|
|
1590
|
+
// buildBlockDelta 签名是 (runId, blockId, delta, index) —— 不要塞多余的
|
|
1591
|
+
// "text" 位置参数,否则 delta 变成字面量 {text_append:"text"}、index 变成对象。
|
|
1592
|
+
envs.push(envelopeBuilder.buildBlockDelta(
|
|
1593
|
+
refState.parentRunId,
|
|
1594
|
+
parentBlockId,
|
|
1595
|
+
{ text_append: announceText },
|
|
1596
|
+
parentIdx,
|
|
1597
|
+
));
|
|
1598
|
+
|
|
1599
|
+
envs.push(envelopeBuilder.buildBlockCompleted(
|
|
1600
|
+
refState.parentRunId,
|
|
1601
|
+
{ id: parentBlockId, type: "text", format: "markdown", text: announceText, status: "completed" },
|
|
1602
|
+
parentIdx,
|
|
1603
|
+
));
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// ② text content_block on CHILD sub-run — subagent_ref 详情页可见
|
|
1607
|
+
// child 所有 envelope 必须挂 parent_run_id + child_session_key,与
|
|
1608
|
+
// after_tool_call 里 buildRunStarted 时挂的字段一致。
|
|
1609
|
+
if (announceText) {
|
|
1610
|
+
const childIdx = nextBlockIndex(refState.subRunId);
|
|
1611
|
+
const childStarted = envelopeBuilder.buildBlockStarted(
|
|
1612
|
+
refState.subRunId,
|
|
1613
|
+
{ type: "text", format: "markdown", text: "" },
|
|
1614
|
+
childIdx,
|
|
1615
|
+
);
|
|
1616
|
+
childStarted.parent_run_id = refState.parentRunId;
|
|
1617
|
+
stampChildSessionKey(childStarted, childKeyFromDaemonRun);
|
|
1618
|
+
const childBlockId = childStarted.payload.block.id;
|
|
1619
|
+
envs.push(childStarted);
|
|
1620
|
+
|
|
1621
|
+
const childDelta = envelopeBuilder.buildBlockDelta(
|
|
1622
|
+
refState.subRunId,
|
|
1623
|
+
childBlockId,
|
|
1624
|
+
{ text_append: announceText },
|
|
1625
|
+
childIdx,
|
|
1626
|
+
);
|
|
1627
|
+
childDelta.parent_run_id = refState.parentRunId;
|
|
1628
|
+
stampChildSessionKey(childDelta, childKeyFromDaemonRun);
|
|
1629
|
+
envs.push(childDelta);
|
|
1630
|
+
|
|
1631
|
+
const childCompleted = envelopeBuilder.buildBlockCompleted(
|
|
1632
|
+
refState.subRunId,
|
|
1633
|
+
{ id: childBlockId, type: "text", format: "markdown", text: announceText, status: "completed" },
|
|
1634
|
+
childIdx,
|
|
1635
|
+
);
|
|
1636
|
+
childCompleted.parent_run_id = refState.parentRunId;
|
|
1637
|
+
stampChildSessionKey(childCompleted, childKeyFromDaemonRun);
|
|
1638
|
+
envs.push(childCompleted);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// ③ subagent_ref.completed on parent run,summary 填入 text 作内联预览
|
|
1642
|
+
const refCompleted = envelopeBuilder.buildBlockCompleted(
|
|
1643
|
+
refState.parentRunId,
|
|
1644
|
+
{
|
|
1645
|
+
id: refState.refBlockId,
|
|
1646
|
+
type: "subagent_ref",
|
|
1647
|
+
child_run_id: refState.subRunId,
|
|
1648
|
+
spawn_tool_use_id: refState.callId,
|
|
1649
|
+
agent: refState.agent,
|
|
1650
|
+
status,
|
|
1651
|
+
stop_reason: stopReason,
|
|
1652
|
+
summary: announceText,
|
|
1653
|
+
},
|
|
1654
|
+
);
|
|
1655
|
+
envs.push(refCompleted);
|
|
1656
|
+
|
|
1657
|
+
// ④ child run.completed / run.failed
|
|
1658
|
+
let runTerminal;
|
|
1659
|
+
if (ok) {
|
|
1660
|
+
runTerminal = envelopeBuilder.buildRunCompleted(refState.subRunId, "end_turn");
|
|
1661
|
+
} else {
|
|
1662
|
+
const errMsg =
|
|
1663
|
+
(typeof event?.error === "string" && event.error.trim()) || "subagent failed";
|
|
1664
|
+
runTerminal = envelopeBuilder.buildRunFailed(refState.subRunId, "runtime", errMsg);
|
|
1665
|
+
}
|
|
1666
|
+
runTerminal.parent_run_id = refState.parentRunId;
|
|
1667
|
+
stampChildSessionKey(runTerminal, childKeyFromDaemonRun);
|
|
1668
|
+
envs.push(runTerminal);
|
|
1669
|
+
|
|
1670
|
+
enqueuePost(refState.subRunId, ctx, envs);
|
|
1671
|
+
subagentRefByChildKey.delete(childKeyFromDaemonRun);
|
|
1672
|
+
dropBlockIndex(refState.subRunId);
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// ── B2. 非 subagent 的自发轮 fallback(announceMatch 没命中,或 refState 缺失)──
|
|
1677
|
+
// 例如 cron / 系统自发投递 / 子轮但 refState 已被清。沿用原 followup top-level run。
|
|
1678
|
+
// 注意:这条路径是 best-effort 兜底,验证场景里基本不应该走到。
|
|
1679
|
+
log.warn(
|
|
1680
|
+
`agent_end fallback (non-subagent autonomous): thread=${rc.thread_id} ` +
|
|
1681
|
+
`daemon_run=${daemonRun} (no refState for childKey=${childKeyFromDaemonRun || "<none>"})`,
|
|
1682
|
+
);
|
|
1683
|
+
|
|
1684
|
+
const followupRunId = deriveAutonomousFollowupRunId(rc.thread_id, daemonRun);
|
|
1685
|
+
if (!followupRunId) return;
|
|
1686
|
+
const envs = [];
|
|
1687
|
+
const runStarted = envelopeBuilder.buildRunStarted(followupRunId, {
|
|
1688
|
+
model: "",
|
|
1689
|
+
options: {
|
|
1690
|
+
autonomous_followup: true,
|
|
1691
|
+
daemon_run_id: daemonRun,
|
|
1692
|
+
},
|
|
1693
|
+
});
|
|
1694
|
+
runStarted.extensions = {
|
|
1695
|
+
...(runStarted.extensions || {}),
|
|
1696
|
+
openclaw: {
|
|
1697
|
+
...(runStarted.extensions?.openclaw || {}),
|
|
1698
|
+
autonomous_followup: true,
|
|
1699
|
+
},
|
|
1700
|
+
};
|
|
1701
|
+
envs.push(runStarted);
|
|
1702
|
+
|
|
1703
|
+
if (announceText) {
|
|
1704
|
+
const idx = nextBlockIndex(followupRunId);
|
|
1705
|
+
const started = envelopeBuilder.buildBlockStarted(
|
|
1706
|
+
followupRunId,
|
|
1707
|
+
{ type: "text", format: "markdown", text: "" },
|
|
1708
|
+
idx,
|
|
1709
|
+
);
|
|
1710
|
+
const blockId = started.payload.block.id;
|
|
1711
|
+
envs.push(started);
|
|
1712
|
+
envs.push(
|
|
1713
|
+
envelopeBuilder.buildBlockDelta(
|
|
1714
|
+
followupRunId,
|
|
1715
|
+
blockId,
|
|
1716
|
+
{ text_append: announceText },
|
|
1717
|
+
idx,
|
|
1718
|
+
),
|
|
1719
|
+
);
|
|
1720
|
+
envs.push(
|
|
1721
|
+
envelopeBuilder.buildBlockCompleted(
|
|
1722
|
+
followupRunId,
|
|
1723
|
+
{ id: blockId, type: "text", format: "markdown", text: announceText, status: "completed" },
|
|
1724
|
+
idx,
|
|
1725
|
+
),
|
|
1726
|
+
);
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
if (ok) {
|
|
1730
|
+
envs.push(envelopeBuilder.buildRunCompleted(followupRunId, "end_turn"));
|
|
1731
|
+
} else {
|
|
1732
|
+
const message =
|
|
1733
|
+
(typeof event?.error === "string" && event.error.trim()) || "agent failed";
|
|
1734
|
+
envs.push(envelopeBuilder.buildRunFailed(followupRunId, "runtime", message));
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
enqueuePost(null, ctx, envs);
|
|
1738
|
+
dropBlockIndex(followupRunId);
|
|
1739
|
+
});
|
|
1740
|
+
|
|
1741
|
+
// ── 2d: agentlink_attach_file / change_title / notify_user tool injection ──
|
|
1742
|
+
// OpenClaw 不支持 MCP server 配置(2026.3.13 没有 `openclaw mcp` 子命令),
|
|
1743
|
+
// 所以 agentlink 工具通过 plugin 注入。流程:
|
|
1744
|
+
// 1) 读 ~/.agentlink/tools-server.json discovery 文件;不存在 → bridge 没跑,
|
|
1745
|
+
// log info 后跳过(等下次 plugin reload 或 mtime poll)
|
|
1746
|
+
// 2) HTTP GET (实际是 POST {}) /tools/list 拿 agentlink tools registry
|
|
1747
|
+
// 3) 对每个 tool 探测 api.tools.register 是否存在:
|
|
1748
|
+
// - 不存在 → log warn "unsupported on this OpenClaw version", 不注册
|
|
1749
|
+
// - 存在 → 注册一个 forwarder: agent call → POST /tools/call → 拿 result
|
|
1750
|
+
// 4) 30s 轮询 discovery 文件 mtime, 变化(bridge 重启分配新端口)就刷新
|
|
1751
|
+
//
|
|
1752
|
+
// 失败任意一步都不让 plugin 整体失败 —— hook 转发是原生职责, tool injection
|
|
1753
|
+
// 是新增功能。
|
|
1754
|
+
tryInjectAgentlinkTools({ api, log, warnThrottled }).catch((err) => {
|
|
1755
|
+
log.warn(`agentlink tool injection failed: ${err?.message || err}`);
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* 探测 OpenClaw plugin API 是否支持 tool 注册,支持就把 agentlinkToolsServer
|
|
1761
|
+
* 暴露的工具批量注册过去。
|
|
1762
|
+
*
|
|
1763
|
+
* 设计要点:
|
|
1764
|
+
* - discovery 文件缺失 → 不报错,只 log info(bridge 没跑是合法状态)
|
|
1765
|
+
* - api.tools.register 缺失 → 一次性 warn,后续 plugin 启动若 API 出现可自愈
|
|
1766
|
+
* - 注册成功后启动 30s mtime poller, bridge 重启换端口时自动重新拉取 tools
|
|
1767
|
+
*/
|
|
1768
|
+
async function tryInjectAgentlinkTools({ api, log, warnThrottled }) {
|
|
1769
|
+
const fs = require("node:fs");
|
|
1770
|
+
const fsP = require("node:fs/promises");
|
|
1771
|
+
const path = require("node:path");
|
|
1772
|
+
const os = require("node:os");
|
|
1773
|
+
|
|
1774
|
+
const DISCOVERY_PATH = path.join(os.homedir(), ".agentlink", "tools-server.json");
|
|
1775
|
+
|
|
1776
|
+
if (typeof api.tools?.register !== "function") {
|
|
1777
|
+
log.warn(
|
|
1778
|
+
`tool injection unsupported on this OpenClaw version ` +
|
|
1779
|
+
`(api.tools.register is undefined). agentlink_* tools won't be available to ` +
|
|
1780
|
+
`OpenClaw LLM. Continuing with hook-only behavior.`,
|
|
1781
|
+
);
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
async function readDiscovery() {
|
|
1786
|
+
try {
|
|
1787
|
+
const raw = await fsP.readFile(DISCOVERY_PATH, "utf8");
|
|
1788
|
+
const doc = JSON.parse(raw);
|
|
1789
|
+
if (typeof doc?.url === "string" && typeof doc?.auth === "string") {
|
|
1790
|
+
return doc;
|
|
1791
|
+
}
|
|
1792
|
+
} catch (err) {
|
|
1793
|
+
if (err.code !== "ENOENT") {
|
|
1794
|
+
log.warn(`discovery file read failed: ${err.message}`);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
return null;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
async function fetchToolsList(disc) {
|
|
1801
|
+
const res = await fetch(`${disc.url.replace(/\/+$/, "")}/tools/list`, {
|
|
1802
|
+
method: "POST",
|
|
1803
|
+
headers: {
|
|
1804
|
+
"Content-Type": "application/json",
|
|
1805
|
+
Authorization: `Bearer ${disc.auth}`,
|
|
1806
|
+
},
|
|
1807
|
+
body: "{}",
|
|
1808
|
+
});
|
|
1809
|
+
if (!res.ok) {
|
|
1810
|
+
throw new Error(`tools/list HTTP ${res.status}`);
|
|
1811
|
+
}
|
|
1812
|
+
const body = await res.json();
|
|
1813
|
+
return Array.isArray(body?.tools) ? body.tools : [];
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
function makeHandler(toolName, disc) {
|
|
1817
|
+
return async (args) => {
|
|
1818
|
+
const res = await fetch(`${disc.url.replace(/\/+$/, "")}/tools/call`, {
|
|
1819
|
+
method: "POST",
|
|
1820
|
+
headers: {
|
|
1821
|
+
"Content-Type": "application/json",
|
|
1822
|
+
Authorization: `Bearer ${disc.auth}`,
|
|
1823
|
+
},
|
|
1824
|
+
body: JSON.stringify({ name: toolName, arguments: args || {} }),
|
|
1825
|
+
});
|
|
1826
|
+
const body = await res.json();
|
|
1827
|
+
// body 已经是 MCP tools/call 标准 result shape: { content, isError? }
|
|
1828
|
+
return body;
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/** 当前已注册的 tool name 集合,刷新时与新 list 做 diff 决定 register/unregister。 */
|
|
1833
|
+
let registeredNames = new Set();
|
|
1834
|
+
let lastDiscPid = null;
|
|
1835
|
+
|
|
1836
|
+
async function refresh() {
|
|
1837
|
+
const disc = await readDiscovery();
|
|
1838
|
+
if (!disc) {
|
|
1839
|
+
// bridge 没在跑, 不动现有注册(等下次 poll)
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
// PID 变化 → bridge 重启过, 强制重新注册
|
|
1843
|
+
const pidChanged = disc.pid !== lastDiscPid;
|
|
1844
|
+
lastDiscPid = disc.pid;
|
|
1845
|
+
|
|
1846
|
+
let tools;
|
|
1847
|
+
try {
|
|
1848
|
+
tools = await fetchToolsList(disc);
|
|
1849
|
+
} catch (err) {
|
|
1850
|
+
log.warn(`tools/list fetch failed: ${err.message}`);
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
const newNames = new Set(tools.map((t) => t.name));
|
|
1854
|
+
|
|
1855
|
+
// unregister 旧的(已不在 list 里或 PID 变了 = bridge 重启需要全部重新注册)
|
|
1856
|
+
if (pidChanged && typeof api.tools.unregister === "function") {
|
|
1857
|
+
for (const name of registeredNames) {
|
|
1858
|
+
try { api.tools.unregister(name); } catch { /* swallow */ }
|
|
1859
|
+
}
|
|
1860
|
+
registeredNames = new Set();
|
|
1861
|
+
} else if (typeof api.tools.unregister === "function") {
|
|
1862
|
+
for (const name of registeredNames) {
|
|
1863
|
+
if (!newNames.has(name)) {
|
|
1864
|
+
try { api.tools.unregister(name); } catch { /* swallow */ }
|
|
1865
|
+
registeredNames.delete(name);
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// register 新的(同名重复注册若 API 不接受会 throw, 用 try/catch 兜底)
|
|
1871
|
+
for (const tool of tools) {
|
|
1872
|
+
if (registeredNames.has(tool.name)) continue;
|
|
1873
|
+
try {
|
|
1874
|
+
api.tools.register(
|
|
1875
|
+
{
|
|
1876
|
+
name: tool.name,
|
|
1877
|
+
description: tool.description,
|
|
1878
|
+
inputSchema: tool.inputSchema,
|
|
1879
|
+
},
|
|
1880
|
+
makeHandler(tool.name, disc),
|
|
1881
|
+
);
|
|
1882
|
+
registeredNames.add(tool.name);
|
|
1883
|
+
log.info(`registered agentlink tool: ${tool.name}`);
|
|
1884
|
+
} catch (err) {
|
|
1885
|
+
warnThrottled(`tool register failed for ${tool.name}: ${err.message}`);
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// 首次注册
|
|
1891
|
+
await refresh();
|
|
1892
|
+
|
|
1893
|
+
// 30s mtime poller, 检测 bridge 重启或 tools list 变化
|
|
1894
|
+
let prevMtime = 0;
|
|
1895
|
+
try {
|
|
1896
|
+
prevMtime = fs.statSync(DISCOVERY_PATH).mtimeMs;
|
|
1897
|
+
} catch { /* discovery 不存在 = 0 */ }
|
|
1898
|
+
|
|
1899
|
+
const pollInterval = setInterval(async () => {
|
|
1900
|
+
let curMtime = 0;
|
|
1901
|
+
try { curMtime = fs.statSync(DISCOVERY_PATH).mtimeMs; }
|
|
1902
|
+
catch { /* discovery gone, treat as 0 */ }
|
|
1903
|
+
if (curMtime !== prevMtime) {
|
|
1904
|
+
prevMtime = curMtime;
|
|
1905
|
+
await refresh().catch((err) => log.warn(`refresh threw: ${err.message}`));
|
|
1906
|
+
}
|
|
1907
|
+
}, 30_000);
|
|
1908
|
+
pollInterval.unref?.();
|
|
1909
|
+
|
|
1910
|
+
// plugin shutdown 时清掉 poller(若 OpenClaw 提供 beforeExit / unload 钩子)
|
|
1911
|
+
process.on("beforeExit", () => clearInterval(pollInterval));
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
module.exports = {
|
|
1915
|
+
id: "agentlink-hooks",
|
|
1916
|
+
name: "Agentlink Hooks",
|
|
1917
|
+
description: "Captures OpenClaw tool-call, subagent and agent_end lifecycle events and forwards them to the Agentlink relay as unified Agent Protocol envelopes (tool_use / tool_result / run.completed / run.failed).",
|
|
1918
|
+
register,
|
|
1919
|
+
};
|