@jungjaehoon/mama-core 2.4.1 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -239
- package/db/migrations/030-case-first-memory-substrate.sql +13 -98
- package/db/migrations/034-add-connector-event-scope-columns.sql +9 -13
- package/db/migrations/039-add-connector-event-operator-ingest-seq.sql +9 -95
- package/db/migrations/062-refresh-connector-event-sequences.sql +9 -113
- package/db/migrations/063-refresh-legacy-connector-event-updates.sql +9 -32
- package/db/migrations/067-add-connector-event-source-entity-id.sql +9 -13
- package/db/migrations/069-create-registry-nodes.sql +52 -0
- package/db/migrations/070-record-identity.sql +35 -0
- package/db/migrations/071-service-operation-origins.sql +187 -0
- package/db/migrations/072-observation-versions.sql +29 -0
- package/db/migrations/073-registry-corrections.sql +45 -0
- package/db/migrations/074-work-graph-ref-kinds.sql +39 -0
- package/db/migrations/075-agent-judgments.sql +58 -0
- package/db/migrations/076-scoped-checkpoint-bindings.sql +15 -0
- package/db/migrations/077-legacy-record-kind.sql +6 -0
- package/db/migrations/078-source-ingest-commands.sql +18 -0
- package/db/migrations/079-twin-edge-relations.sql +43 -0
- package/db/migrations/080-restore-duration-days.sql +7 -0
- package/db/migrations/081-mailbox.sql +61 -0
- package/db/migrations/082-commitments-row-id.sql +12 -0
- package/db/migrations/083-observation-is-the-evidence.sql +49 -0
- package/db/migrations/084-scope-kind-is-not-the-cores-to-list.sql +34 -0
- package/db/migrations/085-a-migration-has-a-source.sql +37 -0
- package/db/migrations/086-the-mailbox-is-generic.sql +110 -0
- package/db/migrations/087-mailbox-payload.sql +5 -0
- package/db/migrations/088-native-input-journal.sql +14 -0
- package/db/migrations/089-native-turn-receipt-lookup.sql +3 -0
- package/db/migrations/090-model-run-native-inputs.sql +29 -0
- package/db/migrations/091-native-turn-results.sql +11 -0
- package/db/migrations/092-restore-decision-fts.sql +34 -0
- package/db/migrations/093-native-turn-primary-kind.sql +14 -0
- package/db/migrations/094-commitment-run-provenance.sql +9 -0
- package/db/migrations/095-model-run-native-input-identity.sql +39 -0
- package/db/migrations/096-drop-connector-event-index-fts.sql +9 -0
- package/db/migrations/097-native-input-json-validity.sql +37 -0
- package/db/migrations/098-workflow-memory-kind.sql +105 -0
- package/db/migrations/099-commitment-revision-graph.sql +2 -0
- package/dist/action-contracts.d.ts +212 -0
- package/dist/action-contracts.js +10 -0
- package/dist/api/catalog.d.ts +118 -0
- package/dist/api/catalog.js +1756 -0
- package/dist/api/dispatch.d.ts +76 -0
- package/dist/api/dispatch.js +372 -0
- package/dist/canonicalize.d.ts +2 -2
- package/dist/canonicalize.js +3 -3
- package/dist/client/client.d.ts +33 -0
- package/dist/client/client.js +123 -0
- package/dist/client/ipc.d.ts +81 -0
- package/dist/client/ipc.js +314 -0
- package/dist/db-adapter/base-adapter.d.ts +6 -66
- package/dist/db-adapter/base-adapter.js +6 -6
- package/dist/db-adapter/index.d.ts +5 -5
- package/dist/db-adapter/index.js +2 -5
- package/dist/db-adapter/node-sqlite-adapter.d.ts +85 -15
- package/dist/db-adapter/node-sqlite-adapter.js +1985 -570
- package/dist/db-manager.d.ts +53 -97
- package/dist/db-manager.js +121 -603
- package/dist/embedding/embedder.d.ts +132 -0
- package/dist/embedding/embedder.js +299 -0
- package/dist/identity/principal-repository.d.ts +2 -2
- package/dist/index.d.ts +43 -58
- package/dist/index.js +97 -145
- package/dist/{context-compile/channel-grant.d.ts → knowledge/access.d.ts} +12 -17
- package/dist/knowledge/access.js +663 -0
- package/dist/knowledge/case-errors.d.ts +31 -0
- package/dist/knowledge/case-errors.js +40 -0
- package/dist/{cases/search-rollup.d.ts → knowledge/case-search-rollup.d.ts} +5 -4
- package/dist/{cases/search-rollup.js → knowledge/case-search-rollup.js} +6 -8
- package/dist/{cases/store.d.ts → knowledge/case-store.d.ts} +3 -6
- package/dist/{cases/store.js → knowledge/case-store.js} +7 -42
- package/dist/{cases/timeline-range.d.ts → knowledge/case-timeline-range.d.ts} +3 -3
- package/dist/{cases/timeline-range.js → knowledge/case-timeline-range.js} +47 -25
- package/dist/{cases/types.d.ts → knowledge/case-types.d.ts} +8 -2
- package/dist/{cases/types.js → knowledge/case-types.js} +1 -1
- package/dist/knowledge/commitment-revision-migration.d.ts +4 -0
- package/dist/knowledge/commitment-revision-migration.js +50 -0
- package/dist/knowledge/commitments.d.ts +192 -0
- package/dist/knowledge/commitments.js +386 -0
- package/dist/knowledge/decision-edges.d.ts +70 -0
- package/dist/knowledge/decision-edges.js +123 -0
- package/dist/{agent-graph/types.d.ts → knowledge/graph-query.d.ts} +80 -70
- package/dist/knowledge/graph-query.js +1585 -0
- package/dist/knowledge/identity.d.ts +16 -0
- package/dist/knowledge/identity.js +22 -0
- package/dist/knowledge/index.d.ts +47 -0
- package/dist/knowledge/index.js +90 -0
- package/dist/knowledge/judgment-edge.d.ts +6 -0
- package/dist/knowledge/judgment-edge.js +22 -0
- package/dist/knowledge/judgments.d.ts +118 -0
- package/dist/knowledge/judgments.js +802 -0
- package/dist/knowledge/observations.d.ts +160 -0
- package/dist/knowledge/observations.js +449 -0
- package/dist/knowledge/question-type.d.ts +34 -0
- package/dist/knowledge/question-type.js +70 -0
- package/dist/{search → knowledge}/ranker-features.js +1 -1
- package/dist/{search → knowledge}/search-quality.d.ts +0 -1
- package/dist/knowledge/search.d.ts +38 -0
- package/dist/knowledge/search.js +91 -0
- package/dist/knowledge/source-ingest.d.ts +65 -0
- package/dist/knowledge/source-ingest.js +174 -0
- package/dist/{edges/types.d.ts → knowledge/twin-edge-types.d.ts} +31 -4
- package/dist/{edges/types.js → knowledge/twin-edge-types.js} +13 -2
- package/dist/knowledge/work-dates.d.ts +49 -0
- package/dist/knowledge/work-dates.js +120 -0
- package/dist/mama-api.d.ts +114 -778
- package/dist/mama-api.js +313 -2461
- package/dist/memory/api.d.ts +264 -24
- package/dist/memory/api.js +1633 -843
- package/dist/memory/bootstrap-builder.d.ts +2 -1
- package/dist/memory/bootstrap-builder.js +5 -5
- package/dist/memory/channel-summary-state-store.d.ts +3 -2
- package/dist/memory/channel-summary-state-store.js +53 -51
- package/dist/memory/channel-summary-store.d.ts +5 -3
- package/dist/memory/channel-summary-store.js +3 -8
- package/dist/memory/dashboard-read.d.ts +46 -0
- package/dist/memory/dashboard-read.js +94 -0
- package/dist/memory/event-store.d.ts +3 -3
- package/dist/memory/event-store.js +3 -10
- package/dist/memory/finding-store.d.ts +3 -2
- package/dist/memory/finding-store.js +2 -7
- package/dist/memory/graph-read.d.ts +52 -0
- package/dist/memory/graph-read.js +130 -0
- package/dist/memory/judgment-types.d.ts +394 -0
- package/dist/memory/judgment-types.js +16 -0
- package/dist/memory/provenance-audit.d.ts +5 -2
- package/dist/memory/provenance-audit.js +8 -14
- package/dist/memory/provenance-live.d.ts +141 -0
- package/dist/memory/provenance-live.js +409 -0
- package/dist/memory/provenance-query.d.ts +5 -4
- package/dist/memory/provenance-query.js +21 -29
- package/dist/memory/provenance-resolver.d.ts +191 -0
- package/dist/memory/provenance-resolver.js +155 -0
- package/dist/memory/provenance.d.ts +1 -11
- package/dist/memory/provenance.js +2 -33
- package/dist/memory/recall-sanitize.d.ts +55 -0
- package/dist/memory/recall-sanitize.js +144 -0
- package/dist/memory/secret-filter.d.ts +32 -0
- package/dist/memory/secret-filter.js +126 -0
- package/dist/memory/truth-store.d.ts +2 -1
- package/dist/memory/truth-store.js +1 -4
- package/dist/memory/types.d.ts +74 -24
- package/dist/memory/types.js +75 -5
- package/dist/memory/write-adapters.d.ts +98 -0
- package/dist/memory/write-adapters.js +242 -0
- package/dist/operations/owner-action-effects.d.ts +53 -0
- package/dist/operations/owner-action-effects.js +96 -0
- package/dist/provenance/source-ref.d.ts +1 -1
- package/dist/provenance/source-ref.js +0 -2
- package/dist/registry/corrections.d.ts +31 -0
- package/dist/registry/corrections.js +332 -0
- package/dist/registry/record-identity.d.ts +49 -0
- package/dist/registry/record-identity.js +133 -0
- package/dist/registry/store.d.ts +101 -0
- package/dist/registry/store.js +395 -0
- package/dist/registry/types.d.ts +28 -0
- package/dist/registry/types.js +5 -0
- package/dist/relevance-scorer.js +2 -2
- package/dist/runtime/agent-event-bus.d.ts +50 -0
- package/dist/runtime/agent-event-bus.js +101 -0
- package/dist/runtime/drivers/claude-cli-wrapper.d.ts +152 -0
- package/dist/runtime/drivers/claude-cli-wrapper.js +375 -0
- package/dist/runtime/drivers/claude-effort.d.ts +4 -0
- package/dist/runtime/drivers/claude-effort.js +16 -0
- package/dist/runtime/drivers/cli-arg-redaction.d.ts +2 -0
- package/dist/runtime/drivers/cli-arg-redaction.js +18 -0
- package/dist/runtime/drivers/cli-secret-redaction.d.ts +17 -0
- package/dist/runtime/drivers/cli-secret-redaction.js +126 -0
- package/dist/runtime/drivers/codex-app-server-process.d.ts +277 -0
- package/dist/runtime/drivers/codex-app-server-process.js +2342 -0
- package/dist/runtime/drivers/codex-auxiliary-tools.d.ts +17 -0
- package/dist/runtime/drivers/codex-auxiliary-tools.js +267 -0
- package/dist/runtime/drivers/codex-home.d.ts +20 -0
- package/dist/runtime/drivers/codex-home.js +674 -0
- package/dist/runtime/drivers/codex-thread-registry.d.ts +63 -0
- package/dist/runtime/drivers/codex-thread-registry.js +433 -0
- package/dist/runtime/drivers/persistent-cli-adapter.d.ts +160 -0
- package/dist/runtime/drivers/persistent-cli-adapter.js +396 -0
- package/dist/runtime/drivers/persistent-cli-process.d.ts +425 -0
- package/dist/runtime/drivers/persistent-cli-process.js +1529 -0
- package/dist/runtime/drivers/types.d.ts +456 -0
- package/dist/runtime/drivers/types.js +110 -0
- package/dist/runtime/mailbox.d.ts +223 -0
- package/dist/runtime/mailbox.js +582 -0
- package/dist/runtime/model-run-store.d.ts +28 -0
- package/dist/{model-runs/store.js → runtime/model-run-store.js} +81 -41
- package/dist/{model-runs/types.d.ts → runtime/model-run-types.d.ts} +33 -4
- package/dist/{model-runs/types.js → runtime/model-run-types.js} +1 -1
- package/dist/runtime/native-effect-observer.d.ts +22 -0
- package/dist/runtime/native-effect-observer.js +103 -0
- package/dist/runtime/native-input-journal.d.ts +46 -0
- package/dist/runtime/native-input-journal.js +308 -0
- package/dist/runtime/native-prompt.d.ts +118 -0
- package/dist/runtime/native-prompt.js +496 -0
- package/dist/runtime/native-session.d.ts +10 -0
- package/dist/{agent-graph/index.js → runtime/native-session.js} +9 -6
- package/dist/runtime/native-tool-trace-observer.d.ts +5 -0
- package/dist/runtime/native-tool-trace-observer.js +65 -0
- package/dist/runtime/native-turn.d.ts +272 -0
- package/dist/runtime/native-turn.js +1041 -0
- package/dist/runtime/operations.d.ts +236 -0
- package/dist/runtime/operations.js +565 -0
- package/dist/runtime/post-tool-handler.d.ts +74 -0
- package/dist/runtime/post-tool-handler.js +137 -0
- package/dist/runtime/prompt-layers.d.ts +126 -0
- package/dist/runtime/prompt-layers.js +239 -0
- package/dist/runtime/runtime-process.d.ts +102 -0
- package/dist/runtime/runtime-process.js +206 -0
- package/dist/runtime/runtime.d.ts +278 -0
- package/dist/runtime/runtime.js +563 -0
- package/dist/runtime/session-pool.d.ts +176 -0
- package/dist/runtime/session-pool.js +369 -0
- package/dist/runtime/stimulus-payload.d.ts +5 -0
- package/dist/runtime/stimulus-payload.js +50 -0
- package/dist/runtime/subagent-bridge.d.ts +24 -0
- package/dist/{agent-graph/types.js → runtime/subagent-bridge.js} +1 -1
- package/dist/runtime/text-completion.d.ts +22 -0
- package/dist/{agent-situation/types.js → runtime/text-completion.js} +1 -1
- package/dist/runtime/token-estimator.d.ts +25 -0
- package/dist/runtime/token-estimator.js +75 -0
- package/dist/runtime/tool-trace-store.d.ts +22 -0
- package/dist/{model-runs → runtime}/tool-trace-store.js +46 -27
- package/dist/runtime/trace-summary.d.ts +5 -0
- package/dist/runtime/trace-summary.js +74 -0
- package/dist/runtime/turn-text.d.ts +23 -0
- package/dist/runtime/turn-text.js +41 -0
- package/dist/storage/database.d.ts +67 -0
- package/dist/storage/database.js +158 -0
- package/package.json +20 -34
- package/dist/agent-graph/alias-write.d.ts +0 -3
- package/dist/agent-graph/alias-write.js +0 -234
- package/dist/agent-graph/entity-resolve.d.ts +0 -3
- package/dist/agent-graph/entity-resolve.js +0 -271
- package/dist/agent-graph/errors.d.ts +0 -4
- package/dist/agent-graph/errors.js +0 -11
- package/dist/agent-graph/graph-query.d.ts +0 -5
- package/dist/agent-graph/graph-query.js +0 -360
- package/dist/agent-graph/index.d.ts +0 -6
- package/dist/agent-situation/builder.d.ts +0 -8
- package/dist/agent-situation/builder.js +0 -239
- package/dist/agent-situation/cache-key.d.ts +0 -4
- package/dist/agent-situation/cache-key.js +0 -64
- package/dist/agent-situation/index.d.ts +0 -7
- package/dist/agent-situation/index.js +0 -28
- package/dist/agent-situation/packet-store.d.ts +0 -27
- package/dist/agent-situation/packet-store.js +0 -228
- package/dist/agent-situation/ranking-policy.d.ts +0 -18
- package/dist/agent-situation/ranking-policy.js +0 -75
- package/dist/agent-situation/source-readers.d.ts +0 -52
- package/dist/agent-situation/source-readers.js +0 -300
- package/dist/agent-situation/types.d.ts +0 -195
- package/dist/cases/wiki-page-index.d.ts +0 -37
- package/dist/cases/wiki-page-index.js +0 -201
- package/dist/config-loader.d.ts +0 -68
- package/dist/config-loader.js +0 -239
- package/dist/connectors/event-index.d.ts +0 -24
- package/dist/connectors/event-index.js +0 -245
- package/dist/connectors/raw-query.d.ts +0 -40
- package/dist/connectors/raw-query.js +0 -412
- package/dist/connectors/types.d.ts +0 -117
- package/dist/connectors/types.js +0 -3
- package/dist/context-compile/boundary-defaults.d.ts +0 -12
- package/dist/context-compile/boundary-defaults.js +0 -105
- package/dist/context-compile/channel-grant.js +0 -66
- package/dist/context-compile/compiler-policy.d.ts +0 -39
- package/dist/context-compile/compiler-policy.js +0 -147
- package/dist/context-compile/compiler.d.ts +0 -18
- package/dist/context-compile/compiler.js +0 -404
- package/dist/context-compile/index.d.ts +0 -10
- package/dist/context-compile/index.js +0 -26
- package/dist/context-compile/packet-store.d.ts +0 -8
- package/dist/context-compile/packet-store.js +0 -239
- package/dist/context-compile/ref.d.ts +0 -7
- package/dist/context-compile/ref.js +0 -102
- package/dist/context-compile/source-readers.d.ts +0 -70
- package/dist/context-compile/source-readers.js +0 -840
- package/dist/context-compile/types.d.ts +0 -155
- package/dist/context-compile/types.js +0 -11
- package/dist/context-compile/visibility.d.ts +0 -21
- package/dist/context-compile/visibility.js +0 -224
- package/dist/decision-tracker.d.ts +0 -206
- package/dist/decision-tracker.js +0 -481
- package/dist/edges/ref-validation.d.ts +0 -7
- package/dist/edges/ref-validation.js +0 -321
- package/dist/edges/store.d.ts +0 -8
- package/dist/edges/store.js +0 -129
- package/dist/embedding-client.d.ts +0 -28
- package/dist/embedding-client.js +0 -53
- package/dist/embedding-server/index.d.ts +0 -67
- package/dist/embedding-server/index.js +0 -405
- package/dist/embedding-server/mobile/auth.d.ts +0 -44
- package/dist/embedding-server/mobile/auth.js +0 -150
- package/dist/embedding-server/mobile/daemon.d.ts +0 -129
- package/dist/embedding-server/mobile/daemon.js +0 -313
- package/dist/embedding-server/mobile/output-parser.d.ts +0 -115
- package/dist/embedding-server/mobile/output-parser.js +0 -241
- package/dist/embedding-server/mobile/session-api.d.ts +0 -57
- package/dist/embedding-server/mobile/session-api.js +0 -261
- package/dist/embedding-server/mobile/session-manager.d.ts +0 -138
- package/dist/embedding-server/mobile/session-manager.js +0 -378
- package/dist/embedding-server/mobile/websocket-handler.d.ts +0 -137
- package/dist/embedding-server/mobile/websocket-handler.js +0 -486
- package/dist/embeddings.d.ts +0 -68
- package/dist/embeddings.js +0 -244
- package/dist/entities/audit-metrics.d.ts +0 -68
- package/dist/entities/audit-metrics.js +0 -124
- package/dist/entities/candidate-generator.d.ts +0 -8
- package/dist/entities/candidate-generator.js +0 -24
- package/dist/entities/entity-linked-decision-counts.d.ts +0 -2
- package/dist/entities/entity-linked-decision-counts.js +0 -29
- package/dist/entities/entity-list.d.ts +0 -22
- package/dist/entities/entity-list.js +0 -3
- package/dist/entities/entity-orphan-list.d.ts +0 -17
- package/dist/entities/entity-orphan-list.js +0 -3
- package/dist/entities/entity-search.d.ts +0 -20
- package/dist/entities/entity-search.js +0 -3
- package/dist/entities/errors.d.ts +0 -55
- package/dist/entities/errors.js +0 -100
- package/dist/entities/lineage-store.d.ts +0 -28
- package/dist/entities/lineage-store.js +0 -204
- package/dist/entities/normalization.d.ts +0 -14
- package/dist/entities/normalization.js +0 -63
- package/dist/entities/policy-store.d.ts +0 -8
- package/dist/entities/policy-store.js +0 -75
- package/dist/entities/policy-types.d.ts +0 -72
- package/dist/entities/policy-types.js +0 -59
- package/dist/entities/projection.d.ts +0 -8
- package/dist/entities/projection.js +0 -82
- package/dist/entities/provenance-query.d.ts +0 -32
- package/dist/entities/provenance-query.js +0 -3
- package/dist/entities/read-identity.d.ts +0 -31
- package/dist/entities/read-identity.js +0 -264
- package/dist/entities/recall-bridge.d.ts +0 -5
- package/dist/entities/recall-bridge.js +0 -171
- package/dist/entities/resolution-engine.d.ts +0 -8
- package/dist/entities/resolution-engine.js +0 -3
- package/dist/entities/rollback-preview.d.ts +0 -35
- package/dist/entities/rollback-preview.js +0 -3
- package/dist/entities/source-locator.d.ts +0 -3
- package/dist/entities/source-locator.js +0 -32
- package/dist/entities/store.d.ts +0 -69
- package/dist/entities/store.js +0 -544
- package/dist/entities/types.d.ts +0 -166
- package/dist/entities/types.js +0 -30
- package/dist/memory/extraction-prompt.d.ts +0 -4
- package/dist/memory/extraction-prompt.js +0 -89
- package/dist/memory/scope-backfill.d.ts +0 -14
- package/dist/memory/scope-backfill.js +0 -3
- package/dist/memory/scope-store.d.ts +0 -4
- package/dist/memory/scope-store.js +0 -9
- package/dist/memory-store.d.ts +0 -87
- package/dist/memory-store.js +0 -78
- package/dist/model-runs/store.d.ts +0 -13
- package/dist/model-runs/tool-trace-store.d.ts +0 -8
- package/dist/notification-manager.d.ts +0 -7
- package/dist/notification-manager.js +0 -12
- package/dist/outcome-tracker.d.ts +0 -151
- package/dist/outcome-tracker.js +0 -271
- package/dist/query-intent.d.ts +0 -23
- package/dist/query-intent.js +0 -114
- package/dist/runtime/trusted-provenance.d.ts +0 -3
- package/dist/runtime/trusted-provenance.js +0 -6
- package/dist/search/question-type.d.ts +0 -5
- package/dist/search/question-type.js +0 -41
- package/dist/test-utils.d.ts +0 -80
- package/dist/test-utils.js +0 -218
- package/dist/tier-validator.d.ts +0 -43
- package/dist/tier-validator.js +0 -159
- package/dist/time-formatter.d.ts +0 -25
- package/dist/time-formatter.js +0 -93
- /package/dist/{search → knowledge}/feedback-store.d.ts +0 -0
- /package/dist/{search → knowledge}/feedback-store.js +0 -0
- /package/dist/{search → knowledge}/ranker-features.d.ts +0 -0
- /package/dist/{search → knowledge}/ranker-rescore.d.ts +0 -0
- /package/dist/{search → knowledge}/ranker-rescore.js +0 -0
- /package/dist/{search → knowledge}/ranker-trainer.d.ts +0 -0
- /package/dist/{search → knowledge}/ranker-trainer.js +0 -0
- /package/dist/{search → knowledge}/search-quality.js +0 -0
|
@@ -0,0 +1,1529 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Persistent Claude CLI Process - Keeps Claude alive for multi-turn conversations
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS:
|
|
6
|
+
* - Previous approach spawned new Claude CLI process for each message
|
|
7
|
+
* - Each spawn required ~20K system prompt to be sent every time
|
|
8
|
+
* - Result: Slow responses (16-30 seconds per message)
|
|
9
|
+
*
|
|
10
|
+
* NEW ARCHITECTURE:
|
|
11
|
+
* - Keep Claude process alive using stream-json input/output
|
|
12
|
+
* - Send messages via stdin, receive responses via stdout
|
|
13
|
+
* - Session memory preserved in Claude's context
|
|
14
|
+
* - System prompt sent only once at process start
|
|
15
|
+
*
|
|
16
|
+
* STREAM-JSON PROTOCOL:
|
|
17
|
+
* Input (stdin):
|
|
18
|
+
* User message: {"type":"user","message":{"role":"user","content":"..."}}
|
|
19
|
+
* Tool result: {"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"xxx","content":"...","is_error":false}]}}
|
|
20
|
+
*
|
|
21
|
+
* Output (stdout):
|
|
22
|
+
* Init: {"type":"system","subtype":"init",...}
|
|
23
|
+
* Assistant: {"type":"assistant","message":{...}}
|
|
24
|
+
* Tool use: Content block with type="tool_use" in assistant message
|
|
25
|
+
* Result: {"type":"result","subtype":"success",...}
|
|
26
|
+
*/
|
|
27
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
28
|
+
if (k2 === undefined) k2 = k;
|
|
29
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
30
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
31
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
32
|
+
}
|
|
33
|
+
Object.defineProperty(o, k2, desc);
|
|
34
|
+
}) : (function(o, m, k, k2) {
|
|
35
|
+
if (k2 === undefined) k2 = k;
|
|
36
|
+
o[k2] = m[k];
|
|
37
|
+
}));
|
|
38
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
39
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
40
|
+
}) : function(o, v) {
|
|
41
|
+
o["default"] = v;
|
|
42
|
+
});
|
|
43
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
44
|
+
var ownKeys = function(o) {
|
|
45
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
46
|
+
var ar = [];
|
|
47
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
48
|
+
return ar;
|
|
49
|
+
};
|
|
50
|
+
return ownKeys(o);
|
|
51
|
+
};
|
|
52
|
+
return function (mod) {
|
|
53
|
+
if (mod && mod.__esModule) return mod;
|
|
54
|
+
var result = {};
|
|
55
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
56
|
+
__setModuleDefault(result, mod);
|
|
57
|
+
return result;
|
|
58
|
+
};
|
|
59
|
+
})();
|
|
60
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
61
|
+
exports.PersistentProcessPool = exports.PersistentClaudeProcess = void 0;
|
|
62
|
+
exports.formatClaudeArgsForLog = formatClaudeArgsForLog;
|
|
63
|
+
const child_process_1 = require("child_process");
|
|
64
|
+
const crypto_1 = require("crypto");
|
|
65
|
+
const path_1 = require("path");
|
|
66
|
+
const fs_1 = require("fs");
|
|
67
|
+
const events_1 = require("events");
|
|
68
|
+
const types_js_1 = require("./types.js");
|
|
69
|
+
const debugLogger = __importStar(require("@jungjaehoon/mama-core/debug-logger"));
|
|
70
|
+
const cli_secret_redaction_js_1 = require("./cli-secret-redaction.js");
|
|
71
|
+
const cli_arg_redaction_js_1 = require("./cli-arg-redaction.js");
|
|
72
|
+
const claude_effort_js_1 = require("./claude-effort.js");
|
|
73
|
+
const { DebugLogger } = debugLogger;
|
|
74
|
+
const persistentLogger = new DebugLogger('PersistentCLI');
|
|
75
|
+
const poolLogger = new DebugLogger('ProcessPool');
|
|
76
|
+
/**
|
|
77
|
+
* Regex to strip lone Unicode surrogates that cause API 400 errors.
|
|
78
|
+
* Matches high surrogates not followed by a low surrogate, and
|
|
79
|
+
* low surrogates not preceded by a high surrogate.
|
|
80
|
+
*/
|
|
81
|
+
// eslint-disable-next-line no-control-regex
|
|
82
|
+
const LONE_SURROGATE_RE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
83
|
+
/** The native Claude Code delegation tool. `run_in_background: true` is the delegating shape. */
|
|
84
|
+
const BACKGROUND_AGENT_TOOL = 'Agent';
|
|
85
|
+
/** Bound on the child's final text carried to the host wake. Mirrors Codex's limit. */
|
|
86
|
+
const MAX_SUBAGENT_FINAL_TEXT_CHARS = 4_000;
|
|
87
|
+
/**
|
|
88
|
+
* How long a `task_notification` waits for the CLI's own follow-up turn before the host
|
|
89
|
+
* takes the wake itself. Measured: `init` follows the notification immediately.
|
|
90
|
+
*/
|
|
91
|
+
const AUTONOMOUS_TURN_GRACE_MS = 5_000;
|
|
92
|
+
/** Identity, not history: finished children are dropped, live ones are few. */
|
|
93
|
+
const MAX_TRACKED_BACKGROUND_AGENTS = 50;
|
|
94
|
+
const MAX_STREAM_TOOL_RESULT_CHARS = 64 * 1024;
|
|
95
|
+
function stableJson(value) {
|
|
96
|
+
if (value === null || typeof value !== 'object') {
|
|
97
|
+
const serialized = JSON.stringify(value);
|
|
98
|
+
return serialized === undefined ? 'undefined' : serialized;
|
|
99
|
+
}
|
|
100
|
+
if (Array.isArray(value)) {
|
|
101
|
+
return `[${value.map((item) => stableJson(item)).join(',')}]`;
|
|
102
|
+
}
|
|
103
|
+
const record = value;
|
|
104
|
+
return `{${Object.keys(record)
|
|
105
|
+
.sort()
|
|
106
|
+
.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`)
|
|
107
|
+
.join(',')}}`;
|
|
108
|
+
}
|
|
109
|
+
function normalizeStreamToolResultContent(content) {
|
|
110
|
+
const normalized = typeof content === 'string'
|
|
111
|
+
? content
|
|
112
|
+
: Array.isArray(content)
|
|
113
|
+
? content
|
|
114
|
+
.filter((block) => block.type === 'text' && typeof block.text === 'string')
|
|
115
|
+
.map((block) => block.text)
|
|
116
|
+
.join('\n')
|
|
117
|
+
: '';
|
|
118
|
+
return normalized.slice(0, MAX_STREAM_TOOL_RESULT_CHARS);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* PersistentClaudeProcess - Manages a single long-lived Claude CLI process
|
|
122
|
+
*
|
|
123
|
+
* Features:
|
|
124
|
+
* - Keeps Claude process alive for multiple messages
|
|
125
|
+
* - Uses stream-json for bidirectional communication
|
|
126
|
+
* - Handles tool execution via Gateway Tools
|
|
127
|
+
* - Auto-restarts on process death
|
|
128
|
+
*/
|
|
129
|
+
class PersistentClaudeProcess extends events_1.EventEmitter {
|
|
130
|
+
process = null;
|
|
131
|
+
options;
|
|
132
|
+
state = 'dead';
|
|
133
|
+
outputBuffer = '';
|
|
134
|
+
currentCallbacks = null;
|
|
135
|
+
/**
|
|
136
|
+
* The most recent request that offered a follow-up sink. A CLI turn that no stdin request
|
|
137
|
+
* and no tracked child explains (a grandchild's task notification, measured 2026-09-10)
|
|
138
|
+
* still answers SOMEONE: the last request that asked.
|
|
139
|
+
*/
|
|
140
|
+
lastFollowUp;
|
|
141
|
+
currentResolve = null;
|
|
142
|
+
currentReject = null;
|
|
143
|
+
currentInputUuid = null;
|
|
144
|
+
inputAcknowledged = false;
|
|
145
|
+
requestTimeoutHandle = null;
|
|
146
|
+
toolUseBlocks = [];
|
|
147
|
+
promptToolExchanges = new Map();
|
|
148
|
+
completedToolExchanges = [];
|
|
149
|
+
awaitingToolResults = false;
|
|
150
|
+
pendingToolUseStartedAt = null;
|
|
151
|
+
accumulatedText = '';
|
|
152
|
+
startPromise = null;
|
|
153
|
+
onTokenUsage;
|
|
154
|
+
/** Live + just-finished native background Agents, keyed by their `Agent` tool_use id. */
|
|
155
|
+
backgroundAgents = new Map();
|
|
156
|
+
/**
|
|
157
|
+
* True once a turn's `result` resolved while a background child was still running. From
|
|
158
|
+
* then until the next stdin request, unattributed assistant/user events on the stream
|
|
159
|
+
* belong to that child, not to a parent turn that no longer exists.
|
|
160
|
+
*/
|
|
161
|
+
parentTurnEnded = false;
|
|
162
|
+
/** Set between a `task_notification` and the CLI's own follow-up turn. */
|
|
163
|
+
pendingNotification = null;
|
|
164
|
+
/** The CLI-initiated turn currently running with no stdin request behind it. */
|
|
165
|
+
autonomousTurn = null;
|
|
166
|
+
/**
|
|
167
|
+
* Resolve the effective request timeout in ms.
|
|
168
|
+
* 0 = unlimited (no timeout). Returns the configured or default value.
|
|
169
|
+
*/
|
|
170
|
+
_getRequestTimeoutMs() {
|
|
171
|
+
if (this.options.requestTimeout !== undefined && this.options.requestTimeout !== null) {
|
|
172
|
+
return Math.max(0, this.options.requestTimeout);
|
|
173
|
+
}
|
|
174
|
+
return Math.max(0, this.options.timeouts?.requestMs ?? 120_000);
|
|
175
|
+
}
|
|
176
|
+
constructor(options) {
|
|
177
|
+
super();
|
|
178
|
+
this.options = options;
|
|
179
|
+
this.onTokenUsage = options.onTokenUsage;
|
|
180
|
+
// Register default error handler to prevent Node crash if no listeners attached
|
|
181
|
+
this.on('error', (err) => {
|
|
182
|
+
console.error('[PersistentCLI] Unhandled error event:', err);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Start the Claude CLI process
|
|
187
|
+
*
|
|
188
|
+
* Note: The CLI only emits the 'init' event after receiving the first user message.
|
|
189
|
+
* So we don't wait for init here - we just start the process and let it run.
|
|
190
|
+
* The first sendMessage() call will handle init as part of its response flow.
|
|
191
|
+
*/
|
|
192
|
+
async start() {
|
|
193
|
+
// Serialize concurrent start() calls — if already starting, wait for that to finish
|
|
194
|
+
if (this.startPromise) {
|
|
195
|
+
return this.startPromise;
|
|
196
|
+
}
|
|
197
|
+
if (this.state !== 'dead') {
|
|
198
|
+
persistentLogger.info(`[PersistentCLI] Process already in state: ${this.state}`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
this.startPromise = this.doStart();
|
|
202
|
+
try {
|
|
203
|
+
await this.startPromise;
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
this.startPromise = null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async doStart() {
|
|
210
|
+
this.state = 'starting';
|
|
211
|
+
persistentLogger.info(`[PersistentCLI] Starting process for session: ${this.options.sessionId}`);
|
|
212
|
+
const args = this.buildArgs();
|
|
213
|
+
persistentLogger.info(`[PersistentCLI] Spawning: claude ${formatClaudeArgsForLog(args).join(' ')}`);
|
|
214
|
+
// Clean environment: Remove conflicting MAMA_* variables before merging
|
|
215
|
+
const cleanEnv = { ...(this.options.processEnv ?? process.env) };
|
|
216
|
+
const processOptionsEnv = { ...(this.options.env || {}) };
|
|
217
|
+
if (this.options.env) {
|
|
218
|
+
// If we're setting MAMA_DISABLE_HOOKS, remove MAMA_HOOK_FEATURES
|
|
219
|
+
if ('MAMA_DISABLE_HOOKS' in this.options.env) {
|
|
220
|
+
delete cleanEnv.MAMA_HOOK_FEATURES;
|
|
221
|
+
}
|
|
222
|
+
// If we're setting MAMA_HOOK_FEATURES, remove MAMA_DISABLE_HOOKS
|
|
223
|
+
if ('MAMA_HOOK_FEATURES' in this.options.env) {
|
|
224
|
+
delete cleanEnv.MAMA_DISABLE_HOOKS;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// ============================================================
|
|
228
|
+
// ⚠️ MAMA OS AGENT ISOLATION — DO NOT MODIFY ⚠️
|
|
229
|
+
// ============================================================
|
|
230
|
+
// MAMA OS agents must operate only within the .mama scope, not globally.
|
|
231
|
+
//
|
|
232
|
+
// WHY: Claude Code CLI traverses upward from cwd to find CLAUDE.md.
|
|
233
|
+
// cwd = the user's home → ~/CLAUDE.md gets injected (their personal config leaks
|
|
234
|
+
// into the agent)
|
|
235
|
+
// cwd = <host workspace> + git boundary → traversal stops here
|
|
236
|
+
//
|
|
237
|
+
// HOW: Create <host workspace>/.git/HEAD so Claude Code treats it as a git repo root.
|
|
238
|
+
// This prevents Claude Code from searching for CLAUDE.md above this directory.
|
|
239
|
+
//
|
|
240
|
+
// FILE ACCESS: cwd only restricts CLAUDE.md discovery. If --dangerously-skip-permissions
|
|
241
|
+
// is enabled, the agent can still access all files on the system.
|
|
242
|
+
//
|
|
243
|
+
// Pointing cwd back at the user's home, or removing the git boundary, will cause
|
|
244
|
+
// ~/CLAUDE.md + global plugins to be re-injected every turn, wasting tokens.
|
|
245
|
+
// (This driver does not know where that home is, and must not: the workspace is
|
|
246
|
+
// the caller's to state.)
|
|
247
|
+
// ============================================================
|
|
248
|
+
const workspaceDir = this.options.workspaceDir;
|
|
249
|
+
if (!(0, fs_1.existsSync)(workspaceDir)) {
|
|
250
|
+
(0, fs_1.mkdirSync)(workspaceDir, { recursive: true });
|
|
251
|
+
}
|
|
252
|
+
const gitDir = (0, path_1.join)(workspaceDir, '.git');
|
|
253
|
+
if (!(0, fs_1.existsSync)(gitDir)) {
|
|
254
|
+
(0, fs_1.mkdirSync)(gitDir, { recursive: true });
|
|
255
|
+
}
|
|
256
|
+
const headFile = (0, path_1.join)(gitDir, 'HEAD');
|
|
257
|
+
if (!(0, fs_1.existsSync)(headFile)) {
|
|
258
|
+
(0, fs_1.writeFileSync)(headFile, 'ref: refs/heads/main\n');
|
|
259
|
+
}
|
|
260
|
+
const stderrRedactor = new cli_secret_redaction_js_1.SecretRedactingStream((0, cli_secret_redaction_js_1.claudeConfiguredSecrets)(this.options.mcpConfigPath, {
|
|
261
|
+
...(this.options.processEnv ?? process.env),
|
|
262
|
+
...this.options.env,
|
|
263
|
+
}), (safe) => {
|
|
264
|
+
if (safe.trim())
|
|
265
|
+
console.error(`[PersistentCLI:stderr] ${safe.trim()}`);
|
|
266
|
+
});
|
|
267
|
+
this.process = (0, child_process_1.spawn)('claude', args, {
|
|
268
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
269
|
+
// ⚠️ NEVER spawn in the user's home directory — it breaks agent isolation.
|
|
270
|
+
cwd: workspaceDir,
|
|
271
|
+
env: {
|
|
272
|
+
...cleanEnv,
|
|
273
|
+
...processOptionsEnv,
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
// Set up event handlers
|
|
277
|
+
this.process.stdout?.on('data', (chunk) => this.handleStdout(chunk));
|
|
278
|
+
this.process.stderr?.on('data', (chunk) => stderrRedactor.write(chunk));
|
|
279
|
+
this.process.stderr?.on('end', () => stderrRedactor.end());
|
|
280
|
+
this.process.on('close', (code) => {
|
|
281
|
+
stderrRedactor.end();
|
|
282
|
+
this.handleClose(code);
|
|
283
|
+
});
|
|
284
|
+
this.process.on('error', (error) => this.handleError(error));
|
|
285
|
+
// Don't wait for init - CLI only emits it after first user message
|
|
286
|
+
// Just wait a brief moment for the process to stabilize
|
|
287
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
288
|
+
// Guard: Only set idle if still in starting state AND process is alive
|
|
289
|
+
// handleClose could have been called during the setTimeout above (→ state='dead')
|
|
290
|
+
// Note: TS can't track async state mutations from event handlers, so cast is needed
|
|
291
|
+
const currentState = this.state;
|
|
292
|
+
const pid = this.process?.pid;
|
|
293
|
+
if (currentState === 'starting' && pid && !this.process?.killed) {
|
|
294
|
+
this.state = 'idle';
|
|
295
|
+
persistentLogger.info(`[PersistentCLI] Process started and waiting for first message`);
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
this.state = 'dead';
|
|
299
|
+
throw new Error('Process failed to start');
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Build CLI arguments for stream-json mode
|
|
304
|
+
*/
|
|
305
|
+
buildArgs() {
|
|
306
|
+
const args = [
|
|
307
|
+
'--print',
|
|
308
|
+
'--verbose',
|
|
309
|
+
'--input-format',
|
|
310
|
+
'stream-json',
|
|
311
|
+
'--output-format',
|
|
312
|
+
'stream-json',
|
|
313
|
+
'--replay-user-messages',
|
|
314
|
+
'--session-id',
|
|
315
|
+
this.options.sessionId,
|
|
316
|
+
// ============================================================
|
|
317
|
+
// ⚠️ BLOCK GLOBAL SETTINGS — DO NOT REMOVE ⚠️
|
|
318
|
+
// ============================================================
|
|
319
|
+
// Excluding 'user' from --setting-sources prevents loading ~/.claude/settings.json.
|
|
320
|
+
// That file contains enabledPlugins, so including 'user' causes global plugins
|
|
321
|
+
// (superpowers, bmad, etc.) to be injected every turn.
|
|
322
|
+
// --plugin-dir alone is NOT sufficient (it's additive, not an override).
|
|
323
|
+
// ============================================================
|
|
324
|
+
'--setting-sources',
|
|
325
|
+
'project,local',
|
|
326
|
+
];
|
|
327
|
+
if (this.options.model) {
|
|
328
|
+
args.push('--model', this.options.model);
|
|
329
|
+
}
|
|
330
|
+
if (this.options.systemPrompt) {
|
|
331
|
+
args.push('--system-prompt', this.options.systemPrompt);
|
|
332
|
+
}
|
|
333
|
+
// Hybrid mode: MCP + Gateway can both be enabled
|
|
334
|
+
if (this.options.mcpConfigPath) {
|
|
335
|
+
args.push('--mcp-config', this.options.mcpConfigPath);
|
|
336
|
+
args.push('--strict-mcp-config');
|
|
337
|
+
persistentLogger.info('MCP enabled:', this.options.mcpConfigPath);
|
|
338
|
+
}
|
|
339
|
+
if (this.options.useGatewayTools) {
|
|
340
|
+
persistentLogger.info('Gateway Tools mode enabled');
|
|
341
|
+
}
|
|
342
|
+
args.push(...(0, claude_effort_js_1.claudeEffortArgs)(this.options.model, this.options.effort));
|
|
343
|
+
if (this.options.permissionMode) {
|
|
344
|
+
args.push('--permission-mode', this.options.permissionMode);
|
|
345
|
+
}
|
|
346
|
+
if (this.options.dangerouslySkipPermissions) {
|
|
347
|
+
args.push('--dangerously-skip-permissions');
|
|
348
|
+
}
|
|
349
|
+
// Override built-in tool set (e.g. --tools "" to disable all)
|
|
350
|
+
if (this.options.tools !== undefined) {
|
|
351
|
+
args.push('--tools', this.options.tools);
|
|
352
|
+
}
|
|
353
|
+
// ============================================================
|
|
354
|
+
// ⚠️ PLUGIN ISOLATION — DO NOT REMOVE ⚠️
|
|
355
|
+
// ============================================================
|
|
356
|
+
// Points --plugin-dir to an empty directory so Claude Code cannot load
|
|
357
|
+
// global plugins. MAMA already includes everything needed via --system-prompt,
|
|
358
|
+
// so loading plugins would cause duplicate injection of the same content.
|
|
359
|
+
//
|
|
360
|
+
// Removing this causes skills/CLAUDE.md to be re-injected as system-reminder
|
|
361
|
+
// every turn, wasting thousands of tokens.
|
|
362
|
+
// ============================================================
|
|
363
|
+
const pluginDir = this.options.pluginDir ?? (0, path_1.join)(this.options.workspaceDir, '.empty-plugins');
|
|
364
|
+
if (!(0, fs_1.existsSync)(pluginDir)) {
|
|
365
|
+
(0, fs_1.mkdirSync)(pluginDir, { recursive: true });
|
|
366
|
+
}
|
|
367
|
+
args.push('--plugin-dir', pluginDir);
|
|
368
|
+
// Structural tool enforcement via CLI flags
|
|
369
|
+
if (this.options.allowedTools?.length) {
|
|
370
|
+
args.push('--allowedTools', ...this.options.allowedTools);
|
|
371
|
+
}
|
|
372
|
+
if (this.options.disallowedTools?.length) {
|
|
373
|
+
args.push('--disallowedTools', ...this.options.disallowedTools);
|
|
374
|
+
}
|
|
375
|
+
return args;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Send a user message to Claude
|
|
379
|
+
*/
|
|
380
|
+
async sendMessage(content, callbacks, nativeInputId) {
|
|
381
|
+
if (nativeInputId !== undefined &&
|
|
382
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(nativeInputId)) {
|
|
383
|
+
throw new Error('Native input ID must be a UUID');
|
|
384
|
+
}
|
|
385
|
+
if (this.state === 'dead') {
|
|
386
|
+
await this.start();
|
|
387
|
+
}
|
|
388
|
+
// If another caller is starting, wait for it to finish
|
|
389
|
+
if (this.startPromise) {
|
|
390
|
+
await this.startPromise;
|
|
391
|
+
}
|
|
392
|
+
// Prevent concurrent requests during active processing
|
|
393
|
+
if (this.state === 'busy') {
|
|
394
|
+
throw new Error('Process is busy with another request');
|
|
395
|
+
}
|
|
396
|
+
if (this.state === 'starting' || this.state === 'dead') {
|
|
397
|
+
throw new Error(`Process is not ready (state: ${this.state})`);
|
|
398
|
+
}
|
|
399
|
+
this.state = 'busy';
|
|
400
|
+
this.awaitingToolResults = false;
|
|
401
|
+
this.pendingToolUseStartedAt = null;
|
|
402
|
+
this.currentCallbacks = callbacks || null;
|
|
403
|
+
this.currentInputUuid = nativeInputId ?? (0, crypto_1.randomUUID)();
|
|
404
|
+
this.inputAcknowledged = false;
|
|
405
|
+
this.toolUseBlocks = [];
|
|
406
|
+
this.promptToolExchanges.clear();
|
|
407
|
+
this.completedToolExchanges = [];
|
|
408
|
+
this.accumulatedText = '';
|
|
409
|
+
// A live child keeps running, but this turn's own events are the parent's again.
|
|
410
|
+
this.parentTurnEnded = false;
|
|
411
|
+
return new Promise((resolve, reject) => {
|
|
412
|
+
this.currentResolve = resolve;
|
|
413
|
+
this.currentReject = reject;
|
|
414
|
+
// Set request timeout (0 = unlimited, skip timeout entirely)
|
|
415
|
+
const timeoutMs = this._getRequestTimeoutMs();
|
|
416
|
+
if (timeoutMs > 0) {
|
|
417
|
+
this.requestTimeoutHandle = setTimeout(() => {
|
|
418
|
+
this.handleTimeout();
|
|
419
|
+
}, timeoutMs);
|
|
420
|
+
}
|
|
421
|
+
// Strip lone surrogates to prevent API 400 errors
|
|
422
|
+
const safeContent = content.replace(LONE_SURROGATE_RE, '');
|
|
423
|
+
const message = {
|
|
424
|
+
type: 'user',
|
|
425
|
+
uuid: this.currentInputUuid,
|
|
426
|
+
session_id: this.options.sessionId,
|
|
427
|
+
parent_tool_use_id: null,
|
|
428
|
+
message: {
|
|
429
|
+
role: 'user',
|
|
430
|
+
content: safeContent,
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
const jsonLine = JSON.stringify(message) + '\n';
|
|
434
|
+
persistentLogger.info(`[PersistentCLI] Sending message (${content.length} chars)`);
|
|
435
|
+
if (!this.process?.stdin?.writable) {
|
|
436
|
+
this.handleError(new Error('Process stdin not writable'));
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
callbacks?.onInputDispatch?.({
|
|
441
|
+
backend: 'claude',
|
|
442
|
+
sessionId: this.options.sessionId,
|
|
443
|
+
inputId: this.currentInputUuid,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
this.handleError(error instanceof Error ? error : new Error(String(error)));
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
this.process.stdin.write(jsonLine, (err) => {
|
|
451
|
+
if (err) {
|
|
452
|
+
this.handleError(err);
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Send a tool result back to Claude
|
|
459
|
+
*/
|
|
460
|
+
async sendToolResult(toolUseId, result, isError = false, callbacks) {
|
|
461
|
+
return this.sendToolResults([{ tool_use_id: toolUseId, content: result, is_error: isError }], callbacks);
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Send multiple tool results back to Claude in a single message.
|
|
465
|
+
* Required when Claude requests multiple tools in one turn.
|
|
466
|
+
*/
|
|
467
|
+
async sendToolResults(results, callbacks) {
|
|
468
|
+
if (this.state === 'dead') {
|
|
469
|
+
throw new Error('Cannot send tool result: process is dead');
|
|
470
|
+
}
|
|
471
|
+
if (this.state !== 'idle') {
|
|
472
|
+
throw new Error(`Cannot send tool result in state: ${this.state}`);
|
|
473
|
+
}
|
|
474
|
+
this.state = 'busy';
|
|
475
|
+
this.awaitingToolResults = false;
|
|
476
|
+
this.pendingToolUseStartedAt = null;
|
|
477
|
+
this.currentCallbacks = callbacks || null;
|
|
478
|
+
this.toolUseBlocks = [];
|
|
479
|
+
this.promptToolExchanges.clear();
|
|
480
|
+
this.completedToolExchanges = [];
|
|
481
|
+
this.accumulatedText = '';
|
|
482
|
+
// A live child keeps running, but this turn's own events are the parent's again.
|
|
483
|
+
this.parentTurnEnded = false;
|
|
484
|
+
return new Promise((resolve, reject) => {
|
|
485
|
+
this.currentResolve = resolve;
|
|
486
|
+
this.currentReject = reject;
|
|
487
|
+
// Set request timeout (0 = unlimited, skip timeout entirely)
|
|
488
|
+
const timeoutMs = this._getRequestTimeoutMs();
|
|
489
|
+
if (timeoutMs > 0) {
|
|
490
|
+
this.requestTimeoutHandle = setTimeout(() => {
|
|
491
|
+
this.handleTimeout();
|
|
492
|
+
}, timeoutMs);
|
|
493
|
+
}
|
|
494
|
+
// Strip lone surrogates from tool results to prevent API 400 errors
|
|
495
|
+
const message = {
|
|
496
|
+
type: 'user',
|
|
497
|
+
message: {
|
|
498
|
+
role: 'user',
|
|
499
|
+
content: results.map((r) => ({
|
|
500
|
+
type: 'tool_result',
|
|
501
|
+
tool_use_id: r.tool_use_id,
|
|
502
|
+
content: r.content.replace(LONE_SURROGATE_RE, ''),
|
|
503
|
+
is_error: r.is_error,
|
|
504
|
+
})),
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
const jsonLine = JSON.stringify(message) + '\n';
|
|
508
|
+
persistentLogger.info(`[PersistentCLI] Sending ${results.length} tool_result(s): ${results.map((r) => r.tool_use_id).join(', ')}`);
|
|
509
|
+
if (!this.process?.stdin?.writable) {
|
|
510
|
+
this.handleError(new Error('Process stdin not writable'));
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
this.process.stdin.write(jsonLine, (err) => {
|
|
514
|
+
if (err) {
|
|
515
|
+
this.handleError(err);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Handle stdout data
|
|
522
|
+
*/
|
|
523
|
+
handleStdout(chunk) {
|
|
524
|
+
this.outputBuffer += chunk.toString();
|
|
525
|
+
// Process complete lines
|
|
526
|
+
const lines = this.outputBuffer.split('\n');
|
|
527
|
+
this.outputBuffer = lines.pop() || ''; // Keep incomplete line in buffer
|
|
528
|
+
for (const line of lines) {
|
|
529
|
+
if (!line.trim())
|
|
530
|
+
continue;
|
|
531
|
+
try {
|
|
532
|
+
const event = JSON.parse(line);
|
|
533
|
+
this.processEvent(event);
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
console.warn(`[PersistentCLI] Failed to parse JSON: ${line.substring(0, 100)}...`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// Try to parse buffer as complete JSON (handles case where line doesn't end with newline)
|
|
540
|
+
// This is needed because Claude CLI may not flush a trailing newline when waiting for stdin
|
|
541
|
+
if (this.outputBuffer.trim()) {
|
|
542
|
+
try {
|
|
543
|
+
const event = JSON.parse(this.outputBuffer);
|
|
544
|
+
this.processEvent(event);
|
|
545
|
+
this.outputBuffer = ''; // Clear buffer after successful parse
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
// Not complete JSON yet, wait for more data
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Process a parsed event from stdout
|
|
554
|
+
*/
|
|
555
|
+
processEvent(event) {
|
|
556
|
+
switch (event.type) {
|
|
557
|
+
case 'system':
|
|
558
|
+
if (event.subtype === 'init') {
|
|
559
|
+
// A notification followed by `init` with no stdin request behind it is the CLI
|
|
560
|
+
// opening its OWN turn for the finished child. It must be surfaced BEFORE its
|
|
561
|
+
// assistant text arrives, so the host can bind it to a run.
|
|
562
|
+
if (this.pendingNotification && this.currentResolve === null) {
|
|
563
|
+
this.beginAutonomousTurn();
|
|
564
|
+
}
|
|
565
|
+
persistentLogger.info(`[PersistentCLI] Received init event`);
|
|
566
|
+
// Init event received (logged for debugging)
|
|
567
|
+
this.emit('init', event);
|
|
568
|
+
}
|
|
569
|
+
else if (event.subtype === 'hook_response') {
|
|
570
|
+
// Hook responses - could extract context if needed
|
|
571
|
+
persistentLogger.info(`[PersistentCLI] Hook response received`);
|
|
572
|
+
}
|
|
573
|
+
else if (event.subtype === 'task_started') {
|
|
574
|
+
this.recordBackgroundTaskStarted(event);
|
|
575
|
+
}
|
|
576
|
+
else if (event.subtype === 'task_notification') {
|
|
577
|
+
this.recordBackgroundTaskNotification(event);
|
|
578
|
+
}
|
|
579
|
+
break;
|
|
580
|
+
case 'assistant':
|
|
581
|
+
if (this.autonomousTurn) {
|
|
582
|
+
// The CLI's own follow-up turn: its text is that turn's answer, never this
|
|
583
|
+
// process's next stdin result, and its tool calls are not parent tool uses.
|
|
584
|
+
for (const block of Array.isArray(event.message?.content) ? event.message.content : []) {
|
|
585
|
+
if (block.type === 'text') {
|
|
586
|
+
this.autonomousTurn.text += block.text || '';
|
|
587
|
+
}
|
|
588
|
+
else if (block.type === 'tool_use') {
|
|
589
|
+
persistentLogger.info(`[PersistentCLI] autonomous turn tool use: ${block.name ?? 'unknown'}`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
{
|
|
595
|
+
const child = this.childAgentFor(event);
|
|
596
|
+
if (child) {
|
|
597
|
+
this.absorbChildAssistant(child, event);
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// Process assistant message content
|
|
602
|
+
if (Array.isArray(event.message?.content)) {
|
|
603
|
+
for (const block of event.message.content) {
|
|
604
|
+
if (block.type === 'text') {
|
|
605
|
+
this.accumulatedText += block.text || '';
|
|
606
|
+
this.currentCallbacks?.onDelta?.(block.text || '');
|
|
607
|
+
}
|
|
608
|
+
else if (block.type === 'tool_use') {
|
|
609
|
+
const toolName = block.name ?? 'unknown';
|
|
610
|
+
const toolUse = {
|
|
611
|
+
type: 'tool_use',
|
|
612
|
+
id: block.id || `tool_${(0, crypto_1.randomUUID)()}`,
|
|
613
|
+
name: toolName,
|
|
614
|
+
input: block.input || {},
|
|
615
|
+
};
|
|
616
|
+
if (!this.recordToolUse(toolUse)) {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
break;
|
|
623
|
+
case 'user':
|
|
624
|
+
if (this.currentInputUuid !== null &&
|
|
625
|
+
event.uuid === this.currentInputUuid &&
|
|
626
|
+
event.session_id === this.options.sessionId &&
|
|
627
|
+
!event.parent_tool_use_id &&
|
|
628
|
+
event.message?.role === 'user' &&
|
|
629
|
+
(!event.origin || event.origin.kind === 'human')) {
|
|
630
|
+
if (!this.inputAcknowledged) {
|
|
631
|
+
this.inputAcknowledged = true;
|
|
632
|
+
const receipt = {
|
|
633
|
+
backend: 'claude',
|
|
634
|
+
sessionId: event.session_id,
|
|
635
|
+
inputId: this.currentInputUuid,
|
|
636
|
+
};
|
|
637
|
+
try {
|
|
638
|
+
this.currentCallbacks?.onAccepted?.(receipt);
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
this.handleError(new types_js_1.NativeInputUncertainError('Native receipt observer failed after acceptance', receipt, error));
|
|
642
|
+
this.stop();
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
if (this.autonomousTurn) {
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
{
|
|
651
|
+
const child = this.childAgentFor(event);
|
|
652
|
+
if (child) {
|
|
653
|
+
for (const block of Array.isArray(event.message?.content)
|
|
654
|
+
? event.message.content
|
|
655
|
+
: []) {
|
|
656
|
+
if (block.type === 'tool_result' && block.tool_use_id) {
|
|
657
|
+
const tool = child.nativeItems.get(block.tool_use_id);
|
|
658
|
+
if (tool && !tool.completed) {
|
|
659
|
+
tool.completed = true;
|
|
660
|
+
child.onToolComplete?.(tool.name, block.tool_use_id, block.is_error === true);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
if (Array.isArray(event.message?.content)) {
|
|
668
|
+
for (const block of event.message.content) {
|
|
669
|
+
if (block.type === 'tool_result' && !this.recordToolResult(block)) {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
break;
|
|
675
|
+
case 'result':
|
|
676
|
+
// A CLI-initiated turn's result belongs to THAT turn. It must never resolve a
|
|
677
|
+
// stdin request (its own, or a later one) and must not clear its timeout.
|
|
678
|
+
if (this.autonomousTurn) {
|
|
679
|
+
this.finishAutonomousTurn(event);
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
if (this.currentResolve === null && this.toolUseBlocks.length === 0) {
|
|
683
|
+
// A finished CLI turn nobody requested and no tracked child explains (e.g. a
|
|
684
|
+
// grandchild's task notification). A turn still waiting on host tool results is
|
|
685
|
+
// not finished and keeps the normal path; a finished one is an answer for the
|
|
686
|
+
// last request that asked.
|
|
687
|
+
const text = event.result || '';
|
|
688
|
+
persistentLogger.info(`[PersistentCLI] unrequested CLI turn ended (${event.duration_ms ?? 0}ms); ` +
|
|
689
|
+
(this.lastFollowUp
|
|
690
|
+
? 'handing its text to the last request'
|
|
691
|
+
: 'no request to hand it to'));
|
|
692
|
+
this.lastFollowUp?.({
|
|
693
|
+
agentThreadId: 'untracked',
|
|
694
|
+
agentPath: 'cli-turn',
|
|
695
|
+
itemId: 'untracked',
|
|
696
|
+
text,
|
|
697
|
+
isError: event.subtype !== 'success',
|
|
698
|
+
});
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
701
|
+
if (this.currentInputUuid && this.currentCallbacks?.onAccepted && !this.inputAcknowledged) {
|
|
702
|
+
this.handleError(new types_js_1.NativeInputUncertainError('Native input acknowledgement was not observed', {
|
|
703
|
+
backend: 'claude',
|
|
704
|
+
sessionId: this.options.sessionId,
|
|
705
|
+
inputId: this.currentInputUuid,
|
|
706
|
+
}));
|
|
707
|
+
this.stop();
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
// Request complete
|
|
711
|
+
this.clearRequestTimeout();
|
|
712
|
+
if (event.subtype === 'success') {
|
|
713
|
+
const unresolvedToolUses = [...this.toolUseBlocks];
|
|
714
|
+
const completedToolExchanges = [...this.completedToolExchanges];
|
|
715
|
+
const hasToolUse = unresolvedToolUses.length > 0;
|
|
716
|
+
const result = {
|
|
717
|
+
response: event.result || this.accumulatedText,
|
|
718
|
+
session_id: event.session_id || this.options.sessionId,
|
|
719
|
+
cost_usd: event.total_cost_usd,
|
|
720
|
+
duration_ms: event.duration_ms,
|
|
721
|
+
usage: {
|
|
722
|
+
input_tokens: event.usage?.input_tokens || 0,
|
|
723
|
+
output_tokens: event.usage?.output_tokens || 0,
|
|
724
|
+
cache_creation_input_tokens: event.usage?.cache_creation_input_tokens,
|
|
725
|
+
cache_read_input_tokens: event.usage?.cache_read_input_tokens,
|
|
726
|
+
},
|
|
727
|
+
toolUseBlocks: hasToolUse ? unresolvedToolUses : undefined,
|
|
728
|
+
hasToolUse,
|
|
729
|
+
completedToolExchanges,
|
|
730
|
+
};
|
|
731
|
+
// Record token usage
|
|
732
|
+
if (this.onTokenUsage) {
|
|
733
|
+
try {
|
|
734
|
+
this.onTokenUsage({
|
|
735
|
+
channel_key: this.options.channelKey || 'cli',
|
|
736
|
+
agent_id: this.options.agentId || undefined,
|
|
737
|
+
input_tokens: event.usage?.input_tokens || 0,
|
|
738
|
+
output_tokens: event.usage?.output_tokens || 0,
|
|
739
|
+
cache_read_tokens: event.usage?.cache_read_input_tokens || 0,
|
|
740
|
+
cost_usd: event.total_cost_usd || undefined,
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
/* ignore recording errors */
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
persistentLogger.info(`[PersistentCLI] Request complete (${event.duration_ms}ms, ${unresolvedToolUses.length} unresolved tools, ${completedToolExchanges.length} completed tools)`);
|
|
748
|
+
this.currentCallbacks?.onFinal?.({
|
|
749
|
+
content: result.response,
|
|
750
|
+
toolUseBlocks: unresolvedToolUses,
|
|
751
|
+
});
|
|
752
|
+
this.state = 'idle';
|
|
753
|
+
this.awaitingToolResults = hasToolUse;
|
|
754
|
+
this.pendingToolUseStartedAt = hasToolUse ? Date.now() : null;
|
|
755
|
+
// The parent turn is over but a background child is still running: from here on
|
|
756
|
+
// the stream is the child's until the next stdin request.
|
|
757
|
+
this.parentTurnEnded = this.hasLiveBackgroundAgents();
|
|
758
|
+
this.currentResolve?.(result);
|
|
759
|
+
this.resetRequestState();
|
|
760
|
+
this.emit('idle'); // F7: Trigger message queue drain (after resolve/cleanup)
|
|
761
|
+
}
|
|
762
|
+
else if (event.is_error) {
|
|
763
|
+
const error = new Error(event.error || 'Unknown error');
|
|
764
|
+
this.currentCallbacks?.onError?.(error);
|
|
765
|
+
this.state = 'idle';
|
|
766
|
+
this.awaitingToolResults = false;
|
|
767
|
+
this.pendingToolUseStartedAt = null;
|
|
768
|
+
this.currentReject?.(error);
|
|
769
|
+
this.resetRequestState();
|
|
770
|
+
this.emit('idle'); // F7: Trigger message queue drain (after reject/cleanup)
|
|
771
|
+
}
|
|
772
|
+
break;
|
|
773
|
+
case 'error': {
|
|
774
|
+
this.clearRequestTimeout();
|
|
775
|
+
const error = new Error(event.error || 'Unknown error');
|
|
776
|
+
this.currentCallbacks?.onError?.(error);
|
|
777
|
+
this.state = 'idle';
|
|
778
|
+
this.awaitingToolResults = false;
|
|
779
|
+
this.pendingToolUseStartedAt = null;
|
|
780
|
+
this.currentReject?.(error);
|
|
781
|
+
this.resetRequestState();
|
|
782
|
+
this.emit('idle'); // F7: Trigger message queue drain (after reject/cleanup)
|
|
783
|
+
break;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
recordToolUse(toolUse) {
|
|
788
|
+
const fingerprint = stableJson({ name: toolUse.name, input: toolUse.input });
|
|
789
|
+
const current = this.promptToolExchanges.get(toolUse.id);
|
|
790
|
+
if (current) {
|
|
791
|
+
if (current.toolResult) {
|
|
792
|
+
this.failToolStreamProtocol(`Completed tool id was reused: ${toolUse.id}`);
|
|
793
|
+
return false;
|
|
794
|
+
}
|
|
795
|
+
if (current.toolUseFingerprint !== fingerprint) {
|
|
796
|
+
this.failToolStreamProtocol(`Conflicting tool_use payload for id: ${toolUse.id}`);
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
return true;
|
|
800
|
+
}
|
|
801
|
+
this.promptToolExchanges.set(toolUse.id, {
|
|
802
|
+
toolUse,
|
|
803
|
+
toolUseFingerprint: fingerprint,
|
|
804
|
+
});
|
|
805
|
+
this.toolUseBlocks.push(toolUse);
|
|
806
|
+
// Callback-only metadata pairs overlapping native calls by provider identity;
|
|
807
|
+
// preserve the original tool input and exchange fingerprint unchanged.
|
|
808
|
+
this.currentCallbacks?.onToolUse?.(toolUse.name, {
|
|
809
|
+
...toolUse.input,
|
|
810
|
+
nativeToolUseId: toolUse.id,
|
|
811
|
+
});
|
|
812
|
+
persistentLogger.info(`[PersistentCLI] Tool use: ${toolUse.name}`);
|
|
813
|
+
// Every Agent spawn is tracked from here; its LAUNCH RESULT decides whether it is a
|
|
814
|
+
// background child ("Async agent launched") or a synchronous one (dropped on result).
|
|
815
|
+
// Measured 2026-09-10 21:06 KST: the CLI launched async without run_in_background, the
|
|
816
|
+
// flag-gated tracker missed it, and the child lost the run context mid-flight.
|
|
817
|
+
if (toolUse.name === BACKGROUND_AGENT_TOOL) {
|
|
818
|
+
this.trackBackgroundAgent(toolUse);
|
|
819
|
+
}
|
|
820
|
+
return true;
|
|
821
|
+
}
|
|
822
|
+
// ─── Native background Agent observation ───────────────────────────────
|
|
823
|
+
//
|
|
824
|
+
// The CLI gives one stream. A `run_in_background: true` Agent tool_use, the
|
|
825
|
+
// "Async agent launched" tool_result that names the child, `system/task_*` events, the
|
|
826
|
+
// child's own tool calls, its final text, `task_notification`, and - measured - a whole
|
|
827
|
+
// turn the CLI opens by itself all arrive on it. These helpers separate those without
|
|
828
|
+
// inventing anything the stream does not say.
|
|
829
|
+
trackBackgroundAgent(toolUse) {
|
|
830
|
+
const description = toolUse.input?.description;
|
|
831
|
+
const state = {
|
|
832
|
+
itemId: toolUse.id,
|
|
833
|
+
agentPath: typeof description === 'string' && description.trim() ? description : toolUse.id,
|
|
834
|
+
agentId: null,
|
|
835
|
+
taskId: null,
|
|
836
|
+
spawnObserved: false,
|
|
837
|
+
startFired: false,
|
|
838
|
+
notificationSeen: false,
|
|
839
|
+
completed: false,
|
|
840
|
+
finalText: '',
|
|
841
|
+
onToolUse: this.currentCallbacks?.onToolUse,
|
|
842
|
+
onToolComplete: this.currentCallbacks?.onToolComplete,
|
|
843
|
+
nativeItems: new Map(),
|
|
844
|
+
onSubagentStart: this.currentCallbacks?.onSubagentStart,
|
|
845
|
+
onFollowUp: this.currentCallbacks?.onFollowUp,
|
|
846
|
+
};
|
|
847
|
+
this.backgroundAgents.set(toolUse.id, state);
|
|
848
|
+
this.pruneBackgroundAgents();
|
|
849
|
+
}
|
|
850
|
+
pruneBackgroundAgents() {
|
|
851
|
+
if (this.backgroundAgents.size <= MAX_TRACKED_BACKGROUND_AGENTS)
|
|
852
|
+
return;
|
|
853
|
+
for (const [id, state] of this.backgroundAgents) {
|
|
854
|
+
if (this.backgroundAgents.size <= MAX_TRACKED_BACKGROUND_AGENTS)
|
|
855
|
+
break;
|
|
856
|
+
if (state.completed)
|
|
857
|
+
this.backgroundAgents.delete(id);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
/** The newest tracked agent matching a predicate; `system` task events carry no item id. */
|
|
861
|
+
latestBackgroundAgent(match) {
|
|
862
|
+
let found = null;
|
|
863
|
+
for (const state of this.backgroundAgents.values()) {
|
|
864
|
+
if (match(state))
|
|
865
|
+
found = state;
|
|
866
|
+
}
|
|
867
|
+
return found;
|
|
868
|
+
}
|
|
869
|
+
taskIdentityOf(event) {
|
|
870
|
+
return (event.task_id ?? event.taskId ?? event.task?.id ?? event.agent_id ?? event.agentId ?? null);
|
|
871
|
+
}
|
|
872
|
+
recordBackgroundTaskStarted(event) {
|
|
873
|
+
const state = this.latestBackgroundAgent((candidate) => !candidate.spawnObserved);
|
|
874
|
+
if (!state) {
|
|
875
|
+
console.warn('[PersistentCLI] task_started with no tracked background Agent tool_use');
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
state.spawnObserved = true;
|
|
879
|
+
state.taskId = this.taskIdentityOf(event) ?? state.taskId;
|
|
880
|
+
// The spawn is not reported here: `task_started` precedes the launch tool_result, and
|
|
881
|
+
// that result is what names the child. The task id stays as the fallback identity.
|
|
882
|
+
}
|
|
883
|
+
/** Lift the child's id out of the "Async agent launched" tool_result text. */
|
|
884
|
+
recordBackgroundLaunchResult(state, content) {
|
|
885
|
+
if (!/async agent launched/i.test(content)) {
|
|
886
|
+
// A synchronous child: its result IS its answer, inside the parent turn. Nothing to hold.
|
|
887
|
+
this.backgroundAgents.delete(state.itemId);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
const match = /agentId["'\s:=]+([A-Za-z0-9_-]{4,})/.exec(content);
|
|
891
|
+
if (match) {
|
|
892
|
+
state.agentId = match[1];
|
|
893
|
+
}
|
|
894
|
+
else if (!state.taskId) {
|
|
895
|
+
console.warn(`[PersistentCLI] background Agent launch result carried no agentId (item=${state.itemId})`);
|
|
896
|
+
}
|
|
897
|
+
this.fireSubagentStart(state);
|
|
898
|
+
}
|
|
899
|
+
fireSubagentStart(state) {
|
|
900
|
+
if (state.startFired)
|
|
901
|
+
return;
|
|
902
|
+
const agentThreadId = state.agentId ?? state.taskId;
|
|
903
|
+
if (!agentThreadId)
|
|
904
|
+
return;
|
|
905
|
+
state.startFired = true;
|
|
906
|
+
try {
|
|
907
|
+
state.onSubagentStart?.({
|
|
908
|
+
agentThreadId,
|
|
909
|
+
agentPath: state.agentPath,
|
|
910
|
+
itemId: state.itemId,
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
catch (error) {
|
|
914
|
+
console.error(`[PersistentCLI] onSubagentStart callback failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
915
|
+
}
|
|
916
|
+
const emitted = {
|
|
917
|
+
kind: 'started',
|
|
918
|
+
agentThreadId,
|
|
919
|
+
agentPath: state.agentPath,
|
|
920
|
+
itemId: state.itemId,
|
|
921
|
+
wakeRequired: false,
|
|
922
|
+
};
|
|
923
|
+
this.emit('subagent', emitted);
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Which background child an assistant/user event belongs to, or null for the parent.
|
|
927
|
+
*
|
|
928
|
+
* `parent_tool_use_id` is authoritative when the CLI stamps it. Without it the only
|
|
929
|
+
* honest signal is position: the parent turn already ended and exactly one child is
|
|
930
|
+
* live. Two live children and no stamp is ambiguous, so the event stays with the parent
|
|
931
|
+
* and says so rather than being silently attributed.
|
|
932
|
+
*/
|
|
933
|
+
childAgentFor(event) {
|
|
934
|
+
const stamped = event.parent_tool_use_id;
|
|
935
|
+
if (typeof stamped === 'string' && stamped) {
|
|
936
|
+
return this.backgroundAgents.get(stamped) ?? null;
|
|
937
|
+
}
|
|
938
|
+
if (!this.parentTurnEnded)
|
|
939
|
+
return null;
|
|
940
|
+
const live = [...this.backgroundAgents.values()].filter((state) => !state.completed);
|
|
941
|
+
if (live.length === 1)
|
|
942
|
+
return live[0];
|
|
943
|
+
if (live.length > 1) {
|
|
944
|
+
console.warn(`[PersistentCLI] ${live.length} live background agents and no parent_tool_use_id; ` +
|
|
945
|
+
'event left with the parent');
|
|
946
|
+
}
|
|
947
|
+
return null;
|
|
948
|
+
}
|
|
949
|
+
absorbChildAssistant(child, event) {
|
|
950
|
+
for (const block of Array.isArray(event.message?.content) ? event.message.content : []) {
|
|
951
|
+
if (block.type === 'text') {
|
|
952
|
+
// Only the child's LAST answer matters to the wake, but the stream does not mark
|
|
953
|
+
// it, so text accumulates bounded, like the Codex child's final_answer buffer.
|
|
954
|
+
child.finalText = (child.finalText + (block.text || '')).slice(0, MAX_SUBAGENT_FINAL_TEXT_CHARS);
|
|
955
|
+
}
|
|
956
|
+
else if (block.type === 'tool_use') {
|
|
957
|
+
// Observed, never counted as a parent tool use awaiting a host result. The call
|
|
958
|
+
// itself already reached the MCP server under the parent's context key.
|
|
959
|
+
if (!block.id || child.nativeItems.has(block.id))
|
|
960
|
+
continue;
|
|
961
|
+
child.nativeItems.set(block.id, { name: block.name ?? 'unknown', completed: false });
|
|
962
|
+
child.onToolUse?.(block.name ?? 'unknown', {
|
|
963
|
+
...(block.input ?? {}),
|
|
964
|
+
nativeToolUseId: block.id,
|
|
965
|
+
subagentItemId: child.itemId,
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
recordBackgroundTaskNotification(event) {
|
|
971
|
+
const identity = this.taskIdentityOf(event);
|
|
972
|
+
const state = (identity
|
|
973
|
+
? this.latestBackgroundAgent((candidate) => candidate.agentId === identity || candidate.taskId === identity)
|
|
974
|
+
: null) ?? this.latestBackgroundAgent((candidate) => !candidate.completed);
|
|
975
|
+
if (!state) {
|
|
976
|
+
console.warn('[PersistentCLI] task_notification with no tracked background Agent');
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
if (state.notificationSeen)
|
|
980
|
+
return;
|
|
981
|
+
state.notificationSeen = true;
|
|
982
|
+
// The CLI may answer the notification itself. Give it that window; if it does not, the
|
|
983
|
+
// host takes the wake so a finished child is never dropped.
|
|
984
|
+
const timer = setTimeout(() => {
|
|
985
|
+
this.pendingNotification = null;
|
|
986
|
+
this.completeBackgroundAgent(state, true);
|
|
987
|
+
}, AUTONOMOUS_TURN_GRACE_MS);
|
|
988
|
+
timer.unref?.();
|
|
989
|
+
this.pendingNotification = { itemId: state.itemId, timer };
|
|
990
|
+
}
|
|
991
|
+
beginAutonomousTurn() {
|
|
992
|
+
const pending = this.pendingNotification;
|
|
993
|
+
if (!pending)
|
|
994
|
+
return;
|
|
995
|
+
clearTimeout(pending.timer);
|
|
996
|
+
this.pendingNotification = null;
|
|
997
|
+
const state = this.backgroundAgents.get(pending.itemId);
|
|
998
|
+
if (!state)
|
|
999
|
+
return;
|
|
1000
|
+
this.autonomousTurn = { state, text: '' };
|
|
1001
|
+
const event = {
|
|
1002
|
+
agentThreadId: state.agentId ?? state.taskId ?? state.itemId,
|
|
1003
|
+
agentPath: state.agentPath,
|
|
1004
|
+
itemId: state.itemId,
|
|
1005
|
+
};
|
|
1006
|
+
persistentLogger.info(`[PersistentCLI] autonomous turn opened for finished child ${event.agentThreadId}`);
|
|
1007
|
+
this.emit('autonomousTurn', event);
|
|
1008
|
+
}
|
|
1009
|
+
finishAutonomousTurn(event) {
|
|
1010
|
+
const active = this.autonomousTurn;
|
|
1011
|
+
if (!active)
|
|
1012
|
+
return;
|
|
1013
|
+
this.autonomousTurn = null;
|
|
1014
|
+
const state = active.state;
|
|
1015
|
+
const resultEvent = {
|
|
1016
|
+
agentThreadId: state.agentId ?? state.taskId ?? state.itemId,
|
|
1017
|
+
agentPath: state.agentPath,
|
|
1018
|
+
itemId: state.itemId,
|
|
1019
|
+
text: event.result || active.text,
|
|
1020
|
+
isError: event.is_error === true || event.subtype !== 'success',
|
|
1021
|
+
};
|
|
1022
|
+
// The CLI's own turn IS the owner reacting, so the host must not wake it again.
|
|
1023
|
+
this.completeBackgroundAgent(state, false);
|
|
1024
|
+
this.emit('autonomousTurnResult', resultEvent);
|
|
1025
|
+
// The answer belongs to the request that spawned the child: hand it back there.
|
|
1026
|
+
(state.onFollowUp ?? this.lastFollowUp)?.(resultEvent);
|
|
1027
|
+
}
|
|
1028
|
+
completeBackgroundAgent(state, wakeRequired, status = 'completed') {
|
|
1029
|
+
if (state.completed)
|
|
1030
|
+
return;
|
|
1031
|
+
state.completed = true;
|
|
1032
|
+
for (const [callId, item] of state.nativeItems) {
|
|
1033
|
+
if (item.completed)
|
|
1034
|
+
continue;
|
|
1035
|
+
item.completed = true;
|
|
1036
|
+
try {
|
|
1037
|
+
state.onToolComplete?.(item.name, callId, true, 'unknown');
|
|
1038
|
+
}
|
|
1039
|
+
catch {
|
|
1040
|
+
console.warn('[PersistentCLI] native observation failed');
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
const emitted = {
|
|
1044
|
+
kind: 'completed',
|
|
1045
|
+
agentThreadId: state.agentId ?? state.taskId ?? state.itemId,
|
|
1046
|
+
agentPath: state.agentPath,
|
|
1047
|
+
itemId: state.itemId,
|
|
1048
|
+
// The stream reports a notification, not an exit code; a child cut off by a dead CLI
|
|
1049
|
+
// is `unknown`, never success.
|
|
1050
|
+
status,
|
|
1051
|
+
finalText: state.finalText,
|
|
1052
|
+
wakeRequired,
|
|
1053
|
+
};
|
|
1054
|
+
if (!this.hasLiveBackgroundAgents()) {
|
|
1055
|
+
this.parentTurnEnded = false;
|
|
1056
|
+
}
|
|
1057
|
+
this.emit('subagent', emitted);
|
|
1058
|
+
}
|
|
1059
|
+
/** Whether a spawned background child has not been reported finished yet. */
|
|
1060
|
+
hasLiveBackgroundAgents() {
|
|
1061
|
+
for (const state of this.backgroundAgents.values()) {
|
|
1062
|
+
if (!state.completed)
|
|
1063
|
+
return true;
|
|
1064
|
+
}
|
|
1065
|
+
return false;
|
|
1066
|
+
}
|
|
1067
|
+
recordToolResult(block) {
|
|
1068
|
+
const toolUseId = block.tool_use_id;
|
|
1069
|
+
if (!toolUseId) {
|
|
1070
|
+
this.failToolStreamProtocol('tool_result is missing tool_use_id');
|
|
1071
|
+
return false;
|
|
1072
|
+
}
|
|
1073
|
+
const current = this.promptToolExchanges.get(toolUseId);
|
|
1074
|
+
if (!current) {
|
|
1075
|
+
this.failToolStreamProtocol(`tool_result arrived before tool_use: ${toolUseId}`);
|
|
1076
|
+
return false;
|
|
1077
|
+
}
|
|
1078
|
+
const toolResult = {
|
|
1079
|
+
type: 'tool_result',
|
|
1080
|
+
tool_use_id: toolUseId,
|
|
1081
|
+
content: normalizeStreamToolResultContent(block.content),
|
|
1082
|
+
is_error: block.is_error === true,
|
|
1083
|
+
};
|
|
1084
|
+
const fingerprint = stableJson({
|
|
1085
|
+
content: toolResult.content,
|
|
1086
|
+
is_error: toolResult.is_error,
|
|
1087
|
+
});
|
|
1088
|
+
if (current.toolResult) {
|
|
1089
|
+
if (current.toolResultFingerprint !== fingerprint) {
|
|
1090
|
+
this.failToolStreamProtocol(`Conflicting tool_result payload for id: ${toolUseId}`);
|
|
1091
|
+
return false;
|
|
1092
|
+
}
|
|
1093
|
+
return true;
|
|
1094
|
+
}
|
|
1095
|
+
current.toolResult = toolResult;
|
|
1096
|
+
current.toolResultFingerprint = fingerprint;
|
|
1097
|
+
this.toolUseBlocks = this.toolUseBlocks.filter((toolUse) => toolUse.id !== toolUseId);
|
|
1098
|
+
this.completedToolExchanges.push({ toolUse: current.toolUse, toolResult });
|
|
1099
|
+
this.currentCallbacks?.onToolComplete?.(current.toolUse.name, toolUseId, toolResult.is_error === true);
|
|
1100
|
+
const backgroundAgent = this.backgroundAgents.get(toolUseId);
|
|
1101
|
+
if (backgroundAgent) {
|
|
1102
|
+
// "Async agent launched successfully…" - the one event that reliably names the child.
|
|
1103
|
+
this.recordBackgroundLaunchResult(backgroundAgent, toolResult.content);
|
|
1104
|
+
}
|
|
1105
|
+
return true;
|
|
1106
|
+
}
|
|
1107
|
+
failToolStreamProtocol(message) {
|
|
1108
|
+
const error = new types_js_1.ClaudeToolStreamProtocolError(message);
|
|
1109
|
+
this.currentCallbacks?.onError?.(error);
|
|
1110
|
+
this.handleError(error);
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Handle process close
|
|
1114
|
+
*/
|
|
1115
|
+
handleClose(code) {
|
|
1116
|
+
persistentLogger.info(`[PersistentCLI] Process closed with code ${code}`);
|
|
1117
|
+
this.state = 'dead';
|
|
1118
|
+
this.process = null;
|
|
1119
|
+
this.awaitingToolResults = false;
|
|
1120
|
+
this.pendingToolUseStartedAt = null;
|
|
1121
|
+
// A child cannot outlive the CLI that ran it. Report the ones still open as unknown
|
|
1122
|
+
// rather than leaving the host waiting for a notification that can never arrive.
|
|
1123
|
+
if (this.pendingNotification) {
|
|
1124
|
+
clearTimeout(this.pendingNotification.timer);
|
|
1125
|
+
this.pendingNotification = null;
|
|
1126
|
+
}
|
|
1127
|
+
this.autonomousTurn = null;
|
|
1128
|
+
for (const state of [...this.backgroundAgents.values()]) {
|
|
1129
|
+
if (!state.completed) {
|
|
1130
|
+
this.completeBackgroundAgent(state, true, 'unknown');
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
// Reject any pending request
|
|
1134
|
+
if (this.currentReject) {
|
|
1135
|
+
this.currentReject(new Error(`Process exited with code ${code}`));
|
|
1136
|
+
this.resetRequestState();
|
|
1137
|
+
}
|
|
1138
|
+
this.emit('close', code);
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Handle process error
|
|
1142
|
+
*/
|
|
1143
|
+
handleError(error) {
|
|
1144
|
+
console.error(`[PersistentCLI] Process error:`, error.message);
|
|
1145
|
+
if (this.currentReject) {
|
|
1146
|
+
this.currentReject(error);
|
|
1147
|
+
this.resetRequestState();
|
|
1148
|
+
}
|
|
1149
|
+
// Transition to idle so subsequent requests aren't blocked
|
|
1150
|
+
if (this.state === 'busy') {
|
|
1151
|
+
this.state = 'idle';
|
|
1152
|
+
this.awaitingToolResults = false;
|
|
1153
|
+
this.pendingToolUseStartedAt = null;
|
|
1154
|
+
this.emit('idle');
|
|
1155
|
+
}
|
|
1156
|
+
this.emit('error', error);
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Handle request timeout
|
|
1160
|
+
*/
|
|
1161
|
+
handleTimeout() {
|
|
1162
|
+
console.error(`[PersistentCLI] Request timeout — killing process to prevent zombie`);
|
|
1163
|
+
if (this.currentReject) {
|
|
1164
|
+
this.currentReject(new Error('Request timeout'));
|
|
1165
|
+
this.resetRequestState();
|
|
1166
|
+
}
|
|
1167
|
+
// ⚠️ Timed-out processes MUST be killed.
|
|
1168
|
+
// Setting state to 'idle' without killing leaves zombie processes consuming memory.
|
|
1169
|
+
// When SessionPool creates new sessions without cleaning up old processes,
|
|
1170
|
+
// Claude processes accumulate and exhaust system memory.
|
|
1171
|
+
if (this.process && !this.process.killed) {
|
|
1172
|
+
// `killed` turns true as soon as SIGTERM is sent, so escalation must watch the exit itself.
|
|
1173
|
+
const child = this.process;
|
|
1174
|
+
let exited = false;
|
|
1175
|
+
const markExited = () => {
|
|
1176
|
+
exited = true;
|
|
1177
|
+
};
|
|
1178
|
+
if (typeof child.once === 'function') {
|
|
1179
|
+
child.once('exit', markExited);
|
|
1180
|
+
child.once('close', markExited);
|
|
1181
|
+
}
|
|
1182
|
+
child.kill('SIGTERM');
|
|
1183
|
+
const forceKillTimer = setTimeout(() => {
|
|
1184
|
+
if (!exited)
|
|
1185
|
+
child.kill('SIGKILL');
|
|
1186
|
+
}, 3000);
|
|
1187
|
+
forceKillTimer.unref?.();
|
|
1188
|
+
}
|
|
1189
|
+
this.state = 'dead';
|
|
1190
|
+
this.awaitingToolResults = false;
|
|
1191
|
+
this.pendingToolUseStartedAt = null;
|
|
1192
|
+
this.emit('idle'); // F7: Trigger message queue drain (after cleanup)
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Clear request timeout
|
|
1196
|
+
*/
|
|
1197
|
+
clearRequestTimeout() {
|
|
1198
|
+
if (this.requestTimeoutHandle) {
|
|
1199
|
+
clearTimeout(this.requestTimeoutHandle);
|
|
1200
|
+
this.requestTimeoutHandle = null;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Reset request state
|
|
1205
|
+
*/
|
|
1206
|
+
resetRequestState() {
|
|
1207
|
+
this.clearRequestTimeout();
|
|
1208
|
+
// The request is over, but the runner may still answer it later (its own follow-up turn):
|
|
1209
|
+
// keep the sink the request offered.
|
|
1210
|
+
if (this.currentCallbacks?.onFollowUp)
|
|
1211
|
+
this.lastFollowUp = this.currentCallbacks.onFollowUp;
|
|
1212
|
+
this.currentCallbacks = null;
|
|
1213
|
+
this.currentResolve = null;
|
|
1214
|
+
this.currentReject = null;
|
|
1215
|
+
this.currentInputUuid = null;
|
|
1216
|
+
this.inputAcknowledged = false;
|
|
1217
|
+
this.toolUseBlocks = [];
|
|
1218
|
+
this.promptToolExchanges.clear();
|
|
1219
|
+
this.completedToolExchanges = [];
|
|
1220
|
+
this.accumulatedText = '';
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Stop the process
|
|
1224
|
+
*/
|
|
1225
|
+
stop() {
|
|
1226
|
+
persistentLogger.info(`[PersistentCLI] Stopping process`);
|
|
1227
|
+
// Reject any pending request BEFORE resetting state
|
|
1228
|
+
// This ensures promises are resolved even if process is already dead
|
|
1229
|
+
if (this.currentReject) {
|
|
1230
|
+
this.currentReject(new Error('Process stopped by user'));
|
|
1231
|
+
}
|
|
1232
|
+
if (this.process) {
|
|
1233
|
+
const child = this.process;
|
|
1234
|
+
let exited = false;
|
|
1235
|
+
const markExited = () => {
|
|
1236
|
+
exited = true;
|
|
1237
|
+
};
|
|
1238
|
+
if (typeof child.once === 'function') {
|
|
1239
|
+
child.once('exit', markExited);
|
|
1240
|
+
child.once('close', markExited);
|
|
1241
|
+
}
|
|
1242
|
+
this.clearRequestTimeout();
|
|
1243
|
+
child.stdin?.end();
|
|
1244
|
+
child.kill('SIGTERM');
|
|
1245
|
+
const forceKillTimer = setTimeout(() => {
|
|
1246
|
+
if (!exited) {
|
|
1247
|
+
child.kill('SIGKILL');
|
|
1248
|
+
}
|
|
1249
|
+
}, 3000);
|
|
1250
|
+
forceKillTimer.unref();
|
|
1251
|
+
this.process = null;
|
|
1252
|
+
}
|
|
1253
|
+
this.state = 'dead';
|
|
1254
|
+
this.awaitingToolResults = false;
|
|
1255
|
+
this.pendingToolUseStartedAt = null;
|
|
1256
|
+
this.resetRequestState();
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* Check if process is alive
|
|
1260
|
+
*/
|
|
1261
|
+
isAlive() {
|
|
1262
|
+
return this.state !== 'dead';
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Check if process is ready for new messages
|
|
1266
|
+
*/
|
|
1267
|
+
isReady() {
|
|
1268
|
+
return this.state === 'idle';
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* True while Claude has requested tools and the host has not sent tool_result yet.
|
|
1272
|
+
* The process is technically idle during host-side tool execution, but it must not
|
|
1273
|
+
* be reclaimed because the next prompt depends on this live Claude context.
|
|
1274
|
+
*/
|
|
1275
|
+
hasPendingToolUse() {
|
|
1276
|
+
return this.awaitingToolResults;
|
|
1277
|
+
}
|
|
1278
|
+
getPendingToolUseStartedAt() {
|
|
1279
|
+
return this.pendingToolUseStartedAt;
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Get current state
|
|
1283
|
+
*/
|
|
1284
|
+
getState() {
|
|
1285
|
+
return this.state;
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Get session ID
|
|
1289
|
+
*/
|
|
1290
|
+
getSessionId() {
|
|
1291
|
+
return this.options.sessionId;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
exports.PersistentClaudeProcess = PersistentClaudeProcess;
|
|
1295
|
+
function formatClaudeArgsForLog(args) {
|
|
1296
|
+
return (0, cli_arg_redaction_js_1.formatCliArgsForLog)(args);
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* PersistentProcessPool - Manages multiple persistent Claude processes
|
|
1300
|
+
*
|
|
1301
|
+
* Features:
|
|
1302
|
+
* - One process per channel/session
|
|
1303
|
+
* - Automatic process lifecycle management
|
|
1304
|
+
* - Process reuse for multi-turn conversations
|
|
1305
|
+
*/
|
|
1306
|
+
class PersistentProcessPool {
|
|
1307
|
+
processes = new Map();
|
|
1308
|
+
defaultOptions;
|
|
1309
|
+
idleTimeoutMs;
|
|
1310
|
+
cleanupIntervalMs;
|
|
1311
|
+
pendingToolUseTimeoutMs;
|
|
1312
|
+
cleanupTimer = null;
|
|
1313
|
+
constructor(defaultOptions) {
|
|
1314
|
+
this.defaultOptions = defaultOptions;
|
|
1315
|
+
this.idleTimeoutMs = this.resolveIdleTimeoutMs(defaultOptions);
|
|
1316
|
+
this.cleanupIntervalMs = this.resolveCleanupIntervalMs(defaultOptions);
|
|
1317
|
+
this.pendingToolUseTimeoutMs = this.resolvePendingToolUseTimeoutMs(defaultOptions);
|
|
1318
|
+
this.startCleanupTimer();
|
|
1319
|
+
}
|
|
1320
|
+
resolveIdleTimeoutMs(defaultOptions) {
|
|
1321
|
+
if (defaultOptions.idleTimeoutMs !== undefined) {
|
|
1322
|
+
return Math.max(0, defaultOptions.idleTimeoutMs);
|
|
1323
|
+
}
|
|
1324
|
+
return Math.max(0, defaultOptions.timeouts?.idleMs ?? defaultOptions.timeouts?.sessionMs ?? 1_800_000);
|
|
1325
|
+
}
|
|
1326
|
+
resolveCleanupIntervalMs(defaultOptions) {
|
|
1327
|
+
if (defaultOptions.cleanupIntervalMs !== undefined) {
|
|
1328
|
+
return Math.max(0, defaultOptions.cleanupIntervalMs);
|
|
1329
|
+
}
|
|
1330
|
+
return Math.max(0, defaultOptions.timeouts?.cleanupMs ?? 300_000);
|
|
1331
|
+
}
|
|
1332
|
+
resolvePendingToolUseTimeoutMs(defaultOptions) {
|
|
1333
|
+
if (defaultOptions.pendingToolUseTimeoutMs !== undefined) {
|
|
1334
|
+
return Math.max(0, defaultOptions.pendingToolUseTimeoutMs);
|
|
1335
|
+
}
|
|
1336
|
+
return Math.max(0, defaultOptions.timeouts?.pendingToolMs ?? Math.max(this.idleTimeoutMs * 4, 1_800_000));
|
|
1337
|
+
}
|
|
1338
|
+
startCleanupTimer() {
|
|
1339
|
+
if (this.cleanupTimer || this.cleanupIntervalMs === 0) {
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
this.cleanupTimer = setInterval(() => {
|
|
1343
|
+
this.cleanupIdleProcesses();
|
|
1344
|
+
}, this.cleanupIntervalMs);
|
|
1345
|
+
this.cleanupTimer.unref();
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Get or create a process for a channel
|
|
1349
|
+
*/
|
|
1350
|
+
async getProcess(channelKey, options) {
|
|
1351
|
+
return (await this.getProcessWithStatus(channelKey, options)).process;
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* Get or create a process for a channel and report whether this call created it.
|
|
1355
|
+
*/
|
|
1356
|
+
async getProcessWithStatus(channelKey, options) {
|
|
1357
|
+
this.startCleanupTimer();
|
|
1358
|
+
const now = Date.now();
|
|
1359
|
+
const entry = this.processes.get(channelKey);
|
|
1360
|
+
let process = entry?.process;
|
|
1361
|
+
let created = false;
|
|
1362
|
+
if (!process || !process.isAlive()) {
|
|
1363
|
+
// Create new process
|
|
1364
|
+
const mergedOptions = {
|
|
1365
|
+
sessionId: (0, crypto_1.randomUUID)(),
|
|
1366
|
+
...this.defaultOptions,
|
|
1367
|
+
...options,
|
|
1368
|
+
// A per-call override may omit it; the pool's own workspace is the floor.
|
|
1369
|
+
workspaceDir: options?.workspaceDir ?? this.defaultOptions.workspaceDir,
|
|
1370
|
+
};
|
|
1371
|
+
poolLogger.info(`Creating new process for channel: ${channelKey}`);
|
|
1372
|
+
const createdProcess = new PersistentClaudeProcess(mergedOptions);
|
|
1373
|
+
process = createdProcess;
|
|
1374
|
+
// Handle process errors - prevent unhandled 'error' event crash
|
|
1375
|
+
const removeIfCurrent = () => {
|
|
1376
|
+
const current = this.processes.get(channelKey);
|
|
1377
|
+
if (current?.process === createdProcess) {
|
|
1378
|
+
this.processes.delete(channelKey);
|
|
1379
|
+
}
|
|
1380
|
+
};
|
|
1381
|
+
const touchIfCurrent = () => {
|
|
1382
|
+
const current = this.processes.get(channelKey);
|
|
1383
|
+
if (!current || current.process !== createdProcess) {
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
current.lastUsedAt = Date.now();
|
|
1387
|
+
if (createdProcess.hasPendingToolUse()) {
|
|
1388
|
+
current.pendingToolUseSince =
|
|
1389
|
+
createdProcess.getPendingToolUseStartedAt() ?? current.lastUsedAt;
|
|
1390
|
+
}
|
|
1391
|
+
else {
|
|
1392
|
+
current.pendingToolUseSince = undefined;
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
createdProcess.on('error', (err) => {
|
|
1396
|
+
poolLogger.error(`Process error for ${channelKey}:`, err);
|
|
1397
|
+
// An errored generation is no longer reusable. Stop it before dropping
|
|
1398
|
+
// the pool reference so it cannot survive as an untracked child.
|
|
1399
|
+
createdProcess.stop();
|
|
1400
|
+
removeIfCurrent();
|
|
1401
|
+
});
|
|
1402
|
+
// Handle process death - remove from pool
|
|
1403
|
+
createdProcess.on('close', () => {
|
|
1404
|
+
poolLogger.info(`Process for ${channelKey} closed, removing from pool`);
|
|
1405
|
+
removeIfCurrent();
|
|
1406
|
+
});
|
|
1407
|
+
createdProcess.on('idle', touchIfCurrent);
|
|
1408
|
+
this.processes.set(channelKey, {
|
|
1409
|
+
process: createdProcess,
|
|
1410
|
+
lastUsedAt: now,
|
|
1411
|
+
policyFingerprint: mergedOptions.policyFingerprint,
|
|
1412
|
+
});
|
|
1413
|
+
await createdProcess.start();
|
|
1414
|
+
created = true;
|
|
1415
|
+
}
|
|
1416
|
+
else if (entry) {
|
|
1417
|
+
entry.lastUsedAt = now;
|
|
1418
|
+
}
|
|
1419
|
+
return { process, created };
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Stop idle ready processes that have outlived the configured idle timeout.
|
|
1423
|
+
*/
|
|
1424
|
+
cleanupIdleProcesses(now = Date.now()) {
|
|
1425
|
+
let cleaned = 0;
|
|
1426
|
+
for (const [key, entry] of this.processes) {
|
|
1427
|
+
const process = entry.process;
|
|
1428
|
+
if (!process.isAlive()) {
|
|
1429
|
+
this.processes.delete(key);
|
|
1430
|
+
cleaned++;
|
|
1431
|
+
continue;
|
|
1432
|
+
}
|
|
1433
|
+
if (process.hasPendingToolUse()) {
|
|
1434
|
+
entry.pendingToolUseSince =
|
|
1435
|
+
process.getPendingToolUseStartedAt() ?? entry.pendingToolUseSince ?? now;
|
|
1436
|
+
if (this.pendingToolUseTimeoutMs === 0 ||
|
|
1437
|
+
now - entry.pendingToolUseSince <= this.pendingToolUseTimeoutMs) {
|
|
1438
|
+
continue;
|
|
1439
|
+
}
|
|
1440
|
+
poolLogger.warn(`Stopping process with expired pending tool result wait for: ${key}`);
|
|
1441
|
+
process.stop();
|
|
1442
|
+
this.processes.delete(key);
|
|
1443
|
+
cleaned++;
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1446
|
+
entry.pendingToolUseSince = undefined;
|
|
1447
|
+
if (this.idleTimeoutMs > 0 &&
|
|
1448
|
+
process.isReady() &&
|
|
1449
|
+
now - entry.lastUsedAt > this.idleTimeoutMs) {
|
|
1450
|
+
poolLogger.info(`Stopping idle process for: ${key}`);
|
|
1451
|
+
process.stop();
|
|
1452
|
+
this.processes.delete(key);
|
|
1453
|
+
cleaned++;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
return cleaned;
|
|
1457
|
+
}
|
|
1458
|
+
/**
|
|
1459
|
+
* Stop a specific process
|
|
1460
|
+
*/
|
|
1461
|
+
stopProcess(channelKey) {
|
|
1462
|
+
const entry = this.processes.get(channelKey);
|
|
1463
|
+
if (entry) {
|
|
1464
|
+
entry.process.stop();
|
|
1465
|
+
this.processes.delete(channelKey);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
getSessionPolicyStatus(channelKey, policyFingerprint) {
|
|
1469
|
+
const entry = this.processes.get(channelKey);
|
|
1470
|
+
if (!entry?.process.isAlive()) {
|
|
1471
|
+
return 'missing';
|
|
1472
|
+
}
|
|
1473
|
+
return entry.policyFingerprint === policyFingerprint ? 'compatible' : 'mismatch';
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Stop the exact process generation acquired by the caller. Only remove the pool entry when it
|
|
1477
|
+
* still points at that generation, so stale cleanup cannot stop or evict a replacement. The
|
|
1478
|
+
* expected process may already be absent after its error listener ran; it still must be stopped
|
|
1479
|
+
* or the child would remain alive outside pool lifecycle management.
|
|
1480
|
+
*/
|
|
1481
|
+
retireProcess(channelKey, expectedProcess) {
|
|
1482
|
+
const entry = this.processes.get(channelKey);
|
|
1483
|
+
const removedCurrentGeneration = entry?.process === expectedProcess;
|
|
1484
|
+
if (removedCurrentGeneration) {
|
|
1485
|
+
this.processes.delete(channelKey);
|
|
1486
|
+
}
|
|
1487
|
+
expectedProcess.stop();
|
|
1488
|
+
return removedCurrentGeneration;
|
|
1489
|
+
}
|
|
1490
|
+
/**
|
|
1491
|
+
* Stop all processes
|
|
1492
|
+
*/
|
|
1493
|
+
stopAll() {
|
|
1494
|
+
for (const [key, entry] of this.processes) {
|
|
1495
|
+
poolLogger.info(`Stopping process for: ${key}`);
|
|
1496
|
+
entry.process.stop();
|
|
1497
|
+
}
|
|
1498
|
+
this.processes.clear();
|
|
1499
|
+
if (this.cleanupTimer) {
|
|
1500
|
+
clearInterval(this.cleanupTimer);
|
|
1501
|
+
this.cleanupTimer = null;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Get number of active processes
|
|
1506
|
+
*/
|
|
1507
|
+
getActiveCount() {
|
|
1508
|
+
return this.processes.size;
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* Get all channel keys with active processes
|
|
1512
|
+
*/
|
|
1513
|
+
getActiveChannels() {
|
|
1514
|
+
return Array.from(this.processes.keys());
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Get states of all active processes
|
|
1518
|
+
* @returns Map of channelKey → ProcessState
|
|
1519
|
+
*/
|
|
1520
|
+
getProcessStates() {
|
|
1521
|
+
const states = new Map();
|
|
1522
|
+
for (const [key, entry] of this.processes) {
|
|
1523
|
+
states.set(key, entry.process.getState());
|
|
1524
|
+
}
|
|
1525
|
+
return states;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
exports.PersistentProcessPool = PersistentProcessPool;
|
|
1529
|
+
//# sourceMappingURL=persistent-cli-process.js.map
|