@cuongtran001/kanna 1.2.0 → 1.3.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/dist/client/assets/{arc-BLk1CZKB.js → arc-DXtN3EWJ.js} +1 -1
- package/dist/client/assets/{architectureDiagram-3BPJPVTR-B01ZQYte.js → architectureDiagram-3BPJPVTR-CIGqW1z-.js} +1 -1
- package/dist/client/assets/{blockDiagram-GPEHLZMM-diL6a3cq.js → blockDiagram-GPEHLZMM-BafNS_H6.js} +1 -1
- package/dist/client/assets/{c4Diagram-AAUBKEIU-BY1verL8.js → c4Diagram-AAUBKEIU-YdAgRSis.js} +1 -1
- package/dist/client/assets/channel-BEYTrUAm.js +1 -0
- package/dist/client/assets/{chunk-2J33WTMH-psdZuajw.js → chunk-2J33WTMH-eiP5y5G-.js} +1 -1
- package/dist/client/assets/{chunk-4BX2VUAB-CrwQVBc5.js → chunk-4BX2VUAB-CvLGzPMF.js} +1 -1
- package/dist/client/assets/{chunk-55IACEB6-9-aj4jKO.js → chunk-55IACEB6-Y-S9Fb8A.js} +1 -1
- package/dist/client/assets/{chunk-727SXJPM-1FNK9_nB.js → chunk-727SXJPM-6sJH1RI9.js} +1 -1
- package/dist/client/assets/{chunk-AQP2D5EJ-DFhvd_Ek.js → chunk-AQP2D5EJ-hV1r_IXO.js} +1 -1
- package/dist/client/assets/{chunk-FMBD7UC4-B7MLrDa7.js → chunk-FMBD7UC4-CC3L28S5.js} +1 -1
- package/dist/client/assets/{chunk-ND2GUHAM-D4Df05QE.js → chunk-ND2GUHAM-BZ3czhdH.js} +1 -1
- package/dist/client/assets/{chunk-QZHKN3VN-Iq17Dznm.js → chunk-QZHKN3VN-Bjyd6Yr-.js} +1 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-CvnF5Ud6.js +1 -0
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CvnF5Ud6.js +1 -0
- package/dist/client/assets/{cose-bilkent-S5V4N54A-BmaZhtXy.js → cose-bilkent-S5V4N54A-CfxdDghE.js} +1 -1
- package/dist/client/assets/{dagre-BM42HDAG-BCmpfnfP.js → dagre-BM42HDAG-B8CddsbB.js} +1 -1
- package/dist/client/assets/{diagram-2AECGRRQ-BIEcKowS.js → diagram-2AECGRRQ-F0yS0fVB.js} +1 -1
- package/dist/client/assets/{diagram-5GNKFQAL-CRBABBfv.js → diagram-5GNKFQAL-cW0i0qlh.js} +1 -1
- package/dist/client/assets/{diagram-KO2AKTUF-BRxUk16N.js → diagram-KO2AKTUF-BzAKMeJT.js} +1 -1
- package/dist/client/assets/{diagram-LMA3HP47-DPBs4xl1.js → diagram-LMA3HP47-qdC4XqIl.js} +1 -1
- package/dist/client/assets/{diagram-OG6HWLK6-BE9rgVv8.js → diagram-OG6HWLK6-CGcpi6Dh.js} +1 -1
- package/dist/client/assets/{erDiagram-TEJ5UH35-D3vgA1xJ.js → erDiagram-TEJ5UH35-CxNTDicM.js} +1 -1
- package/dist/client/assets/{flowDiagram-I6XJVG4X-Cgu-8XXC.js → flowDiagram-I6XJVG4X-qkGa5TqY.js} +1 -1
- package/dist/client/assets/{ganttDiagram-6RSMTGT7-CgjXVyTR.js → ganttDiagram-6RSMTGT7-D9mVPoQi.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-PVQCEYII-zD5D8B-i.js → gitGraphDiagram-PVQCEYII-DA0grAXe.js} +1 -1
- package/dist/client/assets/{index-DBTdhMbJ.js → index-BqUY1Bm4.js} +1 -1
- package/dist/client/assets/index-CLsOWXff.css +32 -0
- package/dist/client/assets/{index-zQLLeAfl.js → index-TU1k6ov6.js} +253 -243
- package/dist/client/assets/{infoDiagram-5YYISTIA-BaFHqT4u.js → infoDiagram-5YYISTIA-CGljtLs3.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-2yAC1br2.js → ishikawaDiagram-YF4QCWOH-CtC0xrmG.js} +1 -1
- package/dist/client/assets/{journeyDiagram-JHISSGLW-DacQ-ND5.js → journeyDiagram-JHISSGLW-URLnVZeN.js} +1 -1
- package/dist/client/assets/{kanban-definition-UN3LZRKU-Qlbwm3z4.js → kanban-definition-UN3LZRKU-C2Q6aqQh.js} +1 -1
- package/dist/client/assets/{linear-DeafqpgO.js → linear-ByKHtsIx.js} +1 -1
- package/dist/client/assets/{mermaid.core-KYiCPJDj.js → mermaid.core-Dl_f2KLc.js} +4 -4
- package/dist/client/assets/{mindmap-definition-RKZ34NQL-B1V-T6d3.js → mindmap-definition-RKZ34NQL-CaJ9IDLI.js} +1 -1
- package/dist/client/assets/{pieDiagram-4H26LBE5-DOF9OFRu.js → pieDiagram-4H26LBE5-mmYaf2Oa.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-W4KKPZXB-lH1mB7ko.js → quadrantDiagram-W4KKPZXB-B_PO6OCC.js} +1 -1
- package/dist/client/assets/{requirementDiagram-4Y6WPE33-BaTsJ_sS.js → requirementDiagram-4Y6WPE33-M4huwJt1.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-5OEKKPKP-Pfxib5iI.js → sankeyDiagram-5OEKKPKP-B0hb1sWh.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-3UESZ5HK-BQMEKkBC.js → sequenceDiagram-3UESZ5HK-BAqSptnA.js} +1 -1
- package/dist/client/assets/{stateDiagram-AJRCARHV-CuldNnzp.js → stateDiagram-AJRCARHV-DTNkvPi5.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CVN3oGTF.js +1 -0
- package/dist/client/assets/{timeline-definition-PNZ67QCA-DP7u0YAg.js → timeline-definition-PNZ67QCA-MgAjHz8Q.js} +1 -1
- package/dist/client/assets/{vennDiagram-CIIHVFJN-QlrjpNeQ.js → vennDiagram-CIIHVFJN-DmKoO51W.js} +1 -1
- package/dist/client/assets/{wardley-L42UT6IY-BqdJ_sAM.js → wardley-L42UT6IY-CWcyHnYs.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Wi3kaQ5X.js → wardleyDiagram-YWT4CUSO-BGIGmFP9.js} +1 -1
- package/dist/client/assets/{xychartDiagram-2RQKCTM6-D-KXMyCG.js → xychartDiagram-2RQKCTM6-D20YIvEl.js} +1 -1
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/agent-coordinator-types.ts +158 -0
- package/src/server/agent-coordinator.ts +919 -0
- package/src/server/agent-deps-builders.ts +425 -0
- package/src/server/agent.oauth-rotation.test.ts +101 -0
- package/src/server/agent.test.ts +24 -8
- package/src/server/agent.ts +35 -4963
- package/src/server/auto-continue/read-model.test.ts +2 -2
- package/src/server/auto-continue/read-model.ts +4 -1
- package/src/server/claude-autocontinue-commands.test.ts +494 -0
- package/src/server/claude-autocontinue-commands.ts +256 -0
- package/src/server/claude-cancel-handler.test.ts +472 -0
- package/src/server/claude-cancel-handler.ts +241 -0
- package/src/server/claude-chat-management.test.ts +395 -0
- package/src/server/claude-chat-management.ts +251 -0
- package/src/server/claude-harness-stream.test.ts +273 -0
- package/src/server/claude-harness-stream.ts +158 -0
- package/src/server/claude-loop-orch-commands.test.ts +579 -0
- package/src/server/claude-loop-orch-commands.ts +466 -0
- package/src/server/claude-message-normalizer.test.ts +213 -0
- package/src/server/claude-message-normalizer.ts +436 -0
- package/src/server/claude-prompt-helpers.test.ts +248 -0
- package/src/server/claude-prompt-helpers.ts +139 -0
- package/src/server/claude-sdk-queue.test.ts +169 -0
- package/src/server/claude-sdk-queue.ts +82 -0
- package/src/server/claude-send-command.test.ts +414 -0
- package/src/server/claude-send-command.ts +420 -0
- package/src/server/claude-session-config-helpers.test.ts +279 -0
- package/src/server/claude-session-config-helpers.ts +151 -0
- package/src/server/claude-session-config.test.ts +250 -0
- package/src/server/claude-session-config.ts +159 -0
- package/src/server/claude-session-error-handler.test.ts +498 -0
- package/src/server/claude-session-error-handler.ts +361 -0
- package/src/server/claude-session-lifecycle.test.ts +669 -0
- package/src/server/claude-session-lifecycle.ts +283 -0
- package/src/server/claude-session-rebuild.test.ts +224 -0
- package/src/server/claude-session-rebuild.ts +101 -0
- package/src/server/claude-session-runner.test.ts +523 -0
- package/src/server/claude-session-runner.ts +521 -0
- package/src/server/claude-session-spawner.test.ts +401 -0
- package/src/server/claude-session-spawner.ts +401 -0
- package/src/server/claude-session-start.test.ts +205 -0
- package/src/server/claude-session-start.ts +277 -0
- package/src/server/claude-session-state-queries.test.ts +344 -0
- package/src/server/claude-session-state-queries.ts +160 -0
- package/src/server/claude-session-state.ts +104 -0
- package/src/server/claude-slash-commands.test.ts +277 -0
- package/src/server/claude-slash-commands.ts +234 -0
- package/src/server/claude-spawn-helpers.test.ts +221 -0
- package/src/server/claude-spawn-helpers.ts +179 -0
- package/src/server/claude-steer-log.test.ts +243 -0
- package/src/server/claude-steer-log.ts +58 -0
- package/src/server/claude-subagent-tool-response.test.ts +213 -0
- package/src/server/claude-subagent-tool-response.ts +165 -0
- package/src/server/claude-subagent-wiring.test.ts +263 -0
- package/src/server/claude-subagent-wiring.ts +324 -0
- package/src/server/claude-tool-respond.test.ts +321 -0
- package/src/server/claude-tool-respond.ts +142 -0
- package/src/server/claude-turn-runner.test.ts +310 -0
- package/src/server/claude-turn-runner.ts +228 -0
- package/src/server/claude-turn-starter.test.ts +266 -0
- package/src/server/claude-turn-starter.ts +566 -0
- package/src/server/claude-usage-math.test.ts +169 -0
- package/src/server/claude-usage-math.ts +132 -0
- package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
- package/src/server/diff-store-branch-ops.adapter.ts +602 -0
- package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
- package/src/server/diff-store-commit-ops.adapter.ts +258 -0
- package/src/server/diff-store-errors.ts +85 -0
- package/src/server/diff-store-file-ops.adapter.ts +212 -0
- package/src/server/diff-store-git-branch.adapter.ts +686 -0
- package/src/server/diff-store-io.adapter.ts +27 -0
- package/src/server/diff-store-parse.test.ts +212 -0
- package/src/server/diff-store-parse.ts +122 -0
- package/src/server/diff-store-state.ts +82 -0
- package/src/server/diff-store.ts +76 -1854
- package/src/server/event-store-apply.test.ts +280 -0
- package/src/server/event-store-apply.ts +132 -0
- package/src/server/event-store-chat-lifecycle.test.ts +408 -0
- package/src/server/event-store-chat-lifecycle.ts +441 -0
- package/src/server/event-store-entity-write.ts +356 -0
- package/src/server/event-store-helpers.test.ts +230 -0
- package/src/server/event-store-helpers.ts +235 -0
- package/src/server/event-store-init.ts +276 -0
- package/src/server/event-store-messages.adapter.test.ts +365 -0
- package/src/server/event-store-messages.adapter.ts +213 -0
- package/src/server/event-store-orch-subscription.ts +124 -0
- package/src/server/event-store-orch.test.ts +462 -0
- package/src/server/event-store-orch.ts +312 -0
- package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
- package/src/server/event-store-peripheral-events.adapter.ts +105 -0
- package/src/server/event-store-snapshot.test.ts +597 -0
- package/src/server/event-store-snapshot.ts +658 -0
- package/src/server/event-store-subagent.test.ts +408 -0
- package/src/server/event-store-subagent.ts +198 -0
- package/src/server/event-store-tool-requests.test.ts +210 -0
- package/src/server/event-store-tool-requests.ts +131 -0
- package/src/server/event-store-transcript-write.adapter.ts +285 -0
- package/src/server/event-store-write-ops.test.ts +554 -0
- package/src/server/event-store-write-ops.ts +529 -0
- package/src/server/event-store.test.ts +35 -0
- package/src/server/event-store.ts +251 -2284
- package/src/server/events.ts +3 -0
- package/src/server/harness-types.ts +15 -1
- package/src/server/oauth-errors.ts +19 -0
- package/src/server/read-models.ts +26 -1
- package/src/server/subagent-orchestrator.ts +3 -0
- package/src/server/ws-router-agent-ctrl.test.ts +187 -0
- package/src/server/ws-router-agent-ctrl.ts +137 -0
- package/src/server/ws-router-broadcast.ts +488 -0
- package/src/server/ws-router-chat.test.ts +461 -0
- package/src/server/ws-router-chat.ts +279 -0
- package/src/server/ws-router-defaults.test.ts +283 -0
- package/src/server/ws-router-defaults.ts +434 -0
- package/src/server/ws-router-diff.test.ts +167 -0
- package/src/server/ws-router-diff.ts +250 -0
- package/src/server/ws-router-envelope.ts +329 -0
- package/src/server/ws-router-misc.test.ts +422 -0
- package/src/server/ws-router-misc.ts +265 -0
- package/src/server/ws-router-orch.test.ts +190 -0
- package/src/server/ws-router-orch.ts +93 -0
- package/src/server/ws-router-project.test.ts +403 -0
- package/src/server/ws-router-project.ts +256 -0
- package/src/server/ws-router-push.test.ts +193 -0
- package/src/server/ws-router-push.ts +120 -0
- package/src/server/ws-router-settings.test.ts +173 -0
- package/src/server/ws-router-settings.ts +416 -0
- package/src/server/ws-router-skills.test.ts +194 -0
- package/src/server/ws-router-skills.ts +204 -0
- package/src/server/ws-router-utils.ts +224 -0
- package/src/server/ws-router.ts +339 -2254
- package/src/shared/app-settings-types.ts +365 -0
- package/src/shared/core-types.ts +27 -0
- package/src/shared/git-diff-types.ts +165 -0
- package/src/shared/loop-progress.test.ts +106 -0
- package/src/shared/loop-progress.ts +88 -0
- package/src/shared/mcp-types.ts +105 -0
- package/src/shared/provider-model-types.ts +437 -0
- package/src/shared/subagent-types.ts +198 -0
- package/src/shared/tool-call-types.ts +314 -0
- package/src/shared/transcript-types.ts +270 -0
- package/src/shared/types.ts +27 -1668
- package/dist/client/assets/channel-BMkhjiLI.js +0 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-C5Kg1XOD.js +0 -1
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-C5Kg1XOD.js +0 -1
- package/dist/client/assets/index-H6C-f2Vp.css +0 -32
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Ca2U11af.js +0 -1
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone session-lifecycle helpers for AgentCoordinator.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from agent.ts so the eight related private methods live in their
|
|
5
|
+
* own testable module. The coordinator delegates to these functions by passing
|
|
6
|
+
* an object literal that satisfies `SessionLifecycleDeps`.
|
|
7
|
+
*
|
|
8
|
+
* Side-effect seal: this module contains NO direct IO (no node:fs, no HTTP
|
|
9
|
+
* calls, no Bun primitives). Every effectful operation is injected through
|
|
10
|
+
* the deps interface, including the `homeDir` used by computeWorkflowsDir so
|
|
11
|
+
* tests can control it without real OS calls.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ClaudeDriverPreference } from "../shared/types"
|
|
15
|
+
import type { ClaudeSessionState, ActiveTurn } from "./claude-session-state"
|
|
16
|
+
import type { TokenUnavailability } from "./oauth-pool/oauth-token-pool"
|
|
17
|
+
import { computeWorkflowsDir } from "./claude-pty/jsonl-path.adapter"
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Structural sub-interfaces — only the operations this module calls.
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/** Subset of OAuthTokenPool used by the lifecycle helpers. */
|
|
24
|
+
interface LifecycleOAuthPool {
|
|
25
|
+
release(chatId: string): void
|
|
26
|
+
describeUnavailability(reservedFor?: string): TokenUnavailability[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Subset of WorkflowRegistry used by the lifecycle helpers. */
|
|
30
|
+
interface LifecycleWorkflowRegistry {
|
|
31
|
+
hasActiveRun(chatId: string, freshnessMs: number, now: number): boolean
|
|
32
|
+
register(chatId: string, workflowsDir: string): void
|
|
33
|
+
unregister(chatId: string): void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Subset of EventStore used by buildPoolUnavailableMessage. */
|
|
37
|
+
interface LifecycleStore {
|
|
38
|
+
getChat(id: string): { title?: string | null } | null | undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Dependency bundle injected by AgentCoordinator
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
export interface SessionLifecycleDeps {
|
|
46
|
+
/** Returns the current app settings snapshot. Only lifecycle + driver slices are read. */
|
|
47
|
+
getAppSettingsSnapshot(): {
|
|
48
|
+
claudeDriver?: {
|
|
49
|
+
lifecycle?: {
|
|
50
|
+
idleTimeoutMs?: number
|
|
51
|
+
maxConcurrent?: number
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Default idle timeout (from claudeSessionLifecycle.idleMs constructor arg). */
|
|
57
|
+
defaultIdleMs: number
|
|
58
|
+
/** Default max resident sessions (from claudeSessionLifecycle.maxResidentSessions). */
|
|
59
|
+
defaultMaxResidentSessions: number
|
|
60
|
+
|
|
61
|
+
/** The live claudeSessions map owned by the coordinator. */
|
|
62
|
+
claudeSessions: Map<string, ClaudeSessionState>
|
|
63
|
+
/** Active turns map — only `.has()` is called. */
|
|
64
|
+
activeTurns: Pick<Map<string, ActiveTurn>, "has">
|
|
65
|
+
|
|
66
|
+
/** OAuth token pool, or null when no pool is configured. */
|
|
67
|
+
oauthPool: LifecycleOAuthPool | null
|
|
68
|
+
|
|
69
|
+
/** Workflow registry, or null when not configured. */
|
|
70
|
+
workflowRegistry: LifecycleWorkflowRegistry | null
|
|
71
|
+
|
|
72
|
+
/** Returns the currently resolved Claude driver preference. */
|
|
73
|
+
resolveClaudeDriverPreference(): ClaudeDriverPreference
|
|
74
|
+
|
|
75
|
+
/** Emit a state-change event for a chat (called after evictions). */
|
|
76
|
+
emitStateChange(chatId: string): void
|
|
77
|
+
|
|
78
|
+
/** Store — used by buildPoolUnavailableMessage to resolve chat titles. */
|
|
79
|
+
store: LifecycleStore
|
|
80
|
+
|
|
81
|
+
/** Home directory — injected so tests do not need a real OS call. */
|
|
82
|
+
homeDir: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Exported standalone functions
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the effective Claude idle timeout. Prefers the user-configured
|
|
91
|
+
* override in app settings; falls back to the constructor default.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveClaudeIdleMs(deps: SessionLifecycleDeps): number {
|
|
94
|
+
const fromSettings = deps.getAppSettingsSnapshot().claudeDriver?.lifecycle?.idleTimeoutMs
|
|
95
|
+
if (typeof fromSettings === "number" && Number.isFinite(fromSettings) && fromSettings > 0) {
|
|
96
|
+
return Math.round(fromSettings)
|
|
97
|
+
}
|
|
98
|
+
return deps.defaultIdleMs
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve the effective max-resident-sessions cap. Prefers the user-configured
|
|
103
|
+
* override in app settings; falls back to the constructor default.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveClaudeMaxResident(deps: SessionLifecycleDeps): number {
|
|
106
|
+
const fromSettings = deps.getAppSettingsSnapshot().claudeDriver?.lifecycle?.maxConcurrent
|
|
107
|
+
if (typeof fromSettings === "number" && Number.isFinite(fromSettings) && fromSettings > 0) {
|
|
108
|
+
return Math.round(fromSettings)
|
|
109
|
+
}
|
|
110
|
+
return deps.defaultMaxResidentSessions
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* True when the chat is hosting an in-flight background Workflow. A live
|
|
115
|
+
* workflow runs inside the warm PTY claude process but registers no
|
|
116
|
+
* activeTurn, pendingPromptSeq, or lastUsedAt bump, so without this signal
|
|
117
|
+
* the idle reaper / budget enforcer would tear the process down mid-run and
|
|
118
|
+
* abort the workflow.
|
|
119
|
+
*
|
|
120
|
+
* Liveness comes from the registry's live-run-dir probe, NOT the terminal
|
|
121
|
+
* `wf_<runId>.json` sidecar: Claude only flushes that sidecar at/near
|
|
122
|
+
* termination, so a sidecar-only check is blind for the entire run (the
|
|
123
|
+
* window the guard must cover). `hasActiveRun` reads the live
|
|
124
|
+
* `subagents/workflows/wf_*` transcript dirs (written from second one) and
|
|
125
|
+
* requires activity within one idle window so a stalled/crashed run still
|
|
126
|
+
* eventually reaps.
|
|
127
|
+
*/
|
|
128
|
+
export function hasLiveWorkflow(deps: SessionLifecycleDeps, chatId: string): boolean {
|
|
129
|
+
return deps.workflowRegistry?.hasActiveRun(chatId, resolveClaudeIdleMs(deps), Date.now()) ?? false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* True while the session has at least one Claude-Code background Bash task
|
|
134
|
+
* that has not yet settled. Primary gate is set size > 0: settle events
|
|
135
|
+
* (task_notification) remove their id from the set, so the guard clears the
|
|
136
|
+
* moment the last task reports. The deadline is a zombie backstop only —
|
|
137
|
+
* it fires when a settle notification is genuinely lost (SDK crash / dropped
|
|
138
|
+
* message) and is reset on every launch and settle, so it never expires
|
|
139
|
+
* during normal execution regardless of task duration.
|
|
140
|
+
*/
|
|
141
|
+
export function hasPendingBackgroundTask(session: ClaudeSessionState, now: number): boolean {
|
|
142
|
+
if (session.backgroundTaskIds.size === 0) return false
|
|
143
|
+
if (now < session.backgroundTaskDeadlineAt) return true
|
|
144
|
+
session.backgroundTaskIds.clear()
|
|
145
|
+
session.backgroundTaskDeadlineAt = 0
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Tear down a Claude session and (by default) release the OAuth-pool
|
|
151
|
+
* reservation owned by the chat.
|
|
152
|
+
*
|
|
153
|
+
* `keepReservation: true` — used by rate-limit / auth-error rotation
|
|
154
|
+
* paths that have ALREADY claimed a fresh token via `pickActive(chatId)`
|
|
155
|
+
* before calling close. Without this flag, `release(chatId)` would
|
|
156
|
+
* scan reservedBy for `owner === chatId` and drop the *new* token the
|
|
157
|
+
* rotation just claimed, leaking the rotation's reservation (audit #9d).
|
|
158
|
+
*/
|
|
159
|
+
export function closeClaudeSession(
|
|
160
|
+
deps: SessionLifecycleDeps,
|
|
161
|
+
chatId: string,
|
|
162
|
+
session: ClaudeSessionState,
|
|
163
|
+
opts?: { keepReservation?: boolean },
|
|
164
|
+
): void {
|
|
165
|
+
if (deps.claudeSessions.get(chatId) === session) {
|
|
166
|
+
deps.claudeSessions.delete(chatId)
|
|
167
|
+
}
|
|
168
|
+
if (!opts?.keepReservation) {
|
|
169
|
+
deps.oauthPool?.release(chatId)
|
|
170
|
+
}
|
|
171
|
+
session.session.close()
|
|
172
|
+
// For SDK sessions, unregister the workflow dir here. PTY sessions unregister
|
|
173
|
+
// inside the driver's cleanupResources (driver.ts) — do not double-fire.
|
|
174
|
+
if (deps.resolveClaudeDriverPreference() !== "pty") {
|
|
175
|
+
deps.workflowRegistry?.unregister(chatId)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Register the workflow disk-watch dir for an SDK session once the session
|
|
181
|
+
* token is known. No-op if the registry is absent, already registered, or
|
|
182
|
+
* the driver preference is PTY (the PTY driver registers from its own
|
|
183
|
+
* resolved transcript path in driver.ts cleanup and must not be double-fired).
|
|
184
|
+
*/
|
|
185
|
+
export function maybeRegisterSdkWorkflowsDir(
|
|
186
|
+
deps: SessionLifecycleDeps,
|
|
187
|
+
session: ClaudeSessionState,
|
|
188
|
+
): void {
|
|
189
|
+
if (!deps.workflowRegistry) return
|
|
190
|
+
if (session.workflowsDirRegistered) return
|
|
191
|
+
// PTY registers from its own resolved transcript path; SDK derives from session_token.
|
|
192
|
+
if (deps.resolveClaudeDriverPreference() === "pty") return
|
|
193
|
+
if (!session.sessionToken) return
|
|
194
|
+
const dir = computeWorkflowsDir({
|
|
195
|
+
homeDir: deps.homeDir,
|
|
196
|
+
cwd: session.localPath,
|
|
197
|
+
sessionId: session.sessionToken,
|
|
198
|
+
})
|
|
199
|
+
deps.workflowRegistry.register(session.chatId, dir)
|
|
200
|
+
session.workflowsDirRegistered = true
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Evict LRU idle sessions when the resident count exceeds the configured cap.
|
|
205
|
+
* Never evicts: the protected chat, chats with an active turn, chats with
|
|
206
|
+
* queued prompts, chats with a live workflow, or chats with a pending
|
|
207
|
+
* background task.
|
|
208
|
+
*/
|
|
209
|
+
export function enforceClaudeSessionBudget(
|
|
210
|
+
deps: SessionLifecycleDeps,
|
|
211
|
+
protectedChatId?: string,
|
|
212
|
+
): void {
|
|
213
|
+
const max = resolveClaudeMaxResident(deps)
|
|
214
|
+
if (max <= 0 || deps.claudeSessions.size <= max) return
|
|
215
|
+
|
|
216
|
+
const now = Date.now()
|
|
217
|
+
const candidates = [...deps.claudeSessions.entries()]
|
|
218
|
+
.filter(([chatId, session]) => (
|
|
219
|
+
chatId !== protectedChatId
|
|
220
|
+
&& !deps.activeTurns.has(chatId)
|
|
221
|
+
&& session.pendingPromptSeqs.length === 0
|
|
222
|
+
&& !hasLiveWorkflow(deps, chatId)
|
|
223
|
+
&& !hasPendingBackgroundTask(session, now)
|
|
224
|
+
))
|
|
225
|
+
.sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt)
|
|
226
|
+
|
|
227
|
+
while (deps.claudeSessions.size > max && candidates.length > 0) {
|
|
228
|
+
const next = candidates.shift()
|
|
229
|
+
if (!next) break
|
|
230
|
+
const [chatId, session] = next
|
|
231
|
+
closeClaudeSession(deps, chatId, session)
|
|
232
|
+
deps.emitStateChange(chatId)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Format a refusal message when `pickActive(chatId)` returned null but the
|
|
238
|
+
* pool has tokens. Names the offending tokens so the user knows which
|
|
239
|
+
* chat to close or which token to add a quota to, instead of seeing the
|
|
240
|
+
* generic "all tokens unavailable" line that doesn't say what's holding
|
|
241
|
+
* them. `scopeSuffix` lets the subagent path tag its variant.
|
|
242
|
+
*/
|
|
243
|
+
export function buildPoolUnavailableMessage(
|
|
244
|
+
deps: SessionLifecycleDeps,
|
|
245
|
+
reservedFor: string,
|
|
246
|
+
scopeSuffix: string,
|
|
247
|
+
): string {
|
|
248
|
+
const pool = deps.oauthPool
|
|
249
|
+
if (!pool) {
|
|
250
|
+
return `All OAuth tokens are unavailable${scopeSuffix} (rate-limited, errored, or in use).`
|
|
251
|
+
}
|
|
252
|
+
const now = Date.now()
|
|
253
|
+
const fmtTime = (ms: number): string => {
|
|
254
|
+
const mins = Math.max(0, Math.round((ms - now) / 60_000))
|
|
255
|
+
if (mins < 60) return `${mins}m`
|
|
256
|
+
const h = Math.floor(mins / 60)
|
|
257
|
+
const m = mins % 60
|
|
258
|
+
return m === 0 ? `${h}h` : `${h}h${m}m`
|
|
259
|
+
}
|
|
260
|
+
const lines: string[] = []
|
|
261
|
+
for (const u of pool.describeUnavailability(reservedFor)) {
|
|
262
|
+
if (u.reason === "available") continue
|
|
263
|
+
const label = u.label || u.tokenId.slice(0, 8)
|
|
264
|
+
if (u.reason === "limited") {
|
|
265
|
+
lines.push(` - ${label}: rate-limited (~${fmtTime(u.until)} remaining)`)
|
|
266
|
+
} else if (u.reason === "reserved") {
|
|
267
|
+
const refs = u.byChatIds.map((id) => {
|
|
268
|
+
const chat = deps.store.getChat(id)
|
|
269
|
+
const title = chat?.title || `chat ${id.slice(0, 8)}`
|
|
270
|
+
return `[${title}](/chat/${id})`
|
|
271
|
+
})
|
|
272
|
+
const joined = refs.length === 0 ? "another chat" : refs.join(", ")
|
|
273
|
+
lines.push(` - ${label}: in use by ${joined}`)
|
|
274
|
+
} else if (u.reason === "error") {
|
|
275
|
+
lines.push(` - ${label}: errored${u.message ? ` (${u.message})` : ""}`)
|
|
276
|
+
} else if (u.reason === "disabled") {
|
|
277
|
+
lines.push(` - ${label}: disabled`)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const header = `All OAuth tokens are unavailable${scopeSuffix}:`
|
|
281
|
+
const footer = "Close the chat holding a contested token, wait for the rate-limit to reset, or add another token."
|
|
282
|
+
return [header, ...lines, footer].join("\n")
|
|
283
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for claude-session-rebuild.ts helpers.
|
|
3
|
+
*
|
|
4
|
+
* Uses fake/stub deps so no real IO is involved. Covers both exported
|
|
5
|
+
* functions: recreateActiveTurnFromSession and findLastUserMessageId.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, test, expect } from "bun:test"
|
|
8
|
+
import {
|
|
9
|
+
findLastUserMessageId,
|
|
10
|
+
recreateActiveTurnFromSession,
|
|
11
|
+
type SessionRebuildDeps,
|
|
12
|
+
} from "./claude-session-rebuild"
|
|
13
|
+
import type { ClaudeSessionState, ActiveTurn } from "./claude-session-state"
|
|
14
|
+
import type { MessageEntry } from "./claude-session-rebuild"
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Helpers
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
function makeSession(overrides: Partial<ClaudeSessionState> = {}): ClaudeSessionState {
|
|
21
|
+
return {
|
|
22
|
+
id: "session-1",
|
|
23
|
+
chatId: "chat-1",
|
|
24
|
+
session: {
|
|
25
|
+
provider: "claude",
|
|
26
|
+
stream: {
|
|
27
|
+
async *[Symbol.asyncIterator]() {},
|
|
28
|
+
},
|
|
29
|
+
getAccountInfo: async () => null,
|
|
30
|
+
interrupt: async () => {},
|
|
31
|
+
close: () => {},
|
|
32
|
+
sendPrompt: async () => {},
|
|
33
|
+
setModel: async () => {},
|
|
34
|
+
setPermissionMode: async () => {},
|
|
35
|
+
getSupportedCommands: async () => [],
|
|
36
|
+
},
|
|
37
|
+
localPath: "/tmp/test",
|
|
38
|
+
additionalDirectories: [],
|
|
39
|
+
model: "claude-opus-4-5",
|
|
40
|
+
effort: undefined,
|
|
41
|
+
planMode: false,
|
|
42
|
+
sessionToken: null,
|
|
43
|
+
accountInfoLoaded: false,
|
|
44
|
+
nextPromptSeq: 0,
|
|
45
|
+
pendingPromptSeqs: [],
|
|
46
|
+
activeTokenId: null,
|
|
47
|
+
oauthKeyMasked: null,
|
|
48
|
+
oauthLabel: null,
|
|
49
|
+
openrouterKeyMasked: null,
|
|
50
|
+
openrouterModel: null,
|
|
51
|
+
lastUsedAt: 0,
|
|
52
|
+
backgroundTaskIds: new Set(),
|
|
53
|
+
backgroundTaskDeadlineAt: 0,
|
|
54
|
+
// loopArmedAtSpawn is optional per the type - omit it
|
|
55
|
+
...overrides,
|
|
56
|
+
} as ClaudeSessionState
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makeDeps(overrides: {
|
|
60
|
+
claudeSessions?: Map<string, ClaudeSessionState>
|
|
61
|
+
activeTurns?: Map<string, ActiveTurn>
|
|
62
|
+
providerUsesSdkSession?: (p: string) => boolean
|
|
63
|
+
messages?: MessageEntry[]
|
|
64
|
+
} = {}): SessionRebuildDeps {
|
|
65
|
+
return {
|
|
66
|
+
claudeSessions: overrides.claudeSessions ?? new Map(),
|
|
67
|
+
activeTurns: overrides.activeTurns ?? new Map(),
|
|
68
|
+
providerUsesSdkSession: overrides.providerUsesSdkSession ?? (() => true),
|
|
69
|
+
getMessages: (_chatId) => overrides.messages ?? [],
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// findLastUserMessageId
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
describe("findLastUserMessageId", () => {
|
|
78
|
+
test("returns null when transcript is empty", () => {
|
|
79
|
+
const deps = makeDeps({ messages: [] })
|
|
80
|
+
expect(findLastUserMessageId(deps, "chat-1")).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("returns null when no user_prompt entry exists", () => {
|
|
84
|
+
const deps = makeDeps({
|
|
85
|
+
messages: [
|
|
86
|
+
{ kind: "assistant_text", _id: "a1" },
|
|
87
|
+
{ kind: "result", _id: "r1" },
|
|
88
|
+
],
|
|
89
|
+
})
|
|
90
|
+
expect(findLastUserMessageId(deps, "chat-1")).toBeNull()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test("returns _id of the last user_prompt entry", () => {
|
|
94
|
+
const deps = makeDeps({
|
|
95
|
+
messages: [
|
|
96
|
+
{ kind: "user_prompt", _id: "u1" },
|
|
97
|
+
{ kind: "assistant_text", _id: "a1" },
|
|
98
|
+
{ kind: "user_prompt", _id: "u2" },
|
|
99
|
+
{ kind: "result", _id: "r1" },
|
|
100
|
+
],
|
|
101
|
+
})
|
|
102
|
+
expect(findLastUserMessageId(deps, "chat-1")).toBe("u2")
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("returns the only user_prompt _id when there is exactly one", () => {
|
|
106
|
+
const deps = makeDeps({
|
|
107
|
+
messages: [
|
|
108
|
+
{ kind: "user_prompt", _id: "u1" },
|
|
109
|
+
{ kind: "assistant_text", _id: "a1" },
|
|
110
|
+
],
|
|
111
|
+
})
|
|
112
|
+
expect(findLastUserMessageId(deps, "chat-1")).toBe("u1")
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// recreateActiveTurnFromSession
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
describe("recreateActiveTurnFromSession", () => {
|
|
121
|
+
test("returns undefined when provider does not use SDK session", () => {
|
|
122
|
+
const deps = makeDeps({ providerUsesSdkSession: () => false })
|
|
123
|
+
const result = recreateActiveTurnFromSession(deps, {
|
|
124
|
+
chatId: "chat-1",
|
|
125
|
+
provider: "codex",
|
|
126
|
+
model: "o3",
|
|
127
|
+
planMode: false,
|
|
128
|
+
})
|
|
129
|
+
expect(result).toBeUndefined()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test("returns undefined when no live session exists for the chat", () => {
|
|
133
|
+
const deps = makeDeps({
|
|
134
|
+
claudeSessions: new Map(), // empty — no session for chat-1
|
|
135
|
+
providerUsesSdkSession: () => true,
|
|
136
|
+
})
|
|
137
|
+
const result = recreateActiveTurnFromSession(deps, {
|
|
138
|
+
chatId: "chat-1",
|
|
139
|
+
provider: "claude",
|
|
140
|
+
model: "claude-opus-4-5",
|
|
141
|
+
planMode: false,
|
|
142
|
+
})
|
|
143
|
+
expect(result).toBeUndefined()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test("returns an ActiveTurn and stores it in activeTurns when session exists", () => {
|
|
147
|
+
const session = makeSession({ chatId: "chat-1", model: "claude-opus-4-5", planMode: false })
|
|
148
|
+
const claudeSessions = new Map([["chat-1", session]])
|
|
149
|
+
const activeTurns = new Map<string, ActiveTurn>()
|
|
150
|
+
const deps = makeDeps({
|
|
151
|
+
claudeSessions,
|
|
152
|
+
activeTurns,
|
|
153
|
+
providerUsesSdkSession: () => true,
|
|
154
|
+
messages: [{ kind: "user_prompt", _id: "u1" }],
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const result = recreateActiveTurnFromSession(deps, {
|
|
158
|
+
chatId: "chat-1",
|
|
159
|
+
provider: "claude",
|
|
160
|
+
model: "claude-opus-4-5",
|
|
161
|
+
planMode: false,
|
|
162
|
+
clientTraceId: "trace-abc",
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
expect(result).toBeDefined()
|
|
166
|
+
if (!result) throw new Error("Expected ActiveTurn")
|
|
167
|
+
expect(result.chatId).toBe("chat-1")
|
|
168
|
+
expect(result.provider).toBe("claude")
|
|
169
|
+
expect(result.model).toBe("claude-opus-4-5")
|
|
170
|
+
expect(result.planMode).toBe(false)
|
|
171
|
+
expect(result.status).toBe("waiting_for_user")
|
|
172
|
+
expect(result.pendingTool).toBeNull()
|
|
173
|
+
expect(result.hasFinalResult).toBe(false)
|
|
174
|
+
expect(result.cancelRequested).toBe(false)
|
|
175
|
+
expect(result.clientTraceId).toBe("trace-abc")
|
|
176
|
+
expect(result.userMessageId).toBe("u1")
|
|
177
|
+
expect(activeTurns.get("chat-1")).toBe(result)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test("sets userMessageId to null when no user_prompt in transcript", () => {
|
|
181
|
+
const session = makeSession({ chatId: "chat-2" })
|
|
182
|
+
const claudeSessions = new Map([["chat-2", session]])
|
|
183
|
+
const activeTurns = new Map<string, ActiveTurn>()
|
|
184
|
+
const deps = makeDeps({
|
|
185
|
+
claudeSessions,
|
|
186
|
+
activeTurns,
|
|
187
|
+
providerUsesSdkSession: () => true,
|
|
188
|
+
messages: [],
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const result = recreateActiveTurnFromSession(deps, {
|
|
192
|
+
chatId: "chat-2",
|
|
193
|
+
provider: "claude",
|
|
194
|
+
model: "claude-opus-4-5",
|
|
195
|
+
planMode: false,
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
expect(result?.userMessageId).toBeNull()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test("ghost turn stream is an empty async iterable", async () => {
|
|
202
|
+
const session = makeSession({ chatId: "chat-3" })
|
|
203
|
+
const claudeSessions = new Map([["chat-3", session]])
|
|
204
|
+
const deps = makeDeps({
|
|
205
|
+
claudeSessions,
|
|
206
|
+
activeTurns: new Map(),
|
|
207
|
+
providerUsesSdkSession: () => true,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const result = recreateActiveTurnFromSession(deps, {
|
|
211
|
+
chatId: "chat-3",
|
|
212
|
+
provider: "claude",
|
|
213
|
+
model: "claude-opus-4-5",
|
|
214
|
+
planMode: false,
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
expect(result).toBeDefined()
|
|
218
|
+
const events: unknown[] = []
|
|
219
|
+
for await (const ev of result!.turn.stream) {
|
|
220
|
+
events.push(ev)
|
|
221
|
+
}
|
|
222
|
+
expect(events).toHaveLength(0)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-rebuild helpers extracted from AgentCoordinator (agent.ts).
|
|
3
|
+
*
|
|
4
|
+
* Pure coordination logic — no direct IO. All side-effectful dependencies are
|
|
5
|
+
* injected via `SessionRebuildDeps` so this module stays testable and sealed.
|
|
6
|
+
*/
|
|
7
|
+
import type { AgentProvider } from "../shared/types"
|
|
8
|
+
import type { HarnessEvent, HarnessTurn } from "./harness-types"
|
|
9
|
+
import type { ActiveTurn, ClaudeSessionState } from "./claude-session-state"
|
|
10
|
+
|
|
11
|
+
/** Minimal message shape consumed by findLastUserMessageId. */
|
|
12
|
+
export interface MessageEntry {
|
|
13
|
+
kind: string
|
|
14
|
+
_id: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Injected dependencies — no concrete IO allowed in this module. */
|
|
18
|
+
export interface SessionRebuildDeps {
|
|
19
|
+
claudeSessions: Map<string, ClaudeSessionState>
|
|
20
|
+
activeTurns: Map<string, ActiveTurn>
|
|
21
|
+
providerUsesSdkSession: (provider: AgentProvider) => boolean
|
|
22
|
+
getMessages: (chatId: string) => readonly MessageEntry[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Args forwarded from AgentCoordinator.recreateActiveTurnFromSession. */
|
|
26
|
+
export interface RecreateActiveTurnArgs {
|
|
27
|
+
chatId: string
|
|
28
|
+
provider: AgentProvider
|
|
29
|
+
model: string
|
|
30
|
+
effort?: string
|
|
31
|
+
serviceTier?: "fast"
|
|
32
|
+
planMode: boolean
|
|
33
|
+
clientTraceId?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Re-creates an ActiveTurn from an existing Claude session (SDK providers only).
|
|
38
|
+
* Returns `undefined` when the provider does not use an SDK session or when no
|
|
39
|
+
* live session is found for the chat.
|
|
40
|
+
*
|
|
41
|
+
* Side-effect: mutates `deps.activeTurns` (sets the reconstructed turn).
|
|
42
|
+
*/
|
|
43
|
+
export function recreateActiveTurnFromSession(
|
|
44
|
+
deps: SessionRebuildDeps,
|
|
45
|
+
args: RecreateActiveTurnArgs,
|
|
46
|
+
): ActiveTurn | undefined {
|
|
47
|
+
if (!deps.providerUsesSdkSession(args.provider)) return undefined
|
|
48
|
+
const session = deps.claudeSessions.get(args.chatId)
|
|
49
|
+
if (!session) return undefined
|
|
50
|
+
|
|
51
|
+
const ghostTurn: HarnessTurn = {
|
|
52
|
+
provider: args.provider,
|
|
53
|
+
stream: {
|
|
54
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<HarnessEvent> {
|
|
55
|
+
// intentionally empty — ghost turn emits no events
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
getAccountInfo: session.session.getAccountInfo,
|
|
59
|
+
interrupt: session.session.interrupt,
|
|
60
|
+
close: () => {
|
|
61
|
+
// no-op — session lifetime is managed by AgentCoordinator
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const active: ActiveTurn = {
|
|
66
|
+
chatId: args.chatId,
|
|
67
|
+
provider: args.provider,
|
|
68
|
+
turn: ghostTurn,
|
|
69
|
+
model: session.model,
|
|
70
|
+
effort: session.effort,
|
|
71
|
+
serviceTier: args.serviceTier,
|
|
72
|
+
planMode: session.planMode,
|
|
73
|
+
status: "waiting_for_user",
|
|
74
|
+
pendingTool: null,
|
|
75
|
+
postToolFollowUp: null,
|
|
76
|
+
hasFinalResult: false,
|
|
77
|
+
cancelRequested: false,
|
|
78
|
+
cancelRecorded: false,
|
|
79
|
+
clientTraceId: args.clientTraceId,
|
|
80
|
+
waitStartedAt: null,
|
|
81
|
+
userMessageId: findLastUserMessageId(deps, args.chatId),
|
|
82
|
+
}
|
|
83
|
+
deps.activeTurns.set(args.chatId, active)
|
|
84
|
+
return active
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Scans the chat transcript backwards and returns the `_id` of the most recent
|
|
89
|
+
* `user_prompt` entry, or `null` when the transcript contains no user prompts.
|
|
90
|
+
*/
|
|
91
|
+
export function findLastUserMessageId(
|
|
92
|
+
deps: Pick<SessionRebuildDeps, "getMessages">,
|
|
93
|
+
chatId: string,
|
|
94
|
+
): string | null {
|
|
95
|
+
const messages = deps.getMessages(chatId)
|
|
96
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
97
|
+
const entry = messages[i]
|
|
98
|
+
if (entry.kind === "user_prompt") return entry._id
|
|
99
|
+
}
|
|
100
|
+
return null
|
|
101
|
+
}
|