very-happy-cli 0.2.158 → 0.2.160
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/{AcpBackend-DIDRig5G.cjs → AcpBackend-Bm2rdke8.cjs} +78 -76
- package/dist/{AcpBackend-vDVLHuYJ.mjs → AcpBackend-mzv8Dhba.mjs} +4 -2
- package/dist/{AcpSessionManager-C44U99CC.cjs → AcpSessionManager-BAY1Pggx.cjs} +9 -9
- package/dist/{AcpSessionManager-CmRjfhn3.mjs → AcpSessionManager-CoMWFVw3.mjs} +1 -1
- package/dist/BasePermissionHandler-CFHAxumV.cjs +157 -0
- package/dist/BasePermissionHandler-m3WVholL.mjs +155 -0
- package/dist/BaseReasoningProcessor-BFa9_rZA.cjs +200 -0
- package/dist/BaseReasoningProcessor-CYrKBXGm.mjs +198 -0
- package/dist/RemoteModeDisplay-D-HewyXg.mjs +150 -0
- package/dist/RemoteModeDisplay-NC1gPHtj.cjs +153 -0
- package/dist/agentAttachments-Dbw8iMCZ.cjs +20 -0
- package/dist/agentAttachments-M_B5qEF2.mjs +18 -0
- package/dist/agentGuidance-B5NATuFa.cjs +28 -0
- package/dist/agentGuidance-CQuNlNx8.mjs +20 -0
- package/dist/{agentHome-Dw8Jx_CJ.cjs → agentHome-CboCeKWq.cjs} +14 -28
- package/dist/{agentHome-CyLmGH5L.mjs → agentHome-Dj1C43aJ.mjs} +8 -22
- package/dist/api-CLMUr-SX.mjs +3339 -0
- package/dist/api-CgundNNX.cjs +3346 -0
- package/dist/{types-DIBF4U0Y.mjs → apiMachine-B9dLW_p5.mjs} +9116 -16015
- package/dist/{types-DnrPeN80.cjs → apiMachine-Bn8482VM.cjs} +9104 -16057
- package/dist/assistantTools-BTqPnlFl.cjs +380 -0
- package/dist/assistantTools-D6tuc6o4.mjs +376 -0
- package/dist/attachmentContent-DpiTJZb4.cjs +88 -0
- package/dist/attachmentContent-_kxRznNf.mjs +82 -0
- package/dist/attachmentLimits-CzVrqgLR.cjs +354 -0
- package/dist/attachmentLimits-yba1uHFO.mjs +348 -0
- package/dist/auth-BDOVh5h5.cjs +220 -0
- package/dist/auth-Cwutmdsa.mjs +225 -0
- package/dist/auth-PYHZQNPp.mjs +218 -0
- package/dist/auth-ba4WMYrN.cjs +227 -0
- package/dist/authRelay-BdYkC1Po.cjs +156 -0
- package/dist/authRelay-DSAvg1uv.mjs +143 -0
- package/dist/{auto-DZbNcTXx.cjs → auto-BOX_fgmR.cjs} +70 -79
- package/dist/{auto-BlMZPwOk.mjs → auto-BXu14yP_.mjs} +27 -36
- package/dist/backgroundTaskActivity-8PdPxrvy.mjs +149 -0
- package/dist/backgroundTaskActivity-CBvUdj39.cjs +157 -0
- package/dist/bootstrap-B5G1TijC.cjs +116 -0
- package/dist/bootstrap-hxXPCFMx.mjs +112 -0
- package/dist/browser-Y6Bl8L_O.cjs +22 -0
- package/dist/browser-jh2P0UVZ.mjs +20 -0
- package/dist/claudeCliPath-BqkaH2oR.mjs +8 -0
- package/dist/claudeCliPath-U4HHmV4r.cjs +10 -0
- package/dist/claudeLocal-BaY4fZKi.mjs +339 -0
- package/dist/claudeLocal-CRGG4Pea.cjs +343 -0
- package/dist/claudeLocalLauncher-D4T7MTqd.mjs +152 -0
- package/dist/claudeLocalLauncher-DyEbVzUN.cjs +154 -0
- package/dist/cliUpdate-DsKXPFYp.cjs +120 -0
- package/dist/cliUpdate-Y34VGe9Y.mjs +112 -0
- package/dist/client-3Ac-O4pw.cjs +104 -0
- package/dist/client-BPNbmtvO.cjs +119 -0
- package/dist/client-CeGKqqcJ.mjs +100 -0
- package/dist/client-ClR0K8zX.mjs +112 -0
- package/dist/codex/happyMcpStdioBridge.cjs +33 -14
- package/dist/codex/happyMcpStdioBridge.mjs +26 -7
- package/dist/codexAppServerClient-I3teiV-N.mjs +1088 -0
- package/dist/codexAppServerClient-q2FjnRcK.cjs +1090 -0
- package/dist/codexCommand-4zcccIoq.cjs +2088 -0
- package/dist/codexCommand-Cc9gcbdX.mjs +2086 -0
- package/dist/codexThreadFork-BH9IZPqD.cjs +350 -0
- package/dist/codexThreadFork-slzAseLx.mjs +338 -0
- package/dist/{config-BtPArqRH.mjs → config-CTx_jb-C.mjs} +7 -40
- package/dist/{config-DG84bjwF.cjs → config-Ci9Kpgwa.cjs} +26 -59
- package/dist/configuration-BSYpcPwE.cjs +181 -0
- package/dist/configuration-RfeNjFmd.mjs +170 -0
- package/dist/connect-BpOdzNbE.mjs +682 -0
- package/dist/connect-C2CiOUFs.cjs +684 -0
- package/dist/constants-BC4St0xd.mjs +11 -0
- package/dist/constants-VJ0N6WuG.cjs +17 -0
- package/dist/contentLogMetadata-BlWI0egu.mjs +51 -0
- package/dist/contentLogMetadata-ZSFk_NjQ.cjs +54 -0
- package/dist/context-DsDr6E2t.cjs +69 -0
- package/dist/context-H0Bcb9S_.mjs +65 -0
- package/dist/controlClient-BGbDcgWS.mjs +260 -0
- package/dist/controlClient-Bcwv3D21.cjs +279 -0
- package/dist/createSessionMetadata-BJxEnM-o.cjs +58 -0
- package/dist/createSessionMetadata-CVVkGjxV.mjs +54 -0
- package/dist/defaultPermissionMode-BnxJPVHT.mjs +4 -0
- package/dist/defaultPermissionMode-CxmTXUQR.cjs +7 -0
- package/dist/detectCLI-CSGMlV51.mjs +58 -0
- package/dist/detectCLI-DR9ZaN6B.cjs +61 -0
- package/dist/deterministicJson-C5CYTVpm.cjs +507 -0
- package/dist/deterministicJson-CzkFJWw1.mjs +502 -0
- package/dist/doctor-BPkYWSuq.mjs +75 -0
- package/dist/doctor-D2YJ-Yn2.cjs +395 -0
- package/dist/doctor-DOvByjbk.mjs +391 -0
- package/dist/doctor-u46qYYKX.cjs +79 -0
- package/dist/doctorReadiness-BCZGcN0a.cjs +151 -0
- package/dist/doctorReadiness-C208SMN9.mjs +142 -0
- package/dist/editPaths-BVfS_FFL.mjs +68 -0
- package/dist/editPaths-Bqp4OFPw.cjs +73 -0
- package/dist/ensureDaemonRunning-BCwNrMyv.cjs +64 -0
- package/dist/ensureDaemonRunning-Dqa2pOjY.mjs +61 -0
- package/dist/handleResumeCommand-BvXYf5El.mjs +240 -0
- package/dist/handleResumeCommand-C2cKchpX.cjs +245 -0
- package/dist/ids-B6ZC0HGt.cjs +13 -0
- package/dist/ids-DU6sLx4C.mjs +10 -0
- package/dist/index-B6UGRQ7h.mjs +102 -0
- package/dist/{index-DPCsWlWK.cjs → index-B6pAdvJx.cjs} +131 -91
- package/dist/index-Bu5Zi_a9.cjs +182 -0
- package/dist/index-D3kI0hDR.cjs +107 -0
- package/dist/index-DA7yINuk.cjs +994 -0
- package/dist/index-DDeHXrIX.cjs +71 -0
- package/dist/index-DKLU_fD0.mjs +158 -0
- package/dist/{index-ZChGsuB7.mjs → index-DMZqWwsP.mjs} +79 -39
- package/dist/index-DVkMGPyS.mjs +68 -0
- package/dist/index-qdS1friN.mjs +949 -0
- package/dist/index.cjs +5 -46
- package/dist/index.mjs +5 -46
- package/dist/{install-DarS_Wco.mjs → install-BO_W7VhL.mjs} +37 -37
- package/dist/install-BgoC2e_2.cjs +88 -0
- package/dist/install-CFY3tYtJ.mjs +86 -0
- package/dist/{install-wKQGgXvN.cjs → install-Cs5qAPrB.cjs} +38 -38
- package/dist/{installPiTools-DuZ6jZvX.cjs → installPiTools-BKnXjrVu.cjs} +2 -3
- package/dist/{installPiTools-Cj7yU9Fc.mjs → installPiTools-B_CdM5_2.mjs} +1 -2
- package/dist/{installTerminalHooks-DcQfee_O.cjs → installTerminalHooks-BQZbvxsZ.cjs} +5 -40
- package/dist/{installTerminalHooks-CpNbRFPz.mjs → installTerminalHooks-By7gBMEF.mjs} +3 -38
- package/dist/lib.cjs +28 -19
- package/dist/lib.d.cts +126 -2
- package/dist/lib.d.mts +126 -2
- package/dist/lib.mjs +23 -14
- package/dist/lidState-BeDfxxEX.cjs +305 -0
- package/dist/lidState-CeEt442o.mjs +297 -0
- package/dist/limits-C-a7gr04.cjs +43 -0
- package/dist/limits-DIo7rEBR.mjs +36 -0
- package/dist/localHappyAgentAuth-C-HQyiv6.mjs +82 -0
- package/dist/localHappyAgentAuth-D6rFhaoc.cjs +86 -0
- package/dist/logger-6HYUEXNk.mjs +271 -0
- package/dist/logger-CS0mw3fa.cjs +275 -0
- package/dist/loginShellEnv-BkuYtExZ.cjs +182 -0
- package/dist/loginShellEnv-Dw8M7tkI.mjs +167 -0
- package/dist/machineMetadata-DyDbODYd.cjs +41 -0
- package/dist/machineMetadata-oOzBfDfs.mjs +38 -0
- package/dist/main-BkxJP6JQ.cjs +941 -0
- package/dist/main-BwNIAI1A.mjs +939 -0
- package/dist/manager-BQ4flvQZ.mjs +101 -0
- package/dist/manager-Cly8xVIR.cjs +105 -0
- package/dist/{mcp-BN6bKIGb.mjs → mcp-B1A4I3TK.mjs} +27 -33
- package/dist/{mcp-DGdeUUDJ.cjs → mcp-Hszd7qpS.cjs} +42 -48
- package/dist/messageBuffer-BWlGgR3A.mjs +71 -0
- package/dist/messageBuffer-fJMH6uY8.cjs +73 -0
- package/dist/mirrorProtocol-BNLtoWjO.mjs +170 -0
- package/dist/mirrorProtocol-BUtmg1ws.cjs +186 -0
- package/dist/oneShotPrompts-CL0dip9C.cjs +16 -0
- package/dist/oneShotPrompts-DT4p6RNd.mjs +12 -0
- package/dist/package-BhaKlnxz.mjs +171 -0
- package/dist/package-BwMrPwqH.cjs +173 -0
- package/dist/path-B7zW-WwE.cjs +18 -0
- package/dist/path-D2wdaNSk.mjs +15 -0
- package/dist/peerTools-BTqw97Gb.mjs +272 -0
- package/dist/peerTools-BvKtHXj8.cjs +283 -0
- package/dist/permissionMode-Cr5JCbB7.mjs +80 -0
- package/dist/permissionMode-CrV-XuMs.cjs +87 -0
- package/dist/persistence-HGW_ZVs9.mjs +372 -0
- package/dist/persistence-yKTH3-5X.cjs +414 -0
- package/dist/piExtension.mjs +2 -1
- package/dist/piRunnerArgs-BNloJCCg.cjs +66 -0
- package/dist/piRunnerArgs-BuX1UFWy.mjs +59 -0
- package/dist/{piRuntime-bkw2NAZU.mjs → piRuntime-CSGkwCKQ.mjs} +2 -2
- package/dist/{piRuntime-3qtbKujX.cjs → piRuntime-C_H7MJCM.cjs} +4 -4
- package/dist/{piTerminal-DilKwM7B.mjs → piTerminal-C8a1OrNQ.mjs} +32 -35
- package/dist/{piTerminal-BYxp3LMr.cjs → piTerminal-DNCZ6yTM.cjs} +32 -35
- package/dist/previewPath-B0rBdhy1.cjs +83 -0
- package/dist/previewPath-CXN4Vag0.mjs +81 -0
- package/dist/projectPath-BTBIMB57.mjs +10 -0
- package/dist/projectPath-ChLhdOyt.cjs +13 -0
- package/dist/registerCommonHandlers-Ccvsz7Ou.mjs +536 -0
- package/dist/registerCommonHandlers-DskirN-_.cjs +538 -0
- package/dist/registerKillSessionHandler-DZeLXWcB.cjs +361 -0
- package/dist/registerKillSessionHandler-GYvHdipX.mjs +358 -0
- package/dist/remoteSessionClient-B0LraSB9.cjs +634 -0
- package/dist/remoteSessionClient-CYnXRynE.mjs +612 -0
- package/dist/resources-BdFBQi8r.mjs +117 -0
- package/dist/resources-JDPugcDq.cjs +120 -0
- package/dist/run-CJzxoHVh.cjs +5762 -0
- package/dist/run-D3ifupU3.mjs +5739 -0
- package/dist/runClaude-Ba01qT-u.mjs +848 -0
- package/dist/runClaude-Da3RirzE.cjs +850 -0
- package/dist/runClaudeDeps-DOt79DjU.mjs +3624 -0
- package/dist/runClaudeDeps-DOzEBMls.cjs +3649 -0
- package/dist/{runGemini-Cjii069p.mjs → runGemini-BGeTz57m.mjs} +58 -31
- package/dist/{runGemini-28O2sqyI.cjs → runGemini-DSr9LIxL.cjs} +202 -175
- package/dist/{runOpenClaw-CiMZad-H.cjs → runOpenClaw-B1mTxN35.cjs} +64 -65
- package/dist/{runOpenClaw-jaLr7KF1.mjs → runOpenClaw-CogSedIc.mjs} +31 -32
- package/dist/sandbox-CG7IyDTU.cjs +187 -0
- package/dist/sandbox-DTe6d54R.mjs +180 -0
- package/dist/selfCheck-C9mVrQwf.mjs +84 -0
- package/dist/selfCheck-Dc-mNSLb.cjs +87 -0
- package/dist/{send-D5ejHPXG.cjs → send-B7J_eaG3.cjs} +16 -9
- package/dist/{send-C9mOtChi.mjs → send-CBXJKc6O.mjs} +12 -5
- package/dist/server-CZTP_iOn.cjs +407 -0
- package/dist/server-DcVBv04l.mjs +404 -0
- package/dist/sessionLock-Bys1PNSo.cjs +205 -0
- package/dist/sessionLock-D-EG2CfG.mjs +197 -0
- package/dist/sessionModeFile-DMl3dw3r.mjs +32 -0
- package/dist/sessionModeFile-RSm0NQkH.cjs +36 -0
- package/dist/sessionOps-Dn8z85h_.cjs +783 -0
- package/dist/sessionOps-SuHW8Dfe.mjs +760 -0
- package/dist/sessionScanner-BOrGjMXL.cjs +209 -0
- package/dist/sessionScanner-CX3onqXh.mjs +206 -0
- package/dist/{sessions-W6771g3X.mjs → sessions-BEG_HTSi.mjs} +17 -8
- package/dist/{sessions-BNMPQoLE.cjs → sessions-D74aJbhL.cjs} +37 -28
- package/dist/setupOfflineReconnection-ByElY60m.cjs +74 -0
- package/dist/setupOfflineReconnection-DpvWTt69.mjs +72 -0
- package/dist/smallFastModel-BLNgMrwR.mjs +18 -0
- package/dist/smallFastModel-D_uxGMo3.cjs +21 -0
- package/dist/{spawn-D0QVhzRb.mjs → spawn-B7yro0b2.mjs} +26 -38
- package/dist/{spawn-Ddtom1cb.cjs → spawn-DgR0RKRs.cjs} +52 -64
- package/dist/spawnAgents-BGQiEF-w.cjs +52 -0
- package/dist/spawnAgents-DZzZ9jMI.mjs +45 -0
- package/dist/spawnHappyCLI-B2H5s4B_.mjs +64 -0
- package/dist/spawnHappyCLI-DBhwZT7O.cjs +66 -0
- package/dist/specialCommands-BSqhYg5X.mjs +52 -0
- package/dist/specialCommands-DSsTeRej.cjs +54 -0
- package/dist/startFileWatcher-B4VsLgjD.cjs +71 -0
- package/dist/startFileWatcher-DErVWZE4.mjs +69 -0
- package/dist/startupPlan-BLMbfQIH.cjs +12 -0
- package/dist/startupPlan-DFMyNgAs.mjs +10 -0
- package/dist/streamRelay-BiKw59Cc.cjs +233 -0
- package/dist/streamRelay-FPVbHpbM.mjs +231 -0
- package/dist/sync-C8gyQFMc.mjs +69 -0
- package/dist/sync-DccIKZ5_.cjs +71 -0
- package/dist/systemPrompt-BwTPWfMu.cjs +97 -0
- package/dist/systemPrompt-DX521JJz.mjs +94 -0
- package/dist/{teams-CqSwtw2O.mjs → teams-C32F1hqd.mjs} +9 -40
- package/dist/{teams-BZ1tayg_.cjs → teams-Dl8g8kTI.cjs} +14 -45
- package/dist/template-BaHAmH2K.cjs +129 -0
- package/dist/template-CiOPa2rd.mjs +121 -0
- package/dist/{terminalTitleSuggest-DKgX3osW.mjs → terminalTitleSuggest-CeKsX_V6.mjs} +1 -9
- package/dist/{terminalTitleSuggest-Dji8I0Jy.cjs → terminalTitleSuggest-DhvRFX3H.cjs} +0 -10
- package/dist/terminals-B_bfC9c2.cjs +250 -0
- package/dist/terminals-Dlg0x3nY.mjs +235 -0
- package/dist/time-D0XyHOid.mjs +38 -0
- package/dist/time-DCuTyook.cjs +42 -0
- package/dist/titleGenerator-CQ8CoGhg.mjs +166 -0
- package/dist/titleGenerator-ChzMJsyg.cjs +172 -0
- package/dist/tmuxSocket-B8Yx20sT.mjs +19 -0
- package/dist/tmuxSocket-D-Xt6Vn-.cjs +24 -0
- package/dist/{todo-BYMg0fR0.cjs → todo-BvUOTa8G.cjs} +15 -12
- package/dist/{todo-BbusB1zM.mjs → todo-CZ6cFrrn.mjs} +11 -8
- package/dist/tools-BZtHF_qQ.mjs +177 -0
- package/dist/tools-Be3AoAwu.cjs +63 -0
- package/dist/tools-DQj0AeLk.cjs +180 -0
- package/dist/tools-DkluDcG4.mjs +60 -0
- package/dist/trimIdent-4gvk9Wk6.cjs +22 -0
- package/dist/trimIdent-BAjmv_TG.mjs +20 -0
- package/dist/types-CRS-srZK.cjs +66 -0
- package/dist/types-CkqAACXx.mjs +64 -0
- package/dist/uninstall-BkLzaJ6I.mjs +47 -0
- package/dist/uninstall-x-TUtf5H.cjs +49 -0
- package/package.json +7 -7
- package/dist/agentGuidance-CrR9l62U.mjs +0 -2145
- package/dist/agentGuidance-DwOWbX55.cjs +0 -2217
- package/dist/index-BG932eCh.cjs +0 -19655
- package/dist/index-CVCt3kSt.mjs +0 -19591
- package/dist/index-D5ScEErM.cjs +0 -58
- package/dist/index-DgO0FHOx.mjs +0 -52
- package/dist/limits-D2uN090G.mjs +0 -2528
- package/dist/limits-kqFqhfsh.cjs +0 -2652
|
@@ -0,0 +1,3346 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var axios = require('axios');
|
|
4
|
+
var logger = require('./logger-CS0mw3fa.cjs');
|
|
5
|
+
var node_events = require('node:events');
|
|
6
|
+
var socket_ioClient = require('socket.io-client');
|
|
7
|
+
var attachmentLimits = require('./attachmentLimits-CzVrqgLR.cjs');
|
|
8
|
+
var authRelay = require('./authRelay-BdYkC1Po.cjs');
|
|
9
|
+
var limits = require('./limits-C-a7gr04.cjs');
|
|
10
|
+
var time = require('./time-DCuTyook.cjs');
|
|
11
|
+
var lidState = require('./lidState-BeDfxxEX.cjs');
|
|
12
|
+
var configuration = require('./configuration-BSYpcPwE.cjs');
|
|
13
|
+
var node_crypto = require('node:crypto');
|
|
14
|
+
var index = require('./index-DA7yINuk.cjs');
|
|
15
|
+
var registerCommonHandlers = require('./registerCommonHandlers-DskirN-_.cjs');
|
|
16
|
+
var cuid2 = require('@paralleldrive/cuid2');
|
|
17
|
+
var sync = require('./sync-DccIKZ5_.cjs');
|
|
18
|
+
var backgroundTaskActivity = require('./backgroundTaskActivity-CBvUdj39.cjs');
|
|
19
|
+
var controlClient = require('./controlClient-Bcwv3D21.cjs');
|
|
20
|
+
var contentLogMetadata = require('./contentLogMetadata-ZSFk_NjQ.cjs');
|
|
21
|
+
var chalk = require('chalk');
|
|
22
|
+
|
|
23
|
+
async function hmac_sha512(key, data) {
|
|
24
|
+
const hmac = node_crypto.createHmac("sha512", key);
|
|
25
|
+
hmac.update(data);
|
|
26
|
+
return new Uint8Array(hmac.digest());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function deriveSecretKeyTreeRoot(seed, usage) {
|
|
30
|
+
const I = await hmac_sha512(new TextEncoder().encode(usage + " Master Seed"), seed);
|
|
31
|
+
return {
|
|
32
|
+
key: I.slice(0, 32),
|
|
33
|
+
chainCode: I.slice(32)
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
async function deriveSecretKeyTreeChild(chainCode, index) {
|
|
37
|
+
const data = new Uint8Array([0, ...new TextEncoder().encode(index)]);
|
|
38
|
+
const I = await hmac_sha512(chainCode, data);
|
|
39
|
+
return {
|
|
40
|
+
key: I.subarray(0, 32),
|
|
41
|
+
chainCode: I.subarray(32)
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
async function deriveKey(master, usage, path) {
|
|
45
|
+
let state = await deriveSecretKeyTreeRoot(master, usage);
|
|
46
|
+
let remaining = [...path];
|
|
47
|
+
while (remaining.length > 0) {
|
|
48
|
+
let index = remaining[0];
|
|
49
|
+
remaining = remaining.slice(1);
|
|
50
|
+
state = await deriveSecretKeyTreeChild(state.chainCode, index);
|
|
51
|
+
}
|
|
52
|
+
return state.key;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const PRICING = {
|
|
56
|
+
// --- Claude Fable / Mythos (same tier, same price) ---
|
|
57
|
+
"claude-fable-5-1": { input: 10, output: 50, cache_write: 12.5, cache_read: 0.25 },
|
|
58
|
+
"claude-fable-5": { input: 10, output: 50, cache_write: 12.5, cache_read: 1 },
|
|
59
|
+
"claude-mythos-5-1": { input: 10, output: 50, cache_write: 12.5, cache_read: 0.25 },
|
|
60
|
+
"claude-mythos-5": { input: 10, output: 50, cache_write: 12.5, cache_read: 1 },
|
|
61
|
+
// --- Claude 5 ---
|
|
62
|
+
"claude-opus-5-5": { input: 4, output: 20, cache_write: 5, cache_read: 0.2 },
|
|
63
|
+
"claude-opus-5": { input: 5, output: 25, cache_write: 6.25, cache_read: 0.5 },
|
|
64
|
+
"claude-sonnet-5": { input: 2, output: 10, cache_write: 2.5, cache_read: 0.2 },
|
|
65
|
+
// --- Claude 4.x ---
|
|
66
|
+
"claude-opus-4-8": { input: 5, output: 25, cache_write: 6.25, cache_read: 0.5 },
|
|
67
|
+
"claude-opus-4-7": { input: 5, output: 25, cache_write: 6.25, cache_read: 0.5 },
|
|
68
|
+
"claude-opus-4-6": { input: 5, output: 25, cache_write: 6.25, cache_read: 0.5 },
|
|
69
|
+
"claude-opus-4-5": { input: 5, output: 25, cache_write: 6.25, cache_read: 0.5 },
|
|
70
|
+
"claude-opus-4-1": { input: 15, output: 75, cache_write: 18.75, cache_read: 1.5 },
|
|
71
|
+
"claude-opus-4": { input: 15, output: 75, cache_write: 18.75, cache_read: 1.5 },
|
|
72
|
+
"claude-sonnet-4-6": { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 },
|
|
73
|
+
"claude-sonnet-4-5": { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 },
|
|
74
|
+
"claude-sonnet-4": { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 },
|
|
75
|
+
"claude-haiku-4-5": { input: 1, output: 5, cache_write: 1.25, cache_read: 0.1 },
|
|
76
|
+
// --- Legacy / Claude 3 ---
|
|
77
|
+
"claude-3-opus-20240229": { input: 15, output: 75, cache_write: 18.75, cache_read: 1.5 },
|
|
78
|
+
"claude-3-sonnet-20240229": { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 },
|
|
79
|
+
"claude-3-5-sonnet-20240620": { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 },
|
|
80
|
+
"claude-3-5-sonnet-20241022": { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 },
|
|
81
|
+
"claude-3-7-sonnet-20250219": { input: 3, output: 15, cache_write: 3.75, cache_read: 0.3 },
|
|
82
|
+
"claude-3-haiku-20240307": { input: 0.25, output: 1.25, cache_write: 0.3125, cache_read: 0.025 },
|
|
83
|
+
"claude-3-5-haiku-20241022": { input: 0.8, output: 4, cache_write: 1, cache_read: 0.08 }
|
|
84
|
+
};
|
|
85
|
+
const DEFAULT_MODEL = "claude-opus-5-5";
|
|
86
|
+
const FAMILY_DEFAULTS = {
|
|
87
|
+
fable: "claude-fable-5-1",
|
|
88
|
+
// Project Glasswing counterpart of Fable: same tier, same published rates.
|
|
89
|
+
mythos: "claude-mythos-5-1",
|
|
90
|
+
opus: "claude-opus-5-5",
|
|
91
|
+
sonnet: "claude-sonnet-5",
|
|
92
|
+
haiku: "claude-haiku-4-5"
|
|
93
|
+
};
|
|
94
|
+
function resolvePricingKey(modelId) {
|
|
95
|
+
if (!modelId) return DEFAULT_MODEL;
|
|
96
|
+
const raw = modelId.trim().toLowerCase();
|
|
97
|
+
if (raw in PRICING) return raw;
|
|
98
|
+
const id = raw.replace(/\[1m\]$/, "");
|
|
99
|
+
if (id in PRICING) return id;
|
|
100
|
+
const family = ["fable", "mythos", "opus", "sonnet", "haiku"].find((f) => id.includes(f));
|
|
101
|
+
if (!family) return DEFAULT_MODEL;
|
|
102
|
+
const version = id.match(/(?:^|[^0-9])(\d)(?:[-._](\d))?(?:[^0-9]|$)/);
|
|
103
|
+
const major = version ? version[1] : null;
|
|
104
|
+
const minor = version && version[2] ? version[2] : null;
|
|
105
|
+
const key = major ? `claude-${family}-${major}${minor ? `-${minor}` : ""}` : null;
|
|
106
|
+
if (key && key in PRICING) return key;
|
|
107
|
+
if (major === "3" && family !== "fable" && family !== "mythos") {
|
|
108
|
+
if (family === "opus") return "claude-3-opus-20240229";
|
|
109
|
+
if (family === "haiku") return minor === "5" ? "claude-3-5-haiku-20241022" : "claude-3-haiku-20240307";
|
|
110
|
+
if (minor === "7") return "claude-3-7-sonnet-20250219";
|
|
111
|
+
return minor === "5" ? "claude-3-5-sonnet-20241022" : "claude-3-sonnet-20240229";
|
|
112
|
+
}
|
|
113
|
+
return FAMILY_DEFAULTS[family];
|
|
114
|
+
}
|
|
115
|
+
function calculateCost(usage, modelId) {
|
|
116
|
+
const pricing = PRICING[resolvePricingKey(modelId)];
|
|
117
|
+
const inputCost = usage.input_tokens / 1e6 * pricing.input;
|
|
118
|
+
const outputCost = usage.output_tokens / 1e6 * pricing.output;
|
|
119
|
+
const cacheWriteCost = (usage.cache_creation_input_tokens || 0) / 1e6 * pricing.cache_write;
|
|
120
|
+
const cacheReadCost = (usage.cache_read_input_tokens || 0) / 1e6 * pricing.cache_read;
|
|
121
|
+
const totalInputCost = inputCost + cacheWriteCost + cacheReadCost;
|
|
122
|
+
return {
|
|
123
|
+
total: totalInputCost + outputCost,
|
|
124
|
+
input: totalInputCost,
|
|
125
|
+
output: outputCost
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const SUBAGENT_RESULT_MAX_CHARS = 16384;
|
|
130
|
+
const SUBAGENT_PROGRESS_MIN_INTERVAL_MS = 5e3;
|
|
131
|
+
function capSubagentText(text, max = SUBAGENT_RESULT_MAX_CHARS) {
|
|
132
|
+
const trimmed = text.trim();
|
|
133
|
+
if (trimmed.length <= max) return { text: trimmed };
|
|
134
|
+
return { text: trimmed.slice(0, max), truncated: true };
|
|
135
|
+
}
|
|
136
|
+
function toolUseResultToText(toolUseResult) {
|
|
137
|
+
if (!toolUseResult || typeof toolUseResult !== "object") return null;
|
|
138
|
+
const content = toolUseResult.content;
|
|
139
|
+
if (typeof content === "string") return content;
|
|
140
|
+
if (!Array.isArray(content)) return null;
|
|
141
|
+
const parts = [];
|
|
142
|
+
for (const block of content) {
|
|
143
|
+
if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
|
|
144
|
+
parts.push(block.text);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return parts.length > 0 ? parts.join("\n") : null;
|
|
148
|
+
}
|
|
149
|
+
function toolUseResultStatus(toolUseResult) {
|
|
150
|
+
if (!toolUseResult || typeof toolUseResult !== "object") return "unknown";
|
|
151
|
+
const status = toolUseResult.status;
|
|
152
|
+
if (status === "async_launched") return "async_launched";
|
|
153
|
+
if (status === "completed") return "completed";
|
|
154
|
+
return "unknown";
|
|
155
|
+
}
|
|
156
|
+
function toolUseResultStats(toolUseResult) {
|
|
157
|
+
if (!toolUseResult || typeof toolUseResult !== "object") return void 0;
|
|
158
|
+
const r = toolUseResult;
|
|
159
|
+
const stats = {};
|
|
160
|
+
if (typeof r.totalToolUseCount === "number") stats.toolUses = r.totalToolUseCount;
|
|
161
|
+
if (typeof r.totalTokens === "number") stats.totalTokens = r.totalTokens;
|
|
162
|
+
if (typeof r.totalDurationMs === "number") stats.durationMs = r.totalDurationMs;
|
|
163
|
+
if (r.toolStats && typeof r.toolStats === "object") stats.toolStats = r.toolStats;
|
|
164
|
+
return Object.keys(stats).length > 0 ? stats : void 0;
|
|
165
|
+
}
|
|
166
|
+
function parseTaskNotificationText(text) {
|
|
167
|
+
if (!/<task-notification>/i.test(text)) return null;
|
|
168
|
+
const pick = (tag) => text.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "i"))?.[1] ?? null;
|
|
169
|
+
const rawStatus = pick("status")?.trim().toLowerCase() ?? null;
|
|
170
|
+
const status = rawStatus === "completed" || rawStatus === "failed" || rawStatus === "stopped" ? rawStatus : null;
|
|
171
|
+
let resultText = pick("result");
|
|
172
|
+
let result = null;
|
|
173
|
+
if (resultText !== null) {
|
|
174
|
+
resultText = resultText.replace(/^\s*\[harness:[^\]]*\]\s*/i, "").replace(/<usage>[\s\S]*?<\/usage>/gi, "").trim();
|
|
175
|
+
if (resultText.length > 0) result = capSubagentText(resultText);
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
toolUseId: pick("tool-use-id")?.trim() || null,
|
|
179
|
+
taskId: pick("task-id")?.trim() || null,
|
|
180
|
+
status,
|
|
181
|
+
summary: pick("summary")?.trim() || null,
|
|
182
|
+
result
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function shouldEmitProgress(previous, toolUses, now) {
|
|
186
|
+
if (!previous) return true;
|
|
187
|
+
if (previous.lastToolUses === toolUses) return false;
|
|
188
|
+
return now - previous.lastAt >= SUBAGENT_PROGRESS_MIN_INTERVAL_MS;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const SUBAGENT_BUFFER_MAX_MESSAGES = 100;
|
|
192
|
+
function isSubagentTool(name) {
|
|
193
|
+
return name === "Task" || name === "Agent";
|
|
194
|
+
}
|
|
195
|
+
function shouldHideParentToolCall(name) {
|
|
196
|
+
return name === "Task";
|
|
197
|
+
}
|
|
198
|
+
function pickProviderSubagent(message) {
|
|
199
|
+
const raw = message;
|
|
200
|
+
if (typeof raw.parent_tool_use_id === "string" && raw.parent_tool_use_id.length > 0) {
|
|
201
|
+
return raw.parent_tool_use_id;
|
|
202
|
+
}
|
|
203
|
+
if (typeof raw.parentToolUseId === "string" && raw.parentToolUseId.length > 0) {
|
|
204
|
+
return raw.parentToolUseId;
|
|
205
|
+
}
|
|
206
|
+
return void 0;
|
|
207
|
+
}
|
|
208
|
+
function getUuidToProviderSubagent(state) {
|
|
209
|
+
if (!state.uuidToProviderSubagent) {
|
|
210
|
+
state.uuidToProviderSubagent = /* @__PURE__ */ new Map();
|
|
211
|
+
}
|
|
212
|
+
return state.uuidToProviderSubagent;
|
|
213
|
+
}
|
|
214
|
+
function getTaskPromptToSubagents(state) {
|
|
215
|
+
if (!state.taskPromptToSubagents) {
|
|
216
|
+
state.taskPromptToSubagents = /* @__PURE__ */ new Map();
|
|
217
|
+
}
|
|
218
|
+
return state.taskPromptToSubagents;
|
|
219
|
+
}
|
|
220
|
+
function getProviderSubagentToSessionSubagent(state) {
|
|
221
|
+
if (!state.providerSubagentToSessionSubagent) {
|
|
222
|
+
state.providerSubagentToSessionSubagent = /* @__PURE__ */ new Map();
|
|
223
|
+
}
|
|
224
|
+
return state.providerSubagentToSessionSubagent;
|
|
225
|
+
}
|
|
226
|
+
function getSessionSubagentIdForProviderSubagent(state, providerSubagent) {
|
|
227
|
+
return getProviderSubagentToSessionSubagent(state).get(providerSubagent);
|
|
228
|
+
}
|
|
229
|
+
function ensureSessionSubagentIdForProviderSubagent(state, providerSubagent) {
|
|
230
|
+
const existing = getSessionSubagentIdForProviderSubagent(state, providerSubagent);
|
|
231
|
+
if (existing) {
|
|
232
|
+
return existing;
|
|
233
|
+
}
|
|
234
|
+
const created = cuid2.createId();
|
|
235
|
+
getProviderSubagentToSessionSubagent(state).set(providerSubagent, created);
|
|
236
|
+
return created;
|
|
237
|
+
}
|
|
238
|
+
function getSubagentTitles(state) {
|
|
239
|
+
if (!state.subagentTitles) {
|
|
240
|
+
state.subagentTitles = /* @__PURE__ */ new Map();
|
|
241
|
+
}
|
|
242
|
+
return state.subagentTitles;
|
|
243
|
+
}
|
|
244
|
+
function getBufferedSubagentMessages(state) {
|
|
245
|
+
if (!state.bufferedSubagentMessages) {
|
|
246
|
+
state.bufferedSubagentMessages = /* @__PURE__ */ new Map();
|
|
247
|
+
}
|
|
248
|
+
return state.bufferedSubagentMessages;
|
|
249
|
+
}
|
|
250
|
+
function getHiddenParentToolCalls(state) {
|
|
251
|
+
if (!state.hiddenParentToolCalls) {
|
|
252
|
+
state.hiddenParentToolCalls = /* @__PURE__ */ new Set();
|
|
253
|
+
}
|
|
254
|
+
return state.hiddenParentToolCalls;
|
|
255
|
+
}
|
|
256
|
+
function bufferSubagentMessage(state, subagent, message) {
|
|
257
|
+
const buffer = getBufferedSubagentMessages(state);
|
|
258
|
+
const queue = buffer.get(subagent) ?? [];
|
|
259
|
+
queue.push(message);
|
|
260
|
+
while (queue.length > SUBAGENT_BUFFER_MAX_MESSAGES) queue.shift();
|
|
261
|
+
buffer.set(subagent, queue);
|
|
262
|
+
}
|
|
263
|
+
function consumeBufferedSubagentMessages(state, subagent) {
|
|
264
|
+
const buffer = getBufferedSubagentMessages(state);
|
|
265
|
+
const queue = buffer.get(subagent) ?? [];
|
|
266
|
+
buffer.delete(subagent);
|
|
267
|
+
return queue;
|
|
268
|
+
}
|
|
269
|
+
function getStartedSubagents(state) {
|
|
270
|
+
if (!state.startedSubagents) {
|
|
271
|
+
state.startedSubagents = /* @__PURE__ */ new Set();
|
|
272
|
+
}
|
|
273
|
+
return state.startedSubagents;
|
|
274
|
+
}
|
|
275
|
+
function getActiveSubagents(state) {
|
|
276
|
+
if (!state.activeSubagents) {
|
|
277
|
+
state.activeSubagents = /* @__PURE__ */ new Set();
|
|
278
|
+
}
|
|
279
|
+
return state.activeSubagents;
|
|
280
|
+
}
|
|
281
|
+
function getAgentToolCalls(state) {
|
|
282
|
+
if (!state.agentToolCalls) state.agentToolCalls = /* @__PURE__ */ new Set();
|
|
283
|
+
return state.agentToolCalls;
|
|
284
|
+
}
|
|
285
|
+
function getSubagentMeta(state) {
|
|
286
|
+
if (!state.subagentMeta) state.subagentMeta = /* @__PURE__ */ new Map();
|
|
287
|
+
return state.subagentMeta;
|
|
288
|
+
}
|
|
289
|
+
function getStubSubagents(state) {
|
|
290
|
+
if (!state.stubSubagents) state.stubSubagents = /* @__PURE__ */ new Set();
|
|
291
|
+
return state.stubSubagents;
|
|
292
|
+
}
|
|
293
|
+
function getProgressThrottle(state) {
|
|
294
|
+
if (!state.progressThrottle) state.progressThrottle = /* @__PURE__ */ new Map();
|
|
295
|
+
return state.progressThrottle;
|
|
296
|
+
}
|
|
297
|
+
function nowOf(state) {
|
|
298
|
+
return state.now ? state.now() : Date.now();
|
|
299
|
+
}
|
|
300
|
+
function rememberSubagentMeta(state, subagent, meta) {
|
|
301
|
+
const existing = getSubagentMeta(state).get(subagent) ?? {};
|
|
302
|
+
const description = typeof meta.description === "string" && meta.description.trim() ? meta.description.trim() : existing.description;
|
|
303
|
+
const subagentType = typeof meta.subagentType === "string" && meta.subagentType.trim() ? meta.subagentType.trim() : existing.subagentType;
|
|
304
|
+
getSubagentMeta(state).set(subagent, { ...description ? { description } : {}, ...subagentType ? { subagentType } : {} });
|
|
305
|
+
}
|
|
306
|
+
function pickAssistantUsage(message) {
|
|
307
|
+
if (message.type !== "assistant") return void 0;
|
|
308
|
+
const usage = message.message?.usage;
|
|
309
|
+
if (!usage || typeof usage.input_tokens !== "number" || typeof usage.output_tokens !== "number") return void 0;
|
|
310
|
+
return {
|
|
311
|
+
input_tokens: usage.input_tokens,
|
|
312
|
+
output_tokens: usage.output_tokens,
|
|
313
|
+
...typeof usage.cache_creation_input_tokens === "number" ? { cache_creation_input_tokens: usage.cache_creation_input_tokens } : {},
|
|
314
|
+
...typeof usage.cache_read_input_tokens === "number" ? { cache_read_input_tokens: usage.cache_read_input_tokens } : {}
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function pickUuid(message) {
|
|
318
|
+
const raw = message;
|
|
319
|
+
if (typeof raw.uuid === "string" && raw.uuid.length > 0) {
|
|
320
|
+
return raw.uuid;
|
|
321
|
+
}
|
|
322
|
+
return void 0;
|
|
323
|
+
}
|
|
324
|
+
function pickParentUuid(message) {
|
|
325
|
+
const raw = message;
|
|
326
|
+
if (typeof raw.parentUuid === "string" && raw.parentUuid.length > 0) {
|
|
327
|
+
return raw.parentUuid;
|
|
328
|
+
}
|
|
329
|
+
if (typeof raw.parentUUID === "string" && raw.parentUUID.length > 0) {
|
|
330
|
+
return raw.parentUUID;
|
|
331
|
+
}
|
|
332
|
+
return void 0;
|
|
333
|
+
}
|
|
334
|
+
function isSidechainMessage(message) {
|
|
335
|
+
const raw = message;
|
|
336
|
+
return raw.isSidechain === true;
|
|
337
|
+
}
|
|
338
|
+
function normalizePrompt(prompt) {
|
|
339
|
+
return prompt.trim();
|
|
340
|
+
}
|
|
341
|
+
function queueTaskPromptSubagent(state, prompt, subagent) {
|
|
342
|
+
const normalized = normalizePrompt(prompt);
|
|
343
|
+
if (normalized.length === 0) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const promptMap = getTaskPromptToSubagents(state);
|
|
347
|
+
const queue = promptMap.get(normalized) ?? [];
|
|
348
|
+
if (!queue.includes(subagent)) {
|
|
349
|
+
queue.push(subagent);
|
|
350
|
+
}
|
|
351
|
+
promptMap.set(normalized, queue);
|
|
352
|
+
}
|
|
353
|
+
function consumeTaskPromptSubagent(state, prompt) {
|
|
354
|
+
const normalized = normalizePrompt(prompt);
|
|
355
|
+
if (normalized.length === 0) {
|
|
356
|
+
return void 0;
|
|
357
|
+
}
|
|
358
|
+
const promptMap = getTaskPromptToSubagents(state);
|
|
359
|
+
const queue = promptMap.get(normalized);
|
|
360
|
+
if (!queue || queue.length === 0) {
|
|
361
|
+
return void 0;
|
|
362
|
+
}
|
|
363
|
+
const subagent = queue.shift();
|
|
364
|
+
if (queue.length === 0) {
|
|
365
|
+
promptMap.delete(normalized);
|
|
366
|
+
}
|
|
367
|
+
return subagent;
|
|
368
|
+
}
|
|
369
|
+
function consumeSinglePendingTaskSubagent(state) {
|
|
370
|
+
const promptMap = getTaskPromptToSubagents(state);
|
|
371
|
+
let candidateKey = null;
|
|
372
|
+
let candidateSubagent = null;
|
|
373
|
+
for (const [prompt, queue2] of promptMap.entries()) {
|
|
374
|
+
if (queue2.length === 0) {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (candidateKey !== null) {
|
|
378
|
+
return void 0;
|
|
379
|
+
}
|
|
380
|
+
candidateKey = prompt;
|
|
381
|
+
candidateSubagent = queue2[0] ?? null;
|
|
382
|
+
}
|
|
383
|
+
if (!candidateKey || !candidateSubagent) {
|
|
384
|
+
return void 0;
|
|
385
|
+
}
|
|
386
|
+
const queue = promptMap.get(candidateKey);
|
|
387
|
+
if (!queue || queue.length === 0) {
|
|
388
|
+
return void 0;
|
|
389
|
+
}
|
|
390
|
+
queue.shift();
|
|
391
|
+
if (queue.length === 0) {
|
|
392
|
+
promptMap.delete(candidateKey);
|
|
393
|
+
}
|
|
394
|
+
return candidateSubagent;
|
|
395
|
+
}
|
|
396
|
+
function pickSidechainRootPrompt(message) {
|
|
397
|
+
if (message.type !== "user") {
|
|
398
|
+
return void 0;
|
|
399
|
+
}
|
|
400
|
+
if (typeof message.message?.content === "string") {
|
|
401
|
+
const normalized = normalizePrompt(message.message.content);
|
|
402
|
+
return normalized.length > 0 ? normalized : void 0;
|
|
403
|
+
}
|
|
404
|
+
return void 0;
|
|
405
|
+
}
|
|
406
|
+
function resolveProviderSubagent(message, state) {
|
|
407
|
+
const explicitSubagent = pickProviderSubagent(message);
|
|
408
|
+
if (explicitSubagent) {
|
|
409
|
+
return explicitSubagent;
|
|
410
|
+
}
|
|
411
|
+
const parentUuid = pickParentUuid(message);
|
|
412
|
+
if (parentUuid) {
|
|
413
|
+
const inheritedSubagent = getUuidToProviderSubagent(state).get(parentUuid);
|
|
414
|
+
if (inheritedSubagent) {
|
|
415
|
+
return inheritedSubagent;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (!isSidechainMessage(message)) {
|
|
419
|
+
return void 0;
|
|
420
|
+
}
|
|
421
|
+
const prompt = pickSidechainRootPrompt(message);
|
|
422
|
+
if (prompt) {
|
|
423
|
+
const matchedSubagent = consumeTaskPromptSubagent(state, prompt);
|
|
424
|
+
if (matchedSubagent) {
|
|
425
|
+
return matchedSubagent;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (!parentUuid) {
|
|
429
|
+
return consumeSinglePendingTaskSubagent(state);
|
|
430
|
+
}
|
|
431
|
+
return void 0;
|
|
432
|
+
}
|
|
433
|
+
function rememberSubagentForMessage(message, state, providerSubagent) {
|
|
434
|
+
if (!providerSubagent) {
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const uuid = pickUuid(message);
|
|
438
|
+
if (!uuid) {
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
getUuidToProviderSubagent(state).set(uuid, providerSubagent);
|
|
442
|
+
}
|
|
443
|
+
function pickTaskPrompt(input) {
|
|
444
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
445
|
+
return void 0;
|
|
446
|
+
}
|
|
447
|
+
const prompt = input.prompt;
|
|
448
|
+
if (typeof prompt !== "string") {
|
|
449
|
+
return void 0;
|
|
450
|
+
}
|
|
451
|
+
const normalized = normalizePrompt(prompt);
|
|
452
|
+
return normalized.length > 0 ? normalized : void 0;
|
|
453
|
+
}
|
|
454
|
+
function pickTaskTitle(input) {
|
|
455
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
456
|
+
return void 0;
|
|
457
|
+
}
|
|
458
|
+
const candidateKeys = ["description", "title", "subagent_type"];
|
|
459
|
+
for (const key of candidateKeys) {
|
|
460
|
+
const value = input[key];
|
|
461
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
462
|
+
return value.trim();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return void 0;
|
|
466
|
+
}
|
|
467
|
+
function setSubagentTitle(state, subagent, title) {
|
|
468
|
+
if (!title || title.trim().length === 0) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
getSubagentTitles(state).set(subagent, title.trim());
|
|
472
|
+
}
|
|
473
|
+
function maybeEmitSubagentStart(state, turn, subagent, envelopes) {
|
|
474
|
+
if (!subagent) {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const started = getStartedSubagents(state);
|
|
478
|
+
if (started.has(subagent)) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const title = getSubagentTitles(state).get(subagent);
|
|
482
|
+
const meta = getSubagentMeta(state).get(subagent);
|
|
483
|
+
envelopes.push(index.createEnvelope("agent", {
|
|
484
|
+
t: "start",
|
|
485
|
+
...title ? { title } : {},
|
|
486
|
+
...meta?.description ? { description: meta.description } : {},
|
|
487
|
+
...meta?.subagentType ? { subagentType: meta.subagentType } : {}
|
|
488
|
+
}, { turn, subagent }));
|
|
489
|
+
started.add(subagent);
|
|
490
|
+
getActiveSubagents(state).add(subagent);
|
|
491
|
+
}
|
|
492
|
+
function emitSubagentStopWithStatus(state, turn, subagent, envelopes, payload) {
|
|
493
|
+
envelopes.push(index.createEnvelope("agent", {
|
|
494
|
+
t: "stop",
|
|
495
|
+
...payload.status ? { status: payload.status } : {},
|
|
496
|
+
...payload.result ? { result: payload.result } : {},
|
|
497
|
+
...payload.usage ? { usage: payload.usage } : {}
|
|
498
|
+
}, { ...turn ? { turn } : {}, subagent }));
|
|
499
|
+
getActiveSubagents(state).delete(subagent);
|
|
500
|
+
getStartedSubagents(state).delete(subagent);
|
|
501
|
+
}
|
|
502
|
+
function maybeEmitSubagentStop(state, turn, subagent, envelopes) {
|
|
503
|
+
const active = getActiveSubagents(state);
|
|
504
|
+
if (!active.has(subagent)) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
envelopes.push(index.createEnvelope("agent", { t: "stop" }, { turn, subagent }));
|
|
508
|
+
active.delete(subagent);
|
|
509
|
+
}
|
|
510
|
+
function clearSubagentTracking(state) {
|
|
511
|
+
getUuidToProviderSubagent(state).clear();
|
|
512
|
+
getTaskPromptToSubagents(state).clear();
|
|
513
|
+
getBufferedSubagentMessages(state).clear();
|
|
514
|
+
getHiddenParentToolCalls(state).clear();
|
|
515
|
+
getStartedSubagents(state).clear();
|
|
516
|
+
getActiveSubagents(state).clear();
|
|
517
|
+
}
|
|
518
|
+
function ensureTurn(state, envelopes) {
|
|
519
|
+
if (state.currentTurnId) {
|
|
520
|
+
return state.currentTurnId;
|
|
521
|
+
}
|
|
522
|
+
const turnId = cuid2.createId();
|
|
523
|
+
envelopes.push(index.createEnvelope("agent", { t: "turn-start" }, { turn: turnId }));
|
|
524
|
+
state.currentTurnId = turnId;
|
|
525
|
+
return turnId;
|
|
526
|
+
}
|
|
527
|
+
function closeTurn(state, status, envelopes, meta) {
|
|
528
|
+
if (!state.currentTurnId) {
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
envelopes.push(index.createEnvelope("agent", {
|
|
532
|
+
t: "turn-end",
|
|
533
|
+
status,
|
|
534
|
+
...meta?.error ? { error: meta.error } : {},
|
|
535
|
+
...typeof meta?.costUsd === "number" ? { costUsd: meta.costUsd } : {},
|
|
536
|
+
...typeof meta?.durationMs === "number" ? { durationMs: meta.durationMs } : {},
|
|
537
|
+
...typeof meta?.numTurns === "number" ? { numTurns: meta.numTurns } : {},
|
|
538
|
+
...meta?.usage ? { usage: meta.usage } : {}
|
|
539
|
+
}, { turn: state.currentTurnId }));
|
|
540
|
+
state.currentTurnId = null;
|
|
541
|
+
state.pendingAssistantError = void 0;
|
|
542
|
+
clearSubagentTracking(state);
|
|
543
|
+
}
|
|
544
|
+
function toolTitle(name, input) {
|
|
545
|
+
if (input && typeof input === "object") {
|
|
546
|
+
const description = input.description;
|
|
547
|
+
if (typeof description === "string" && description.trim().length > 0) {
|
|
548
|
+
return description.length > 80 ? `${description.slice(0, 77)}...` : description;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return `${name} call`;
|
|
552
|
+
}
|
|
553
|
+
function toToolArgs(input) {
|
|
554
|
+
if (input && typeof input === "object" && !Array.isArray(input)) {
|
|
555
|
+
return input;
|
|
556
|
+
}
|
|
557
|
+
if (input === void 0) {
|
|
558
|
+
return {};
|
|
559
|
+
}
|
|
560
|
+
return { input };
|
|
561
|
+
}
|
|
562
|
+
function closeClaudeTurnWithStatus(state, status, meta) {
|
|
563
|
+
const envelopes = [];
|
|
564
|
+
closeTurn(state, status, envelopes, meta);
|
|
565
|
+
return {
|
|
566
|
+
currentTurnId: state.currentTurnId,
|
|
567
|
+
envelopes
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
function mapClaudeLogMessageToSessionEnvelopes(message, state) {
|
|
571
|
+
return mapClaudeLogMessageToSessionEnvelopesInternal(message, state);
|
|
572
|
+
}
|
|
573
|
+
function mapTaskLifecycleSystemMessage(message, state, envelopes) {
|
|
574
|
+
const raw = message;
|
|
575
|
+
const subtype = typeof raw.subtype === "string" ? raw.subtype : "";
|
|
576
|
+
if (!subtype.startsWith("task_")) return;
|
|
577
|
+
if (raw.skip_transcript === true) return;
|
|
578
|
+
const toolUseId = typeof raw.tool_use_id === "string" && raw.tool_use_id.length > 0 ? raw.tool_use_id : null;
|
|
579
|
+
if (!toolUseId) return;
|
|
580
|
+
if (subtype === "task_started" && raw.task_type === "local_bash" && state.bashToolCalls?.has(toolUseId)) {
|
|
581
|
+
(state.backgroundBashCalls ??= /* @__PURE__ */ new Set()).add(toolUseId);
|
|
582
|
+
}
|
|
583
|
+
const backgroundBash = state.backgroundBashCalls?.has(toolUseId) === true;
|
|
584
|
+
if (!getAgentToolCalls(state).has(toolUseId) && !backgroundBash) return;
|
|
585
|
+
const subagent = ensureSessionSubagentIdForProviderSubagent(state, toolUseId);
|
|
586
|
+
const usage = raw.usage && typeof raw.usage === "object" ? {
|
|
587
|
+
...typeof raw.usage.tool_uses === "number" ? { toolUses: raw.usage.tool_uses } : {},
|
|
588
|
+
...typeof raw.usage.total_tokens === "number" ? { totalTokens: raw.usage.total_tokens } : {},
|
|
589
|
+
...typeof raw.usage.duration_ms === "number" ? { durationMs: raw.usage.duration_ms } : {}
|
|
590
|
+
} : void 0;
|
|
591
|
+
if (subtype === "task_started") {
|
|
592
|
+
rememberSubagentMeta(state, subagent, { description: raw.description, subagentType: backgroundBash ? "background-command" : raw.subagent_type });
|
|
593
|
+
const turn = ensureTurn(state, envelopes);
|
|
594
|
+
getStartedSubagents(state).delete(subagent);
|
|
595
|
+
maybeEmitSubagentStart(state, turn, subagent, envelopes);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (subtype === "task_progress") {
|
|
599
|
+
const toolUses = typeof raw.usage?.tool_uses === "number" ? raw.usage.tool_uses : 0;
|
|
600
|
+
const throttle = getProgressThrottle(state);
|
|
601
|
+
const now = nowOf(state);
|
|
602
|
+
if (!shouldEmitProgress(throttle.get(subagent), toolUses, now)) return;
|
|
603
|
+
throttle.set(subagent, { lastAt: now, lastToolUses: toolUses });
|
|
604
|
+
const turn = ensureTurn(state, envelopes);
|
|
605
|
+
maybeEmitSubagentStart(state, turn, subagent, envelopes);
|
|
606
|
+
envelopes.push(index.createEnvelope("agent", {
|
|
607
|
+
t: "progress",
|
|
608
|
+
toolUses,
|
|
609
|
+
...typeof raw.last_tool_name === "string" && raw.last_tool_name ? { lastTool: raw.last_tool_name } : {},
|
|
610
|
+
...usage?.totalTokens !== void 0 ? { totalTokens: usage.totalTokens } : {},
|
|
611
|
+
...usage?.durationMs !== void 0 ? { durationMs: usage.durationMs } : {},
|
|
612
|
+
...typeof raw.summary === "string" && raw.summary.trim() ? { summary: raw.summary.trim() } : {}
|
|
613
|
+
}, { turn, subagent }));
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (subtype === "task_notification") {
|
|
617
|
+
const status = raw.status === "completed" || raw.status === "failed" || raw.status === "stopped" ? raw.status : "completed";
|
|
618
|
+
emitSubagentStopWithStatus(state, state.currentTurnId ?? void 0, subagent, envelopes, {
|
|
619
|
+
status,
|
|
620
|
+
...usage ? { usage } : {}
|
|
621
|
+
});
|
|
622
|
+
getStubSubagents(state).delete(subagent);
|
|
623
|
+
getProgressThrottle(state).delete(subagent);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (subtype === "task_updated" && raw.patch?.is_backgrounded === true) {
|
|
627
|
+
getStubSubagents(state).add(subagent);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
function mapClaudeLogMessageToSessionEnvelopesInternal(message, state) {
|
|
631
|
+
const envelopes = [];
|
|
632
|
+
const claudeUuid = pickUuid(message);
|
|
633
|
+
const providerSubagent = resolveProviderSubagent(message, state);
|
|
634
|
+
const subagent = providerSubagent ? getSessionSubagentIdForProviderSubagent(state, providerSubagent) : void 0;
|
|
635
|
+
rememberSubagentForMessage(message, state, providerSubagent);
|
|
636
|
+
if (providerSubagent && !subagent) {
|
|
637
|
+
bufferSubagentMessage(state, providerSubagent, message);
|
|
638
|
+
return {
|
|
639
|
+
currentTurnId: state.currentTurnId,
|
|
640
|
+
envelopes: []
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
if (message.type === "summary") {
|
|
644
|
+
return {
|
|
645
|
+
currentTurnId: state.currentTurnId,
|
|
646
|
+
envelopes
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
if (message.type === "system") {
|
|
650
|
+
mapTaskLifecycleSystemMessage(message, state, envelopes);
|
|
651
|
+
return {
|
|
652
|
+
currentTurnId: state.currentTurnId,
|
|
653
|
+
envelopes
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
if (message.isCompactSummary) {
|
|
657
|
+
return {
|
|
658
|
+
currentTurnId: state.currentTurnId,
|
|
659
|
+
envelopes
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
if (message.type === "assistant") {
|
|
663
|
+
const assistantError = message.error;
|
|
664
|
+
if (!message.isSidechain && typeof assistantError === "string" && assistantError.trim()) {
|
|
665
|
+
state.pendingAssistantError = assistantError.trim();
|
|
666
|
+
}
|
|
667
|
+
const turnId = ensureTurn(state, envelopes);
|
|
668
|
+
maybeEmitSubagentStart(state, turnId, subagent, envelopes);
|
|
669
|
+
const usage = pickAssistantUsage(message);
|
|
670
|
+
const blocks = Array.isArray(message.message?.content) ? message.message.content : [];
|
|
671
|
+
const apiMessageId = !isSidechainMessage(message) && typeof message.message?.id === "string" && message.message.id.length > 0 ? message.message.id : void 0;
|
|
672
|
+
let blockCursor = 0;
|
|
673
|
+
if (apiMessageId) {
|
|
674
|
+
if (state.streamBlockMessageId !== apiMessageId) {
|
|
675
|
+
state.streamBlockMessageId = apiMessageId;
|
|
676
|
+
state.streamBlockCursor = 0;
|
|
677
|
+
}
|
|
678
|
+
blockCursor = state.streamBlockCursor ?? 0;
|
|
679
|
+
state.streamBlockCursor = blockCursor + blocks.length;
|
|
680
|
+
}
|
|
681
|
+
const actualModel = subagent && typeof message.message?.model === "string" && message.message.model.trim() ? { actualModel: message.message.model.trim() } : {};
|
|
682
|
+
const draftKey = (index$1) => apiMessageId ? index.streamKeyOf(apiMessageId, blockCursor + index$1) : void 0;
|
|
683
|
+
for (const [blockIndex, block] of blocks.entries()) {
|
|
684
|
+
if (block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0) {
|
|
685
|
+
envelopes.push(index.createEnvelope("agent", { t: "text", text: block.text, ...actualModel }, { turn: turnId, subagent, claudeUuid, usage, streamKey: draftKey(blockIndex) }));
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
if (block.type === "thinking" && typeof block.thinking === "string" && block.thinking.trim().length > 0) {
|
|
689
|
+
envelopes.push(index.createEnvelope("agent", { t: "text", text: block.thinking, thinking: true, ...actualModel }, { turn: turnId, subagent, claudeUuid, usage, streamKey: draftKey(blockIndex) }));
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
if (block.type === "tool_use") {
|
|
693
|
+
const call = typeof block.id === "string" && block.id.length > 0 ? block.id : cuid2.createId();
|
|
694
|
+
const name = typeof block.name === "string" && block.name.length > 0 ? block.name : "unknown";
|
|
695
|
+
const baseArgs = toToolArgs(block.input);
|
|
696
|
+
const title = toolTitle(name, block.input);
|
|
697
|
+
const sessionSubagentForCall = ensureSessionSubagentIdForProviderSubagent(state, call);
|
|
698
|
+
if (isSubagentTool(name)) {
|
|
699
|
+
const prompt = pickTaskPrompt(block.input);
|
|
700
|
+
if (prompt) {
|
|
701
|
+
queueTaskPromptSubagent(state, prompt, call);
|
|
702
|
+
}
|
|
703
|
+
setSubagentTitle(state, sessionSubagentForCall, pickTaskTitle(block.input) ?? prompt);
|
|
704
|
+
getAgentToolCalls(state).add(call);
|
|
705
|
+
const input = block.input ?? {};
|
|
706
|
+
rememberSubagentMeta(state, sessionSubagentForCall, { description: input.description, subagentType: input.subagent_type });
|
|
707
|
+
}
|
|
708
|
+
if (shouldHideParentToolCall(name)) {
|
|
709
|
+
getHiddenParentToolCalls(state).add(call);
|
|
710
|
+
const buffered2 = consumeBufferedSubagentMessages(state, call);
|
|
711
|
+
for (const bufferedMessage of buffered2) {
|
|
712
|
+
const replay = mapClaudeLogMessageToSessionEnvelopesInternal(bufferedMessage, state);
|
|
713
|
+
envelopes.push(...replay.envelopes);
|
|
714
|
+
}
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
if (name === "Bash") (state.bashToolCalls ??= /* @__PURE__ */ new Set()).add(call);
|
|
718
|
+
const args = isSubagentTool(name) || name === "Bash" ? { ...baseArgs, sessionSubagent: sessionSubagentForCall } : baseArgs;
|
|
719
|
+
envelopes.push(index.createEnvelope("agent", {
|
|
720
|
+
t: "tool-call-start",
|
|
721
|
+
call,
|
|
722
|
+
name,
|
|
723
|
+
title,
|
|
724
|
+
description: title,
|
|
725
|
+
args
|
|
726
|
+
}, { turn: turnId, subagent, usage }));
|
|
727
|
+
const buffered = consumeBufferedSubagentMessages(state, call);
|
|
728
|
+
for (const bufferedMessage of buffered) {
|
|
729
|
+
const replay = mapClaudeLogMessageToSessionEnvelopesInternal(bufferedMessage, state);
|
|
730
|
+
envelopes.push(...replay.envelopes);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
currentTurnId: state.currentTurnId,
|
|
736
|
+
envelopes
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
if (message.type === "user") {
|
|
740
|
+
if (message.isMeta) {
|
|
741
|
+
return {
|
|
742
|
+
currentTurnId: state.currentTurnId,
|
|
743
|
+
envelopes
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
if (typeof message.message.content === "string") {
|
|
747
|
+
if (message.isSidechain) {
|
|
748
|
+
const turnId2 = ensureTurn(state, envelopes);
|
|
749
|
+
maybeEmitSubagentStart(state, turnId2, subagent, envelopes);
|
|
750
|
+
envelopes.push(index.createEnvelope("agent", { t: "text", text: message.message.content }, { turn: turnId2, subagent, claudeUuid }));
|
|
751
|
+
} else {
|
|
752
|
+
const origin = message.origin;
|
|
753
|
+
if (origin?.kind === "task-notification" || /<task-notification>/i.test(message.message.content)) {
|
|
754
|
+
const fields = parseTaskNotificationText(message.message.content);
|
|
755
|
+
const providerId = fields?.toolUseId ?? null;
|
|
756
|
+
const subagentForNotification = providerId && getAgentToolCalls(state).has(providerId) ? getSessionSubagentIdForProviderSubagent(state, providerId) : void 0;
|
|
757
|
+
if (fields && subagentForNotification && (fields.result || fields.status)) {
|
|
758
|
+
emitSubagentStopWithStatus(state, state.currentTurnId ?? void 0, subagentForNotification, envelopes, {
|
|
759
|
+
...fields.status ? { status: fields.status } : {},
|
|
760
|
+
...fields.result ? { result: fields.result } : {}
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
closeTurn(state, "completed", envelopes);
|
|
765
|
+
envelopes.push(index.createEnvelope("user", { t: "text", text: message.message.content }, { claudeUuid }));
|
|
766
|
+
}
|
|
767
|
+
return {
|
|
768
|
+
currentTurnId: state.currentTurnId,
|
|
769
|
+
envelopes
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
const blocks = Array.isArray(message.message.content) ? message.message.content : [];
|
|
773
|
+
if (blocks.length === 0) {
|
|
774
|
+
return {
|
|
775
|
+
currentTurnId: state.currentTurnId,
|
|
776
|
+
envelopes
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
const turnId = ensureTurn(state, envelopes);
|
|
780
|
+
if (message.isSidechain) {
|
|
781
|
+
maybeEmitSubagentStart(state, turnId, subagent, envelopes);
|
|
782
|
+
}
|
|
783
|
+
for (const block of blocks) {
|
|
784
|
+
if (block.type === "tool_result" && typeof block.tool_use_id === "string" && block.tool_use_id.length > 0) {
|
|
785
|
+
const sessionSubagentForToolResult = getSessionSubagentIdForProviderSubagent(state, block.tool_use_id);
|
|
786
|
+
if (!message.isSidechain) {
|
|
787
|
+
if (getHiddenParentToolCalls(state).has(block.tool_use_id)) {
|
|
788
|
+
if (sessionSubagentForToolResult) {
|
|
789
|
+
maybeEmitSubagentStop(state, turnId, sessionSubagentForToolResult, envelopes);
|
|
790
|
+
}
|
|
791
|
+
getHiddenParentToolCalls(state).delete(block.tool_use_id);
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
if (sessionSubagentForToolResult && getAgentToolCalls(state).has(block.tool_use_id)) {
|
|
795
|
+
const toolUseResult = message.tool_use_result;
|
|
796
|
+
const resultStatus = toolUseResultStatus(toolUseResult);
|
|
797
|
+
if (resultStatus === "async_launched") {
|
|
798
|
+
getStubSubagents(state).add(sessionSubagentForToolResult);
|
|
799
|
+
envelopes.push(index.createEnvelope("agent", { t: "tool-call-end", call: block.tool_use_id }, { turn: turnId, subagent }));
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
if (resultStatus === "completed") {
|
|
803
|
+
const text = toolUseResultToText(toolUseResult);
|
|
804
|
+
const stats = toolUseResultStats(toolUseResult);
|
|
805
|
+
const result = text ? capSubagentText(text) : void 0;
|
|
806
|
+
emitSubagentStopWithStatus(state, turnId, sessionSubagentForToolResult, envelopes, {
|
|
807
|
+
status: "completed",
|
|
808
|
+
...result ? { result } : {},
|
|
809
|
+
...stats ? { usage: { toolUses: stats.toolUses, totalTokens: stats.totalTokens, durationMs: stats.durationMs } } : {}
|
|
810
|
+
});
|
|
811
|
+
envelopes.push(index.createEnvelope("agent", {
|
|
812
|
+
t: "tool-call-end",
|
|
813
|
+
call: block.tool_use_id,
|
|
814
|
+
...result ? { result: { ...result, ...stats ? { stats } : {} } } : {}
|
|
815
|
+
}, { turn: turnId, subagent }));
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
if (sessionSubagentForToolResult && !state.backgroundBashCalls?.has(block.tool_use_id)) {
|
|
820
|
+
maybeEmitSubagentStop(state, turnId, sessionSubagentForToolResult, envelopes);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
envelopes.push(index.createEnvelope("agent", {
|
|
824
|
+
t: "tool-call-end",
|
|
825
|
+
call: block.tool_use_id
|
|
826
|
+
}, { turn: turnId, subagent }));
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
if (block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0) {
|
|
830
|
+
envelopes.push(index.createEnvelope("agent", { t: "text", text: block.text }, { turn: turnId, subagent, claudeUuid }));
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return {
|
|
834
|
+
currentTurnId: state.currentTurnId,
|
|
835
|
+
envelopes
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
if (message.type === "result") {
|
|
839
|
+
const raw = message;
|
|
840
|
+
const status = raw.interrupted === true ? "cancelled" : raw.is_error === true ? "failed" : "completed";
|
|
841
|
+
const error = status === "failed" ? raw.errors?.filter((item) => typeof item === "string" && item.trim()).join("\n") || state.pendingAssistantError || raw.subtype || "Claude turn failed" : void 0;
|
|
842
|
+
if (status === "failed" && !state.currentTurnId) {
|
|
843
|
+
ensureTurn(state, envelopes);
|
|
844
|
+
}
|
|
845
|
+
const usage = raw.usage && typeof raw.usage.input_tokens === "number" && typeof raw.usage.output_tokens === "number" ? {
|
|
846
|
+
input_tokens: raw.usage.input_tokens,
|
|
847
|
+
output_tokens: raw.usage.output_tokens,
|
|
848
|
+
...typeof raw.usage.cache_creation_input_tokens === "number" ? { cache_creation_input_tokens: raw.usage.cache_creation_input_tokens } : {},
|
|
849
|
+
...typeof raw.usage.cache_read_input_tokens === "number" ? { cache_read_input_tokens: raw.usage.cache_read_input_tokens } : {}
|
|
850
|
+
} : void 0;
|
|
851
|
+
closeTurn(state, status, envelopes, {
|
|
852
|
+
error,
|
|
853
|
+
costUsd: typeof raw.total_cost_usd === "number" ? raw.total_cost_usd : void 0,
|
|
854
|
+
durationMs: typeof raw.duration_ms === "number" ? raw.duration_ms : void 0,
|
|
855
|
+
numTurns: typeof raw.num_turns === "number" ? raw.num_turns : void 0,
|
|
856
|
+
usage
|
|
857
|
+
});
|
|
858
|
+
return {
|
|
859
|
+
currentTurnId: state.currentTurnId,
|
|
860
|
+
envelopes
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
return {
|
|
864
|
+
currentTurnId: state.currentTurnId,
|
|
865
|
+
envelopes
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function sessionEnvelopeOf(body) {
|
|
870
|
+
if (!body || typeof body !== "object") return null;
|
|
871
|
+
const record = body;
|
|
872
|
+
if (record.role !== "session" || !record.content || typeof record.content !== "object") return null;
|
|
873
|
+
const content = record.content;
|
|
874
|
+
if (content.type === "session" && content.data && typeof content.data === "object") {
|
|
875
|
+
return content.data;
|
|
876
|
+
}
|
|
877
|
+
if (content.ev !== void 0) return content;
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
function isQueuedUserText(body) {
|
|
881
|
+
if (!body || typeof body !== "object") return false;
|
|
882
|
+
const record = body;
|
|
883
|
+
return record.role === "user" && record.content?.type === "text" && typeof record.meta?.queuedAt === "number";
|
|
884
|
+
}
|
|
885
|
+
function scanUndeliveredQueuedInputs(records, prior) {
|
|
886
|
+
const canceled = prior?.canceled ?? /* @__PURE__ */ new Set();
|
|
887
|
+
const undelivered = [];
|
|
888
|
+
let reachedBoundary = false;
|
|
889
|
+
for (const record of records) {
|
|
890
|
+
const envelope = sessionEnvelopeOf(record.body);
|
|
891
|
+
if (envelope) {
|
|
892
|
+
const t = envelope.ev?.t;
|
|
893
|
+
if (t === "turn-end") {
|
|
894
|
+
reachedBoundary = true;
|
|
895
|
+
break;
|
|
896
|
+
}
|
|
897
|
+
if (t === "queue-cancel" && Array.isArray(envelope.ev?.targetLocalKeys)) {
|
|
898
|
+
for (const key of envelope.ev.targetLocalKeys) {
|
|
899
|
+
if (typeof key === "string") canceled.add(key);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
if (isQueuedUserText(record.body) && record.localId && !canceled.has(record.localId)) {
|
|
905
|
+
undelivered.push(record.localId);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
return { undeliveredLocalKeys: undelivered, reachedBoundary, canceled };
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function record(value) {
|
|
912
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
913
|
+
}
|
|
914
|
+
function finiteNonNegative(value) {
|
|
915
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
|
|
916
|
+
}
|
|
917
|
+
function firstNumber(source, keys) {
|
|
918
|
+
for (const key of keys) {
|
|
919
|
+
const value = finiteNonNegative(source[key]);
|
|
920
|
+
if (value !== void 0) return value;
|
|
921
|
+
}
|
|
922
|
+
return void 0;
|
|
923
|
+
}
|
|
924
|
+
function normalizeAgentUsage(raw) {
|
|
925
|
+
const outer = record(raw);
|
|
926
|
+
if (!outer) return null;
|
|
927
|
+
const wrapped = record(outer.totalTokenUsage) ?? record(outer.total_token_usage) ?? record(outer.total) ?? record(outer.usage) ?? outer;
|
|
928
|
+
const input = firstNumber(wrapped, ["inputTokens", "input_tokens", "input"]);
|
|
929
|
+
const output = firstNumber(wrapped, ["outputTokens", "output_tokens", "output"]);
|
|
930
|
+
const cacheRead = firstNumber(wrapped, ["cachedInputTokens", "cached_input_tokens", "cachedReadTokens", "cacheReadInputTokens", "cache_read_input_tokens", "cache_read"]);
|
|
931
|
+
const cacheCreation = firstNumber(wrapped, ["cacheCreationInputTokens", "cache_creation_input_tokens", "cachedWriteTokens", "cacheWriteInputTokens", "cache_write_input_tokens", "cache_creation"]);
|
|
932
|
+
const reasoning = firstNumber(wrapped, ["reasoningOutputTokens", "reasoning_output_tokens", "thoughtTokens", "reasoning"]);
|
|
933
|
+
const explicitTotal = firstNumber(wrapped, ["totalTokens", "total_tokens", "total"]);
|
|
934
|
+
const fallbackTotal = input !== void 0 || output !== void 0 ? (input ?? 0) + (output ?? 0) : void 0;
|
|
935
|
+
const total = explicitTotal ?? fallbackTotal;
|
|
936
|
+
if (total === void 0) return null;
|
|
937
|
+
return {
|
|
938
|
+
total,
|
|
939
|
+
...input !== void 0 ? { input } : {},
|
|
940
|
+
...output !== void 0 ? { output } : {},
|
|
941
|
+
...cacheCreation !== void 0 ? { cache_creation: cacheCreation } : {},
|
|
942
|
+
...cacheRead !== void 0 ? { cache_read: cacheRead } : {},
|
|
943
|
+
...reasoning !== void 0 ? { reasoning } : {}
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
function usageAgentKey(agent) {
|
|
947
|
+
return agent.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const TURN_LEASE_MS = 6e4;
|
|
951
|
+
const TURN_BUSY_TTL_MS = 15e4;
|
|
952
|
+
class TurnActivityTracker {
|
|
953
|
+
constructor(ttlMs = TURN_BUSY_TTL_MS) {
|
|
954
|
+
this.ttlMs = ttlMs;
|
|
955
|
+
}
|
|
956
|
+
busy = /* @__PURE__ */ new Map();
|
|
957
|
+
apply(sessionId, event, now = Date.now()) {
|
|
958
|
+
if (event === "turn_started") this.busy.set(sessionId, now);
|
|
959
|
+
else this.busy.delete(sessionId);
|
|
960
|
+
}
|
|
961
|
+
/** The wrapper is gone: whatever it reported no longer holds. */
|
|
962
|
+
forget(sessionId) {
|
|
963
|
+
this.busy.delete(sessionId);
|
|
964
|
+
}
|
|
965
|
+
/** Sessions whose last renewal is still inside the TTL. */
|
|
966
|
+
activeSessions(now = Date.now()) {
|
|
967
|
+
const out = [];
|
|
968
|
+
for (const [id, at] of this.busy) {
|
|
969
|
+
if (now - at <= this.ttlMs) out.push(id);
|
|
970
|
+
else this.busy.delete(id);
|
|
971
|
+
}
|
|
972
|
+
return out;
|
|
973
|
+
}
|
|
974
|
+
hasActiveTurn(now = Date.now()) {
|
|
975
|
+
return this.activeSessions(now).length > 0;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
class TurnReporter {
|
|
979
|
+
constructor(send, leaseMs = TURN_LEASE_MS) {
|
|
980
|
+
this.send = send;
|
|
981
|
+
this.leaseMs = leaseMs;
|
|
982
|
+
}
|
|
983
|
+
thinking = false;
|
|
984
|
+
lastStartedAt = 0;
|
|
985
|
+
/** Called on every keepAlive tick; emits only on an edge or a due renewal. */
|
|
986
|
+
observe(thinking, now = Date.now()) {
|
|
987
|
+
if (thinking) {
|
|
988
|
+
const due = !this.thinking || now - this.lastStartedAt >= this.leaseMs;
|
|
989
|
+
this.thinking = true;
|
|
990
|
+
if (!due) return null;
|
|
991
|
+
this.lastStartedAt = now;
|
|
992
|
+
this.send("turn_started");
|
|
993
|
+
return "turn_started";
|
|
994
|
+
}
|
|
995
|
+
if (!this.thinking) return null;
|
|
996
|
+
this.thinking = false;
|
|
997
|
+
this.lastStartedAt = 0;
|
|
998
|
+
this.send("turn_ended");
|
|
999
|
+
return "turn_ended";
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const PROMPT_QUEUE_INFLIGHT_TIMEOUT_MS = 2e4;
|
|
1004
|
+
const PROMPT_QUEUE_RETRY_MIN_MS = 1e3;
|
|
1005
|
+
const PROMPT_QUEUE_RETRY_MAX_MS = 3e4;
|
|
1006
|
+
class PromptQueueDrain {
|
|
1007
|
+
constructor(deps) {
|
|
1008
|
+
this.deps = deps;
|
|
1009
|
+
this.now = deps.now ?? (() => Date.now());
|
|
1010
|
+
this.log = deps.log ?? (() => {
|
|
1011
|
+
});
|
|
1012
|
+
this.schedule = deps.schedule ?? ((fn, delayMs) => {
|
|
1013
|
+
const timer = setTimeout(fn, delayMs);
|
|
1014
|
+
timer.unref?.();
|
|
1015
|
+
});
|
|
1016
|
+
this.inflightTimeoutMs = deps.inflightTimeoutMs ?? PROMPT_QUEUE_INFLIGHT_TIMEOUT_MS;
|
|
1017
|
+
}
|
|
1018
|
+
inflight = null;
|
|
1019
|
+
/**
|
|
1020
|
+
* localIds routed while a dispatch call was still in flight. The server's
|
|
1021
|
+
* `new-message` echo regularly beats the HTTP response (both come off the
|
|
1022
|
+
* same transaction), so `onInbound` can precede the `dispatched` outcome;
|
|
1023
|
+
* without this the guard armed AFTER the message had arrived and sat
|
|
1024
|
+
* until the timeout — observed in the first isolated e2e run.
|
|
1025
|
+
*/
|
|
1026
|
+
arrivedDuringDispatch = /* @__PURE__ */ new Set();
|
|
1027
|
+
/** Items the server still holds, from the last snapshot / dispatch response. */
|
|
1028
|
+
pending = 0;
|
|
1029
|
+
disabled = false;
|
|
1030
|
+
closed = false;
|
|
1031
|
+
busy = false;
|
|
1032
|
+
failures = 0;
|
|
1033
|
+
retryAt = 0;
|
|
1034
|
+
retryScheduled = false;
|
|
1035
|
+
now;
|
|
1036
|
+
log;
|
|
1037
|
+
schedule;
|
|
1038
|
+
inflightTimeoutMs;
|
|
1039
|
+
/** The runner is blocked on an empty input queue. */
|
|
1040
|
+
onIdle() {
|
|
1041
|
+
void this.maybeDispatch("idle");
|
|
1042
|
+
}
|
|
1043
|
+
/** A `prompt-queue` snapshot arrived; only a non-empty queue is worth a call. */
|
|
1044
|
+
onQueueChanged(count) {
|
|
1045
|
+
this.pending = Math.max(0, count);
|
|
1046
|
+
if (count > 0) void this.maybeDispatch("queue-changed");
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* True while the server still holds queued prompts for this session. The
|
|
1050
|
+
* runners fold it into their "idle, waiting for the user" judgement so a
|
|
1051
|
+
* turn that ends with more queue behind it raises no reply_done /
|
|
1052
|
+
* input_needed notification and no daemon `completed` report per item.
|
|
1053
|
+
*/
|
|
1054
|
+
hasPending() {
|
|
1055
|
+
return !this.disabled && !this.closed && this.pending > 0;
|
|
1056
|
+
}
|
|
1057
|
+
/** A user message with this localId was routed into the runner's queue. */
|
|
1058
|
+
onInbound(localId) {
|
|
1059
|
+
if (this.inflight?.localId === localId) {
|
|
1060
|
+
this.inflight = null;
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
if (this.busy) {
|
|
1064
|
+
this.arrivedDuringDispatch.add(localId);
|
|
1065
|
+
if (this.arrivedDuringDispatch.size > 64) {
|
|
1066
|
+
const oldest = this.arrivedDuringDispatch.values().next().value;
|
|
1067
|
+
if (oldest !== void 0) this.arrivedDuringDispatch.delete(oldest);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
close() {
|
|
1072
|
+
this.closed = true;
|
|
1073
|
+
}
|
|
1074
|
+
state() {
|
|
1075
|
+
return { inflight: this.inflight, disabled: this.disabled, busy: this.busy, failures: this.failures, retryAt: this.retryAt, pending: this.pending };
|
|
1076
|
+
}
|
|
1077
|
+
async maybeDispatch(reason) {
|
|
1078
|
+
if (this.closed || this.disabled || this.busy) return false;
|
|
1079
|
+
const now = this.now();
|
|
1080
|
+
if (this.inflight) {
|
|
1081
|
+
if (now - this.inflight.since < this.inflightTimeoutMs) return false;
|
|
1082
|
+
this.log(`[prompt-queue] inflight ${this.inflight.localId} not routed within ${this.inflightTimeoutMs}ms; releasing`);
|
|
1083
|
+
this.inflight = null;
|
|
1084
|
+
}
|
|
1085
|
+
if (now < this.retryAt) {
|
|
1086
|
+
this.scheduleRetry(this.retryAt - now);
|
|
1087
|
+
return false;
|
|
1088
|
+
}
|
|
1089
|
+
if (!this.deps.isIdle()) return false;
|
|
1090
|
+
this.busy = true;
|
|
1091
|
+
this.arrivedDuringDispatch.clear();
|
|
1092
|
+
try {
|
|
1093
|
+
const outcome = await this.deps.dispatch();
|
|
1094
|
+
switch (outcome.kind) {
|
|
1095
|
+
case "dispatched": {
|
|
1096
|
+
this.failures = 0;
|
|
1097
|
+
if (typeof outcome.remaining === "number") this.pending = Math.max(0, outcome.remaining);
|
|
1098
|
+
const alreadyRouted = this.arrivedDuringDispatch.delete(outcome.localId) || outcome.created === false;
|
|
1099
|
+
if (!alreadyRouted) {
|
|
1100
|
+
this.inflight = { localId: outcome.localId, since: this.now() };
|
|
1101
|
+
this.schedule(() => {
|
|
1102
|
+
void this.maybeDispatch("inflight-timeout");
|
|
1103
|
+
}, this.inflightTimeoutMs + 1);
|
|
1104
|
+
}
|
|
1105
|
+
this.log(`[prompt-queue] dispatched ${outcome.localId} (${reason}${alreadyRouted ? ", already routed" : ""})`);
|
|
1106
|
+
return true;
|
|
1107
|
+
}
|
|
1108
|
+
case "empty":
|
|
1109
|
+
this.failures = 0;
|
|
1110
|
+
this.pending = 0;
|
|
1111
|
+
return false;
|
|
1112
|
+
case "unsupported":
|
|
1113
|
+
this.disabled = true;
|
|
1114
|
+
this.log("[prompt-queue] server has no prompt queue; drain disabled for this process");
|
|
1115
|
+
return false;
|
|
1116
|
+
case "error": {
|
|
1117
|
+
this.failures += 1;
|
|
1118
|
+
const delay = Math.min(PROMPT_QUEUE_RETRY_MAX_MS, PROMPT_QUEUE_RETRY_MIN_MS * 2 ** (this.failures - 1));
|
|
1119
|
+
this.retryAt = this.now() + delay;
|
|
1120
|
+
this.log(`[prompt-queue] dispatch failed (${outcome.message}); retry in ${delay}ms`);
|
|
1121
|
+
this.scheduleRetry(delay);
|
|
1122
|
+
return false;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
} finally {
|
|
1126
|
+
this.busy = false;
|
|
1127
|
+
this.arrivedDuringDispatch.clear();
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
scheduleRetry(delayMs) {
|
|
1131
|
+
if (this.retryScheduled || this.closed) return;
|
|
1132
|
+
this.retryScheduled = true;
|
|
1133
|
+
this.schedule(() => {
|
|
1134
|
+
this.retryScheduled = false;
|
|
1135
|
+
void this.maybeDispatch("retry");
|
|
1136
|
+
}, delayMs);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
class ApiSessionClient extends node_events.EventEmitter {
|
|
1141
|
+
/**
|
|
1142
|
+
* B-512: the session socket now connects before the runner has attached its
|
|
1143
|
+
* `archived` listener (registerKillSessionHandler). An archive signal that
|
|
1144
|
+
* fires in that window must not be lost: remember it and replay it to any
|
|
1145
|
+
* listener attached later.
|
|
1146
|
+
*/
|
|
1147
|
+
archiveSignalled = false;
|
|
1148
|
+
emit(event, ...args) {
|
|
1149
|
+
if (event === "archived") this.archiveSignalled = true;
|
|
1150
|
+
return super.emit(event, ...args);
|
|
1151
|
+
}
|
|
1152
|
+
on(event, listener) {
|
|
1153
|
+
super.on(event, listener);
|
|
1154
|
+
if (event === "archived" && this.archiveSignalled) queueMicrotask(() => listener());
|
|
1155
|
+
return this;
|
|
1156
|
+
}
|
|
1157
|
+
token;
|
|
1158
|
+
sessionId;
|
|
1159
|
+
metadata;
|
|
1160
|
+
metadataVersion;
|
|
1161
|
+
agentState;
|
|
1162
|
+
agentStateVersion;
|
|
1163
|
+
socket;
|
|
1164
|
+
relaySocket = null;
|
|
1165
|
+
pendingMessages = [];
|
|
1166
|
+
pendingMessageCallback = null;
|
|
1167
|
+
pendingFileEvents = [];
|
|
1168
|
+
pendingFileEventCallback = null;
|
|
1169
|
+
blobKey = null;
|
|
1170
|
+
/**
|
|
1171
|
+
* In-flight attachment download promises that belong to the *current*
|
|
1172
|
+
* (not-yet-drained) batch. Each promise resolves to the decoded blob (or
|
|
1173
|
+
* null on failure), so per-message ownership is intrinsic — there is no
|
|
1174
|
+
* shared push-array between batches that a late download could leak into.
|
|
1175
|
+
*/
|
|
1176
|
+
pendingDownloads = [];
|
|
1177
|
+
rpcHandlerManager;
|
|
1178
|
+
agentStateLock = new attachmentLimits.AsyncLock();
|
|
1179
|
+
metadataLock = new attachmentLimits.AsyncLock();
|
|
1180
|
+
encryptionKey;
|
|
1181
|
+
encryptionVariant;
|
|
1182
|
+
reconnectInterval = null;
|
|
1183
|
+
ignoreArchiveSignal = false;
|
|
1184
|
+
skipInitialMessages = false;
|
|
1185
|
+
claudeSessionProtocolState = {
|
|
1186
|
+
currentTurnId: null,
|
|
1187
|
+
uuidToProviderSubagent: /* @__PURE__ */ new Map(),
|
|
1188
|
+
taskPromptToSubagents: /* @__PURE__ */ new Map(),
|
|
1189
|
+
providerSubagentToSessionSubagent: /* @__PURE__ */ new Map(),
|
|
1190
|
+
subagentTitles: /* @__PURE__ */ new Map(),
|
|
1191
|
+
bufferedSubagentMessages: /* @__PURE__ */ new Map(),
|
|
1192
|
+
hiddenParentToolCalls: /* @__PURE__ */ new Set(),
|
|
1193
|
+
startedSubagents: /* @__PURE__ */ new Set(),
|
|
1194
|
+
activeSubagents: /* @__PURE__ */ new Set()
|
|
1195
|
+
};
|
|
1196
|
+
seenClaudeUsageEvents = /* @__PURE__ */ new Set();
|
|
1197
|
+
claudeUsageTotals = {
|
|
1198
|
+
total: 0,
|
|
1199
|
+
input: 0,
|
|
1200
|
+
output: 0,
|
|
1201
|
+
cache_creation: 0,
|
|
1202
|
+
cache_read: 0,
|
|
1203
|
+
costTotal: 0,
|
|
1204
|
+
costInput: 0,
|
|
1205
|
+
costOutput: 0
|
|
1206
|
+
};
|
|
1207
|
+
lastSeq = 0;
|
|
1208
|
+
pendingOutbox = [];
|
|
1209
|
+
sendSync;
|
|
1210
|
+
receiveSync;
|
|
1211
|
+
directInboundLocalIds = /* @__PURE__ */ new Set();
|
|
1212
|
+
routedInboundLocalIds = /* @__PURE__ */ new Set();
|
|
1213
|
+
handoverInFlight = null;
|
|
1214
|
+
/** B-265 reconnect: the message cursor was seeded from the server, so
|
|
1215
|
+
* nothing before it is history to replay — but socket fast-path routing
|
|
1216
|
+
* must wait for the first fetch, otherwise a message that lands between
|
|
1217
|
+
* the seed and the first fetch is routed twice (socket + fetch). */
|
|
1218
|
+
awaitingInitialFetch = false;
|
|
1219
|
+
/** B-509: server-side prompt queue drain, attached by the runner. */
|
|
1220
|
+
promptQueueDrain = null;
|
|
1221
|
+
constructor(token, session, opts) {
|
|
1222
|
+
super();
|
|
1223
|
+
this.token = token;
|
|
1224
|
+
this.sessionId = session.id;
|
|
1225
|
+
if (opts?.initialSeq !== void 0 && opts.initialSeq >= 0) {
|
|
1226
|
+
this.lastSeq = opts.initialSeq;
|
|
1227
|
+
this.awaitingInitialFetch = true;
|
|
1228
|
+
}
|
|
1229
|
+
this.metadata = session.metadata;
|
|
1230
|
+
this.metadataVersion = session.metadataVersion;
|
|
1231
|
+
this.agentState = session.agentState;
|
|
1232
|
+
this.agentStateVersion = session.agentStateVersion;
|
|
1233
|
+
this.encryptionKey = session.encryptionKey;
|
|
1234
|
+
this.encryptionVariant = session.encryptionVariant;
|
|
1235
|
+
this.sendSync = new sync.InvalidateSync(() => this.flushOutbox());
|
|
1236
|
+
this.receiveSync = new sync.InvalidateSync(() => this.fetchMessages());
|
|
1237
|
+
this.rpcHandlerManager = new lidState.RpcHandlerManager({
|
|
1238
|
+
scopePrefix: this.sessionId,
|
|
1239
|
+
encryptionKey: this.encryptionKey,
|
|
1240
|
+
encryptionVariant: this.encryptionVariant,
|
|
1241
|
+
logger: (msg, data) => logger.logger.debug(msg, data)
|
|
1242
|
+
});
|
|
1243
|
+
registerCommonHandlers.registerCommonHandlers(this.rpcHandlerManager, this.metadata.path);
|
|
1244
|
+
this.socket = this.createControlSocket();
|
|
1245
|
+
const controlSocket = this.socket;
|
|
1246
|
+
this.socket.on("connect", () => {
|
|
1247
|
+
logger.logger.debug("Socket connected successfully");
|
|
1248
|
+
if (this.reconnectInterval) {
|
|
1249
|
+
clearInterval(this.reconnectInterval);
|
|
1250
|
+
this.reconnectInterval = null;
|
|
1251
|
+
}
|
|
1252
|
+
this.rpcHandlerManager.onSocketConnect(controlSocket);
|
|
1253
|
+
this.receiveSync.invalidate();
|
|
1254
|
+
void this.connectSessionRelay();
|
|
1255
|
+
void this.promptQueueDrain?.maybeDispatch("connect");
|
|
1256
|
+
});
|
|
1257
|
+
this.socket.on("rpc-request", async (data, callback) => {
|
|
1258
|
+
await lidState.answerSocketRequest(
|
|
1259
|
+
callback,
|
|
1260
|
+
() => this.rpcHandlerManager.handleRequest(data),
|
|
1261
|
+
(drop) => logger.logger.debug(`[API] RPC ${data?.method} unanswered \u2014 ${lidState.describeSocketAckDrop(drop)}`)
|
|
1262
|
+
);
|
|
1263
|
+
});
|
|
1264
|
+
this.socket.on("disconnect", (reason) => {
|
|
1265
|
+
logger.logger.debug(`[API] Socket disconnected: ${reason}`);
|
|
1266
|
+
this.rpcHandlerManager.onSocketDisconnect(controlSocket);
|
|
1267
|
+
this.startSmartReconnect();
|
|
1268
|
+
});
|
|
1269
|
+
this.socket.on("connect_error", (error) => {
|
|
1270
|
+
logger.logger.debug("[API] Socket connection error:", error);
|
|
1271
|
+
this.rpcHandlerManager.onSocketDisconnect(controlSocket);
|
|
1272
|
+
if (error.message === "Session archived") {
|
|
1273
|
+
logger.logger.debug("[API] Archived session rejected during reconnect; exiting");
|
|
1274
|
+
this.emit("archived");
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
this.startSmartReconnect();
|
|
1278
|
+
});
|
|
1279
|
+
this.socket.on("session-archive", ({ sessionId }) => {
|
|
1280
|
+
if (sessionId !== this.sessionId) return;
|
|
1281
|
+
logger.logger.debug("[SOCKET] Server archived this session; exiting...");
|
|
1282
|
+
this.emit("archived");
|
|
1283
|
+
});
|
|
1284
|
+
this.socket.on("update", (data) => {
|
|
1285
|
+
try {
|
|
1286
|
+
logger.logger.debugLargeJson("[SOCKET] [UPDATE] Received update:", data);
|
|
1287
|
+
if (!data.body) {
|
|
1288
|
+
logger.logger.debug("[SOCKET] [UPDATE] [ERROR] No body in update!");
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
if (data.body.t === "prompt-queue") {
|
|
1292
|
+
const items = data.body.items;
|
|
1293
|
+
this.promptQueueDrain?.onQueueChanged(Array.isArray(items) ? items.length : 0);
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
if (data.body.t === "new-message") {
|
|
1297
|
+
const messageSeq = data.body.message?.seq;
|
|
1298
|
+
if (this.lastSeq === 0 || this.awaitingInitialFetch) {
|
|
1299
|
+
this.receiveSync.invalidate();
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
if (typeof messageSeq !== "number" || messageSeq !== this.lastSeq + 1 || data.body.message.content.t !== "encrypted") {
|
|
1303
|
+
this.receiveSync.invalidate();
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
const localId = data.body.message.localId;
|
|
1307
|
+
const relayAccepted = !!localId && this.directInboundLocalIds.delete(localId);
|
|
1308
|
+
if (localId && this.routedInboundLocalIds.has(localId)) {
|
|
1309
|
+
this.lastSeq = messageSeq;
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
if (relayAccepted) {
|
|
1313
|
+
logger.logger.debug(`[API] Routing relay-accepted message ${localId} from the central echo (own persist still pending or failed)`);
|
|
1314
|
+
}
|
|
1315
|
+
const body = authRelay.decrypt(this.encryptionKey, this.encryptionVariant, authRelay.decodeBase64(data.body.message.content.c));
|
|
1316
|
+
logger.logger.debugLargeJson("[SOCKET] [UPDATE] Received update:", body);
|
|
1317
|
+
this.routeIncomingMessage(body, localId);
|
|
1318
|
+
if (localId) this.rememberRoutedInbound(localId);
|
|
1319
|
+
this.lastSeq = messageSeq;
|
|
1320
|
+
} else if (data.body.t === "update-session") {
|
|
1321
|
+
if (data.body.metadata && data.body.metadata.version > this.metadataVersion) {
|
|
1322
|
+
this.metadata = authRelay.decrypt(this.encryptionKey, this.encryptionVariant, authRelay.decodeBase64(data.body.metadata.value));
|
|
1323
|
+
this.metadataVersion = data.body.metadata.version;
|
|
1324
|
+
const meta = this.metadata;
|
|
1325
|
+
if (meta?.lifecycleState === "archiveRequested" || meta?.lifecycleState === "archived") {
|
|
1326
|
+
if (this.ignoreArchiveSignal) {
|
|
1327
|
+
logger.logger.debug(`[SOCKET] Session archived (${meta.lifecycleState}) but suppressed for reconnect`);
|
|
1328
|
+
this.ignoreArchiveSignal = false;
|
|
1329
|
+
} else {
|
|
1330
|
+
logger.logger.debug(`[SOCKET] Session archived (${meta.lifecycleState}), exiting...`);
|
|
1331
|
+
this.emit("archived");
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
if (data.body.agentState && data.body.agentState.version > this.agentStateVersion) {
|
|
1336
|
+
this.agentState = data.body.agentState.value ? authRelay.decrypt(this.encryptionKey, this.encryptionVariant, authRelay.decodeBase64(data.body.agentState.value)) : null;
|
|
1337
|
+
this.agentStateVersion = data.body.agentState.version;
|
|
1338
|
+
}
|
|
1339
|
+
} else if (data.body.t === "update-machine") {
|
|
1340
|
+
logger.logger.debug(`[SOCKET] WARNING: Session client received unexpected machine update - ignoring`);
|
|
1341
|
+
} else {
|
|
1342
|
+
this.emit("message", data.body);
|
|
1343
|
+
}
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
logger.logger.debug("[SOCKET] [UPDATE] [ERROR] Error handling update", { error });
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
this.socket.on("error", (error) => {
|
|
1349
|
+
logger.logger.debug("[API] Socket error:", error);
|
|
1350
|
+
});
|
|
1351
|
+
this.socket.on("server-draining", (data) => {
|
|
1352
|
+
const parsed = index.ReleaseDrainNoticeSchema.safeParse(data);
|
|
1353
|
+
if (!parsed.success || parsed.data.deadline <= Date.now()) return;
|
|
1354
|
+
void this.startReleaseHandover(parsed.data);
|
|
1355
|
+
});
|
|
1356
|
+
this.socket.connect();
|
|
1357
|
+
}
|
|
1358
|
+
createControlSocket(handover) {
|
|
1359
|
+
return socket_ioClient.io(configuration.configuration.serverUrl, {
|
|
1360
|
+
auth: {
|
|
1361
|
+
token: this.token,
|
|
1362
|
+
clientType: "session-scoped",
|
|
1363
|
+
sessionId: this.sessionId,
|
|
1364
|
+
happyClient: `cli-coding-session/${configuration.configuration.currentCliVersion}`,
|
|
1365
|
+
...handover ? { handoverEpoch: handover.epoch } : {}
|
|
1366
|
+
},
|
|
1367
|
+
...handover ? { query: { vh_slot: handover.candidateSlot }, forceNew: true } : {},
|
|
1368
|
+
path: "/v1/updates",
|
|
1369
|
+
reconnection: false,
|
|
1370
|
+
transports: ["websocket"],
|
|
1371
|
+
withCredentials: true,
|
|
1372
|
+
autoConnect: false
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
startReleaseHandover(notice) {
|
|
1376
|
+
if (this.handoverInFlight) return this.handoverInFlight;
|
|
1377
|
+
this.handoverInFlight = this.releaseHandover(notice).finally(() => {
|
|
1378
|
+
this.handoverInFlight = null;
|
|
1379
|
+
});
|
|
1380
|
+
return this.handoverInFlight;
|
|
1381
|
+
}
|
|
1382
|
+
async releaseHandover(notice) {
|
|
1383
|
+
const startedAt = Date.now();
|
|
1384
|
+
const previous = this.socket;
|
|
1385
|
+
const candidate = this.createControlSocket(notice);
|
|
1386
|
+
const transferredListeners = /* @__PURE__ */ new Map();
|
|
1387
|
+
for (const listener of previous.listeners("rpc-request")) candidate.on("rpc-request", listener);
|
|
1388
|
+
const timeoutMs = Math.max(1, Math.min(1e4, notice.deadline - Date.now()));
|
|
1389
|
+
try {
|
|
1390
|
+
await new Promise((resolve, reject) => {
|
|
1391
|
+
const timer = setTimeout(() => reject(new Error("release handover timeout")), timeoutMs);
|
|
1392
|
+
candidate.once("connect", () => {
|
|
1393
|
+
clearTimeout(timer);
|
|
1394
|
+
resolve();
|
|
1395
|
+
});
|
|
1396
|
+
candidate.once("connect_error", (error) => {
|
|
1397
|
+
clearTimeout(timer);
|
|
1398
|
+
reject(error);
|
|
1399
|
+
});
|
|
1400
|
+
candidate.connect();
|
|
1401
|
+
});
|
|
1402
|
+
await this.rpcHandlerManager.onSocketConnectAndWait(candidate, timeoutMs);
|
|
1403
|
+
if (this.socket !== previous) {
|
|
1404
|
+
this.rpcHandlerManager.onSocketDisconnect(candidate);
|
|
1405
|
+
candidate.close();
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
for (const event of ["session-archive", "update", "error", "server-draining"]) {
|
|
1409
|
+
const listeners = previous.listeners(event);
|
|
1410
|
+
previous.removeAllListeners(event);
|
|
1411
|
+
for (const listener of listeners) candidate.on(event, listener);
|
|
1412
|
+
transferredListeners.set(event, listeners);
|
|
1413
|
+
}
|
|
1414
|
+
this.socket = candidate;
|
|
1415
|
+
candidate.on("connect", () => {
|
|
1416
|
+
if (this.reconnectInterval) {
|
|
1417
|
+
clearInterval(this.reconnectInterval);
|
|
1418
|
+
this.reconnectInterval = null;
|
|
1419
|
+
}
|
|
1420
|
+
this.rpcHandlerManager.onSocketConnect(candidate);
|
|
1421
|
+
this.receiveSync.invalidate();
|
|
1422
|
+
});
|
|
1423
|
+
candidate.on("disconnect", () => {
|
|
1424
|
+
this.rpcHandlerManager.onSocketDisconnect(candidate);
|
|
1425
|
+
this.startSmartReconnect();
|
|
1426
|
+
});
|
|
1427
|
+
candidate.on("connect_error", () => {
|
|
1428
|
+
this.rpcHandlerManager.onSocketDisconnect(candidate);
|
|
1429
|
+
this.startSmartReconnect();
|
|
1430
|
+
});
|
|
1431
|
+
this.receiveSync.invalidate();
|
|
1432
|
+
candidate.emit("release-handover-result", { result: "success", durationMs: Date.now() - startedAt });
|
|
1433
|
+
this.rpcHandlerManager.onSocketDisconnect(previous);
|
|
1434
|
+
previous.removeAllListeners("disconnect");
|
|
1435
|
+
previous.close();
|
|
1436
|
+
} catch (error) {
|
|
1437
|
+
if (this.socket === candidate) this.socket = previous;
|
|
1438
|
+
for (const [event, listeners] of transferredListeners) {
|
|
1439
|
+
candidate.removeAllListeners(event);
|
|
1440
|
+
for (const listener of listeners) previous.on(event, listener);
|
|
1441
|
+
}
|
|
1442
|
+
this.rpcHandlerManager.onSocketDisconnect(candidate);
|
|
1443
|
+
candidate.close();
|
|
1444
|
+
if (previous.connected) previous.emit("release-handover-result", { result: "failed", durationMs: Date.now() - startedAt });
|
|
1445
|
+
logger.logger.debug(`[API] Release handover failed: ${error instanceof Error ? error.message : error}`);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
onUserMessage(callback) {
|
|
1449
|
+
this.pendingMessageCallback = callback;
|
|
1450
|
+
while (this.pendingMessages.length > 0) {
|
|
1451
|
+
callback(this.pendingMessages.shift());
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
onFileEvent(callback) {
|
|
1455
|
+
this.pendingFileEventCallback = callback;
|
|
1456
|
+
while (this.pendingFileEvents.length > 0) {
|
|
1457
|
+
callback(this.pendingFileEvents.shift());
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* Derive (and cache) the blob decryption key for this session.
|
|
1462
|
+
* Legacy sessions use deriveKey(masterSecret, 'Happy Blobs', ['master']).
|
|
1463
|
+
* DataKey sessions use deriveKey(dataKey, 'Happy Blobs', ['session']).
|
|
1464
|
+
*/
|
|
1465
|
+
async getBlobKey() {
|
|
1466
|
+
if (!this.blobKey) {
|
|
1467
|
+
const path = this.encryptionVariant === "dataKey" ? ["session"] : ["master"];
|
|
1468
|
+
this.blobKey = await deriveKey(this.encryptionKey, "Happy Blobs", path);
|
|
1469
|
+
}
|
|
1470
|
+
return this.blobKey;
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Download an encrypted attachment blob via the request-download flow:
|
|
1474
|
+
* POST /request-download → { downloadUrl } → GET downloadUrl. Local mode
|
|
1475
|
+
* downloadUrl points back at our server (Bearer required); S3 mode is a
|
|
1476
|
+
* presigned URL that does not accept extra headers.
|
|
1477
|
+
*/
|
|
1478
|
+
async downloadAttachment(ref) {
|
|
1479
|
+
const requestUrl = `${configuration.configuration.serverUrl}/v1/sessions/${this.sessionId}/attachments/request-download`;
|
|
1480
|
+
const requestRes = await axios.post(
|
|
1481
|
+
requestUrl,
|
|
1482
|
+
{ ref },
|
|
1483
|
+
{
|
|
1484
|
+
headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" },
|
|
1485
|
+
timeout: 3e4
|
|
1486
|
+
}
|
|
1487
|
+
);
|
|
1488
|
+
const downloadUrl = requestRes.data?.downloadUrl;
|
|
1489
|
+
if (typeof downloadUrl !== "string") {
|
|
1490
|
+
throw new Error("request-download returned no downloadUrl");
|
|
1491
|
+
}
|
|
1492
|
+
const isServerUrl = downloadUrl.startsWith(configuration.configuration.serverUrl);
|
|
1493
|
+
const headers = {};
|
|
1494
|
+
if (isServerUrl) {
|
|
1495
|
+
headers["Authorization"] = `Bearer ${this.token}`;
|
|
1496
|
+
}
|
|
1497
|
+
const response = await axios.get(downloadUrl, {
|
|
1498
|
+
headers,
|
|
1499
|
+
responseType: "arraybuffer",
|
|
1500
|
+
timeout: 6e4,
|
|
1501
|
+
maxRedirects: 5,
|
|
1502
|
+
maxContentLength: attachmentLimits.MAX_CHAT_ATTACHMENT_ENCRYPTED_BYTES,
|
|
1503
|
+
maxBodyLength: attachmentLimits.MAX_CHAT_ATTACHMENT_ENCRYPTED_BYTES
|
|
1504
|
+
});
|
|
1505
|
+
return new Uint8Array(response.data);
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Download and decrypt an attachment blob.
|
|
1509
|
+
* Returns the decrypted binary data or null if decryption fails.
|
|
1510
|
+
*/
|
|
1511
|
+
async downloadAndDecryptAttachment(ref) {
|
|
1512
|
+
const encrypted = await this.downloadAttachment(ref);
|
|
1513
|
+
const key = await this.getBlobKey();
|
|
1514
|
+
const decrypted = authRelay.decryptBlob(encrypted, key);
|
|
1515
|
+
return decrypted;
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* Track an attachment download whose promise resolves to the decoded blob
|
|
1519
|
+
* (or null on failure). The download stays in the current batch until the
|
|
1520
|
+
* next drainAttachmentsForUserMessage call swaps the bucket out — file
|
|
1521
|
+
* events that arrive after the swap go into a fresh bucket bound to the
|
|
1522
|
+
* next user-text message.
|
|
1523
|
+
*/
|
|
1524
|
+
trackAttachmentDownload(promise) {
|
|
1525
|
+
this.pendingDownloads.push(promise);
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* Atomically claim every download started before this call, wait for them
|
|
1529
|
+
* to resolve, and return the successful ones. The swap-then-await order
|
|
1530
|
+
* guarantees that a late-arriving file event cannot leak into this batch.
|
|
1531
|
+
*/
|
|
1532
|
+
async drainAttachmentsForUserMessage() {
|
|
1533
|
+
const downloads = this.pendingDownloads;
|
|
1534
|
+
this.pendingDownloads = [];
|
|
1535
|
+
if (downloads.length === 0) return [];
|
|
1536
|
+
const results = await Promise.all(downloads);
|
|
1537
|
+
return results.filter((x) => x !== null);
|
|
1538
|
+
}
|
|
1539
|
+
authHeaders() {
|
|
1540
|
+
return {
|
|
1541
|
+
"Authorization": `Bearer ${this.token}`,
|
|
1542
|
+
"Content-Type": "application/json",
|
|
1543
|
+
"X-Happy-Client": `cli-coding-session/${configuration.configuration.currentCliVersion}`
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
/**
|
|
1547
|
+
* B-509: attach the server-side prompt queue drain. `isIdle` is the
|
|
1548
|
+
* runner's own notion (input queue empty + blocked waiting); dispatching is
|
|
1549
|
+
* `POST /v1/sessions/:id/prompt-queue/dispatch`, whose message then comes
|
|
1550
|
+
* back through the ordinary socket / catch-up path and is routed below.
|
|
1551
|
+
*/
|
|
1552
|
+
attachPromptQueueDrain(opts) {
|
|
1553
|
+
this.promptQueueDrain?.close();
|
|
1554
|
+
const drain = new PromptQueueDrain({
|
|
1555
|
+
isIdle: opts.isIdle,
|
|
1556
|
+
dispatch: () => this.dispatchPromptQueueHead(),
|
|
1557
|
+
log: (message) => logger.logger.debug(`[API] ${message}`)
|
|
1558
|
+
});
|
|
1559
|
+
this.promptQueueDrain = drain;
|
|
1560
|
+
return drain;
|
|
1561
|
+
}
|
|
1562
|
+
/** B-509: the server still holds queued prompts for this session (see PromptQueueDrain.hasPending). */
|
|
1563
|
+
promptQueueHasPending() {
|
|
1564
|
+
return this.promptQueueDrain?.hasPending() === true;
|
|
1565
|
+
}
|
|
1566
|
+
/** B-509: stop draining before the process winds down (cleanup's first line). */
|
|
1567
|
+
closePromptQueueDrain() {
|
|
1568
|
+
this.promptQueueDrain?.close();
|
|
1569
|
+
}
|
|
1570
|
+
async dispatchPromptQueueHead() {
|
|
1571
|
+
try {
|
|
1572
|
+
const response = await axios.post(
|
|
1573
|
+
`${configuration.configuration.serverUrl}/v1/sessions/${encodeURIComponent(this.sessionId)}/prompt-queue/dispatch`,
|
|
1574
|
+
{},
|
|
1575
|
+
{ headers: this.authHeaders(), timeout: 3e4, validateStatus: () => true }
|
|
1576
|
+
);
|
|
1577
|
+
if (response.status === 404) return { kind: "unsupported" };
|
|
1578
|
+
if (response.status !== 200) return { kind: "error", message: `HTTP ${response.status}` };
|
|
1579
|
+
const dispatched = response.data?.dispatched;
|
|
1580
|
+
const remaining = typeof response.data?.remaining === "number" ? response.data.remaining : void 0;
|
|
1581
|
+
if (dispatched && typeof dispatched.localId === "string") {
|
|
1582
|
+
return { kind: "dispatched", localId: dispatched.localId, remaining, created: dispatched.created !== false };
|
|
1583
|
+
}
|
|
1584
|
+
return { kind: "empty" };
|
|
1585
|
+
} catch (error) {
|
|
1586
|
+
return { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
routeIncomingMessage(message, sourceLocalId) {
|
|
1590
|
+
if (sourceLocalId) this.promptQueueDrain?.onInbound(sourceLocalId);
|
|
1591
|
+
const messageWithSource = sourceLocalId && typeof message === "object" && message !== null ? { ...message, localKey: message.localKey ?? sourceLocalId } : message;
|
|
1592
|
+
const userResult = attachmentLimits.UserMessageSchema.safeParse(messageWithSource);
|
|
1593
|
+
if (userResult.success) {
|
|
1594
|
+
if (this.pendingMessageCallback) {
|
|
1595
|
+
this.pendingMessageCallback(userResult.data);
|
|
1596
|
+
} else {
|
|
1597
|
+
this.pendingMessages.push(userResult.data);
|
|
1598
|
+
}
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
const fileResult = attachmentLimits.FileEventMessageSchema.safeParse(message);
|
|
1602
|
+
if (fileResult.success) {
|
|
1603
|
+
logger.logger.debug(`[API] Received file event: ${fileResult.data.content.data.ev.name} (ref: ${fileResult.data.content.data.ev.ref})`);
|
|
1604
|
+
if (this.pendingFileEventCallback) {
|
|
1605
|
+
this.pendingFileEventCallback(fileResult.data);
|
|
1606
|
+
} else {
|
|
1607
|
+
this.pendingFileEvents.push(fileResult.data);
|
|
1608
|
+
}
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
const role = typeof message === "object" && message !== null ? message.role : void 0;
|
|
1612
|
+
if (role === "user") {
|
|
1613
|
+
logger.logger.debug("[API] Inbound user-role message did not match UserMessageSchema; forwarding as generic message", {
|
|
1614
|
+
sourceLocalId: sourceLocalId ?? null,
|
|
1615
|
+
issues: userResult.error.issues.slice(0, 5).map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
this.emit("message", message);
|
|
1619
|
+
}
|
|
1620
|
+
async fetchMessages() {
|
|
1621
|
+
const skipRouting = this.skipInitialMessages;
|
|
1622
|
+
if (skipRouting) {
|
|
1623
|
+
this.skipInitialMessages = false;
|
|
1624
|
+
logger.logger.debug("[API] Reconnect mode: skipping existing messages, advancing lastSeq");
|
|
1625
|
+
}
|
|
1626
|
+
let afterSeq = this.lastSeq;
|
|
1627
|
+
while (true) {
|
|
1628
|
+
const response = await axios.get(
|
|
1629
|
+
`${configuration.configuration.serverUrl}/v3/sessions/${encodeURIComponent(this.sessionId)}/messages`,
|
|
1630
|
+
{
|
|
1631
|
+
params: {
|
|
1632
|
+
after_seq: afterSeq,
|
|
1633
|
+
limit: 100
|
|
1634
|
+
},
|
|
1635
|
+
headers: this.authHeaders(),
|
|
1636
|
+
timeout: 6e4
|
|
1637
|
+
}
|
|
1638
|
+
);
|
|
1639
|
+
const messages = Array.isArray(response.data.messages) ? response.data.messages : [];
|
|
1640
|
+
let maxSeq = afterSeq;
|
|
1641
|
+
for (const message of messages) {
|
|
1642
|
+
if (message.seq > maxSeq) {
|
|
1643
|
+
maxSeq = message.seq;
|
|
1644
|
+
}
|
|
1645
|
+
if (skipRouting) continue;
|
|
1646
|
+
const relayAccepted = !!message.localId && this.directInboundLocalIds.delete(message.localId);
|
|
1647
|
+
if (message.localId && this.routedInboundLocalIds.has(message.localId)) {
|
|
1648
|
+
continue;
|
|
1649
|
+
}
|
|
1650
|
+
if (relayAccepted) {
|
|
1651
|
+
logger.logger.debug(`[API] Routing relay-accepted message ${message.localId} from catch-up fetch (own persist still pending or failed)`);
|
|
1652
|
+
}
|
|
1653
|
+
if (message.content?.t !== "encrypted") {
|
|
1654
|
+
continue;
|
|
1655
|
+
}
|
|
1656
|
+
try {
|
|
1657
|
+
const body = authRelay.decrypt(this.encryptionKey, this.encryptionVariant, authRelay.decodeBase64(message.content.c));
|
|
1658
|
+
this.routeIncomingMessage(body, message.localId);
|
|
1659
|
+
if (message.localId) this.rememberRoutedInbound(message.localId);
|
|
1660
|
+
} catch (error) {
|
|
1661
|
+
logger.logger.debug("[API] Failed to decrypt fetched message", {
|
|
1662
|
+
sessionId: this.sessionId,
|
|
1663
|
+
seq: message.seq,
|
|
1664
|
+
error
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
this.lastSeq = Math.max(this.lastSeq, maxSeq);
|
|
1669
|
+
const hasMore = !!response.data.hasMore;
|
|
1670
|
+
if (hasMore && maxSeq === afterSeq) {
|
|
1671
|
+
logger.logger.debug("[API] fetchMessages pagination stalled, stopping to avoid infinite loop", {
|
|
1672
|
+
sessionId: this.sessionId,
|
|
1673
|
+
afterSeq
|
|
1674
|
+
});
|
|
1675
|
+
break;
|
|
1676
|
+
}
|
|
1677
|
+
afterSeq = maxSeq;
|
|
1678
|
+
if (!hasMore) {
|
|
1679
|
+
break;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
this.awaitingInitialFetch = false;
|
|
1683
|
+
}
|
|
1684
|
+
static MAX_OUTBOX_BATCH_SIZE = 50;
|
|
1685
|
+
async flushOutbox() {
|
|
1686
|
+
while (this.pendingOutbox.length > 0) {
|
|
1687
|
+
const batchSize = Math.min(this.pendingOutbox.length, ApiSessionClient.MAX_OUTBOX_BATCH_SIZE);
|
|
1688
|
+
const batch = this.pendingOutbox.slice(0, batchSize);
|
|
1689
|
+
const response = await axios.post(
|
|
1690
|
+
`${configuration.configuration.serverUrl}/v3/sessions/${encodeURIComponent(this.sessionId)}/messages`,
|
|
1691
|
+
{
|
|
1692
|
+
messages: batch
|
|
1693
|
+
},
|
|
1694
|
+
{
|
|
1695
|
+
headers: this.authHeaders(),
|
|
1696
|
+
timeout: 6e4
|
|
1697
|
+
}
|
|
1698
|
+
);
|
|
1699
|
+
const messages = Array.isArray(response.data.messages) ? response.data.messages : [];
|
|
1700
|
+
const maxSeq = messages.reduce((acc, message) => message.seq > acc ? message.seq : acc, this.lastSeq);
|
|
1701
|
+
this.lastSeq = maxSeq;
|
|
1702
|
+
this.pushCommittedMessagesToRelay(batch, messages);
|
|
1703
|
+
this.pendingOutbox.splice(0, batch.length);
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
rememberRoutedInbound(localId) {
|
|
1707
|
+
this.routedInboundLocalIds.add(localId);
|
|
1708
|
+
if (this.routedInboundLocalIds.size > 2e3) {
|
|
1709
|
+
const oldest = this.routedInboundLocalIds.values().next().value;
|
|
1710
|
+
if (oldest) this.routedInboundLocalIds.delete(oldest);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
rememberDirectInbound(localId) {
|
|
1714
|
+
this.directInboundLocalIds.add(localId);
|
|
1715
|
+
if (this.directInboundLocalIds.size > 2e3) {
|
|
1716
|
+
const oldest = this.directInboundLocalIds.values().next().value;
|
|
1717
|
+
if (oldest) this.directInboundLocalIds.delete(oldest);
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
async connectSessionRelay() {
|
|
1721
|
+
const machineId = this.metadata?.machineId;
|
|
1722
|
+
if (!machineId || this.relaySocket?.connected) return;
|
|
1723
|
+
try {
|
|
1724
|
+
const response = await fetch(
|
|
1725
|
+
`${configuration.configuration.serverUrl}/v1/relays/sessions/${encodeURIComponent(this.sessionId)}/machines/${encodeURIComponent(machineId)}`,
|
|
1726
|
+
{ headers: this.authHeaders(), cache: "no-store" }
|
|
1727
|
+
);
|
|
1728
|
+
if (!response.ok) return;
|
|
1729
|
+
const assignment = index.RelayAssignmentResponseSchema.parse(await response.json()).assignment;
|
|
1730
|
+
if (!assignment) return;
|
|
1731
|
+
const relaySocket = socket_ioClient.io(assignment.url, {
|
|
1732
|
+
path: "/v1/relay",
|
|
1733
|
+
auth: { token: assignment.token },
|
|
1734
|
+
transports: ["websocket"],
|
|
1735
|
+
reconnection: true,
|
|
1736
|
+
reconnectionDelay: 500,
|
|
1737
|
+
reconnectionDelayMax: 5e3
|
|
1738
|
+
});
|
|
1739
|
+
this.relaySocket?.close();
|
|
1740
|
+
this.relaySocket = relaySocket;
|
|
1741
|
+
relaySocket.on("connect", () => {
|
|
1742
|
+
if (this.relaySocket !== relaySocket) return;
|
|
1743
|
+
this.rpcHandlerManager.onSocketConnect(relaySocket);
|
|
1744
|
+
logger.logger.debug(`[API] Session connected to regional relay ${assignment.relayId}`);
|
|
1745
|
+
});
|
|
1746
|
+
relaySocket.on("rpc-request", async (data, callback) => {
|
|
1747
|
+
await lidState.answerSocketRequest(
|
|
1748
|
+
callback,
|
|
1749
|
+
() => this.rpcHandlerManager.handleRequest(data),
|
|
1750
|
+
(drop) => logger.logger.debug(`[API] Relay RPC ${data?.method} unanswered \u2014 ${lidState.describeSocketAckDrop(drop)}`)
|
|
1751
|
+
);
|
|
1752
|
+
});
|
|
1753
|
+
relaySocket.on("session-message-deliver", async (data, rawCallback) => {
|
|
1754
|
+
const callback = lidState.safeAck(rawCallback, (drop) => logger.logger.debug(`[API] session-message-deliver unanswered \u2014 ${lidState.describeSocketAckDrop(drop)}`));
|
|
1755
|
+
if (data?.sessionId !== this.sessionId || !Array.isArray(data.messages) || data.messages.length === 0 || data.messages.length > 50) {
|
|
1756
|
+
callback({ ok: false, error: "Invalid session message request" });
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
const unique = /* @__PURE__ */ new Map();
|
|
1760
|
+
for (const item of data.messages) {
|
|
1761
|
+
if (typeof item?.localId !== "string" || !item.localId || typeof item.content !== "string") {
|
|
1762
|
+
callback({ ok: false, error: "Invalid session message request" });
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
if (!unique.has(item.localId)) unique.set(item.localId, { localId: item.localId, content: item.content });
|
|
1766
|
+
}
|
|
1767
|
+
const batch = Array.from(unique.values());
|
|
1768
|
+
for (const item of batch) this.rememberDirectInbound(item.localId);
|
|
1769
|
+
try {
|
|
1770
|
+
const persisted = await axios.post(
|
|
1771
|
+
`${configuration.configuration.serverUrl}/v3/sessions/${encodeURIComponent(this.sessionId)}/messages`,
|
|
1772
|
+
{ messages: batch },
|
|
1773
|
+
{ headers: { ...this.authHeaders(), "X-Happy-Message-Origin": "relay-client" }, timeout: 6e4 }
|
|
1774
|
+
);
|
|
1775
|
+
const stored = Array.isArray(persisted.data.messages) ? persisted.data.messages : [];
|
|
1776
|
+
const storedLocalIds = new Set(stored.map((message) => message.localId));
|
|
1777
|
+
if (batch.some((item) => !storedLocalIds.has(item.localId))) {
|
|
1778
|
+
throw new Error("Central persistence response omitted a session message");
|
|
1779
|
+
}
|
|
1780
|
+
this.lastSeq = stored.reduce((max, message) => Math.max(max, message.seq), this.lastSeq);
|
|
1781
|
+
for (const item of batch) {
|
|
1782
|
+
if (this.routedInboundLocalIds.has(item.localId)) continue;
|
|
1783
|
+
const body = authRelay.decrypt(this.encryptionKey, this.encryptionVariant, authRelay.decodeBase64(item.content));
|
|
1784
|
+
this.routeIncomingMessage(body, item.localId);
|
|
1785
|
+
this.rememberRoutedInbound(item.localId);
|
|
1786
|
+
}
|
|
1787
|
+
callback({ ok: true, messages: stored });
|
|
1788
|
+
} catch (error) {
|
|
1789
|
+
const routed = batch.filter((item) => this.routedInboundLocalIds.has(item.localId)).length;
|
|
1790
|
+
logger.logger.debug(`[API] Relay-delivered persist failed for ${batch.length} message(s) (${routed} already routed via central echo): ${error instanceof Error ? error.message : error}`);
|
|
1791
|
+
for (const item of batch) this.directInboundLocalIds.delete(item.localId);
|
|
1792
|
+
callback({ ok: false, error: error instanceof Error ? error.message : "Failed to persist session messages" });
|
|
1793
|
+
}
|
|
1794
|
+
});
|
|
1795
|
+
relaySocket.on("disconnect", () => {
|
|
1796
|
+
if (this.relaySocket === relaySocket) this.rpcHandlerManager.onSocketDisconnect(relaySocket);
|
|
1797
|
+
});
|
|
1798
|
+
} catch (error) {
|
|
1799
|
+
logger.logger.debug(`[API] Session regional relay unavailable: ${error instanceof Error ? error.message : error}`);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
pushCommittedMessagesToRelay(batch, messages) {
|
|
1803
|
+
if (!this.relaySocket?.connected || messages.length === 0) return;
|
|
1804
|
+
const contentByLocalId = new Map(batch.map((item) => [item.localId, item.content]));
|
|
1805
|
+
const committed = messages.flatMap((message) => {
|
|
1806
|
+
if (!message.localId) return [];
|
|
1807
|
+
const content = contentByLocalId.get(message.localId);
|
|
1808
|
+
if (!content) return [];
|
|
1809
|
+
return [{ ...message, content }];
|
|
1810
|
+
});
|
|
1811
|
+
if (committed.length > 0) {
|
|
1812
|
+
this.relaySocket.emit("session-message-committed", {
|
|
1813
|
+
sessionId: this.sessionId,
|
|
1814
|
+
messages: committed
|
|
1815
|
+
});
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
enqueueMessage(content, invalidate = true, localId) {
|
|
1819
|
+
const encrypted = authRelay.encodeBase64(authRelay.encrypt(this.encryptionKey, this.encryptionVariant, content));
|
|
1820
|
+
this.pendingOutbox.push({
|
|
1821
|
+
content: encrypted,
|
|
1822
|
+
// Deterministic localIds (terminal mirror, B-105) ride the server's
|
|
1823
|
+
// @@unique([sessionId, localId]) for replay idempotency; everything
|
|
1824
|
+
// else keeps the fire-once random id.
|
|
1825
|
+
localId: localId ?? node_crypto.randomUUID()
|
|
1826
|
+
});
|
|
1827
|
+
if (invalidate) {
|
|
1828
|
+
this.sendSync.invalidate();
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
/** B-332: emit a queue-cancel tombstone, mirroring the web's own signal. */
|
|
1832
|
+
sendQueueCancelReason(localKeys, reason) {
|
|
1833
|
+
if (localKeys.length === 0) return;
|
|
1834
|
+
const envelope = index.createEnvelope("user", { t: "queue-cancel", targetLocalKeys: localKeys, reason });
|
|
1835
|
+
this.sendSessionProtocolMessage(envelope);
|
|
1836
|
+
}
|
|
1837
|
+
/**
|
|
1838
|
+
* B-332 site ③: find queued user input this wrapper destroyed (it was still
|
|
1839
|
+
* in the previous wrapper's in-memory queue when it died) and tell the web.
|
|
1840
|
+
*
|
|
1841
|
+
* Walks NEWEST → OLDEST via `before_seq`, decrypting each page, and stops at
|
|
1842
|
+
* the first turn-end. The runner calls this on EVERY reconnect (runClaude /
|
|
1843
|
+
* runCodex), seeded or not: a cursor seeded at the server's latest seq skips
|
|
1844
|
+
* the undelivered tail just as thoroughly as skipExistingMessages does —
|
|
1845
|
+
* and the daemon's resume/restart always seeds, so gating on the skip path
|
|
1846
|
+
* would miss the common case. Best-effort: a failed scan must not take a
|
|
1847
|
+
* session down. Needs no socket; POSTs through the same session-message
|
|
1848
|
+
* channel so the web receives it identically to a cancel-button tombstone.
|
|
1849
|
+
*/
|
|
1850
|
+
async cancelUndeliveredQueuedInputs() {
|
|
1851
|
+
const MAX_SCAN_PAGES = 10;
|
|
1852
|
+
const canceled = /* @__PURE__ */ new Set();
|
|
1853
|
+
const undelivered = [];
|
|
1854
|
+
let beforeSeq = 2147483647;
|
|
1855
|
+
let reachedBoundary = false;
|
|
1856
|
+
let pages = 0;
|
|
1857
|
+
try {
|
|
1858
|
+
while (!reachedBoundary && pages < MAX_SCAN_PAGES) {
|
|
1859
|
+
pages += 1;
|
|
1860
|
+
const response = await axios.get(
|
|
1861
|
+
`${configuration.configuration.serverUrl}/v3/sessions/${encodeURIComponent(this.sessionId)}/messages`,
|
|
1862
|
+
{
|
|
1863
|
+
params: { before_seq: beforeSeq, limit: 100 },
|
|
1864
|
+
headers: this.authHeaders(),
|
|
1865
|
+
timeout: 6e4
|
|
1866
|
+
}
|
|
1867
|
+
);
|
|
1868
|
+
const messages = Array.isArray(response.data.messages) ? response.data.messages : [];
|
|
1869
|
+
if (messages.length === 0) break;
|
|
1870
|
+
const records = messages.map((message) => {
|
|
1871
|
+
let body = null;
|
|
1872
|
+
try {
|
|
1873
|
+
body = message.content?.t === "encrypted" ? authRelay.decrypt(this.encryptionKey, this.encryptionVariant, authRelay.decodeBase64(message.content.c)) : null;
|
|
1874
|
+
} catch (error) {
|
|
1875
|
+
logger.logger.debug("[API] Failed to decrypt during undelivered scan:", error);
|
|
1876
|
+
}
|
|
1877
|
+
return { seq: message.seq, localId: message.localId, body };
|
|
1878
|
+
});
|
|
1879
|
+
const scan = scanUndeliveredQueuedInputs(records, { canceled });
|
|
1880
|
+
for (const key of scan.canceled) canceled.add(key);
|
|
1881
|
+
undelivered.push(...scan.undeliveredLocalKeys);
|
|
1882
|
+
reachedBoundary = scan.reachedBoundary;
|
|
1883
|
+
const oldest = records.at(-1)?.seq;
|
|
1884
|
+
if (oldest === void 0 || messages.length < 100) break;
|
|
1885
|
+
beforeSeq = oldest;
|
|
1886
|
+
}
|
|
1887
|
+
} catch (error) {
|
|
1888
|
+
logger.logger.debug("[API] cancelUndeliveredQueuedInputs scan failed:", error);
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
if (!reachedBoundary) {
|
|
1892
|
+
logger.logger.debug("[API] cancelUndeliveredQueuedInputs found no turn boundary; leaving messages as-is");
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
undelivered.reverse();
|
|
1896
|
+
this.sendQueueCancelReason(undelivered, "restarted");
|
|
1897
|
+
}
|
|
1898
|
+
/**
|
|
1899
|
+
* Send a message to session
|
|
1900
|
+
* @param body - Message body (can be MessageContent or raw content for agent messages)
|
|
1901
|
+
*/
|
|
1902
|
+
sendClaudeSessionMessage(body, opts) {
|
|
1903
|
+
this.emit("claude-session-message", body);
|
|
1904
|
+
const mapped = mapClaudeLogMessageToSessionEnvelopes(body, this.claudeSessionProtocolState);
|
|
1905
|
+
this.claudeSessionProtocolState.currentTurnId = mapped.currentTurnId;
|
|
1906
|
+
for (const [envelopeIndex, envelope] of mapped.envelopes.entries()) {
|
|
1907
|
+
this.sendSessionProtocolMessage(envelope, opts?.localIdFor?.(envelopeIndex));
|
|
1908
|
+
}
|
|
1909
|
+
if (body.type === "assistant" && body.message?.usage) {
|
|
1910
|
+
try {
|
|
1911
|
+
const eventId = typeof body.uuid === "string" ? body.uuid : typeof body.message.id === "string" ? body.message.id : void 0;
|
|
1912
|
+
this.sendUsageData(body.message.usage, body.message.model, eventId);
|
|
1913
|
+
} catch (error) {
|
|
1914
|
+
logger.logger.debug("[SOCKET] Failed to send usage data:", error);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
if (body.type === "summary" && "summary" in body && "leafUuid" in body) {
|
|
1918
|
+
this.updateMetadata((metadata) => ({
|
|
1919
|
+
...metadata,
|
|
1920
|
+
summary: {
|
|
1921
|
+
text: body.summary,
|
|
1922
|
+
updatedAt: Date.now()
|
|
1923
|
+
}
|
|
1924
|
+
}));
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
closeClaudeSessionTurn(status = "completed", meta) {
|
|
1928
|
+
this.emit("turn-ended", status);
|
|
1929
|
+
const mapped = closeClaudeTurnWithStatus(this.claudeSessionProtocolState, status, meta);
|
|
1930
|
+
this.claudeSessionProtocolState.currentTurnId = mapped.currentTurnId;
|
|
1931
|
+
for (const envelope of mapped.envelopes) {
|
|
1932
|
+
this.sendSessionProtocolMessage(envelope);
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
sendCodexMessage(body) {
|
|
1936
|
+
let content = {
|
|
1937
|
+
role: "agent",
|
|
1938
|
+
content: {
|
|
1939
|
+
type: "codex",
|
|
1940
|
+
data: body
|
|
1941
|
+
// This wraps the entire Claude message
|
|
1942
|
+
},
|
|
1943
|
+
meta: {
|
|
1944
|
+
sentFrom: "cli"
|
|
1945
|
+
}
|
|
1946
|
+
};
|
|
1947
|
+
this.enqueueMessage(content);
|
|
1948
|
+
}
|
|
1949
|
+
enqueueSessionProtocolEnvelope(envelope, invalidate = true, localId) {
|
|
1950
|
+
const content = {
|
|
1951
|
+
role: "session",
|
|
1952
|
+
content: envelope,
|
|
1953
|
+
meta: {
|
|
1954
|
+
sentFrom: "cli"
|
|
1955
|
+
}
|
|
1956
|
+
};
|
|
1957
|
+
this.enqueueMessage(content, invalidate, localId);
|
|
1958
|
+
}
|
|
1959
|
+
sendSessionProtocolMessage(envelope, localId) {
|
|
1960
|
+
this.enqueueSessionProtocolEnvelope(envelope, true, localId);
|
|
1961
|
+
}
|
|
1962
|
+
/**
|
|
1963
|
+
* Send a generic agent message to the session using ACP (Agent Communication Protocol) format.
|
|
1964
|
+
* Works for any agent type (Gemini, Codex, Claude, etc.) - CLI normalizes to unified ACP format.
|
|
1965
|
+
*
|
|
1966
|
+
* @param provider - The agent provider sending the message (e.g., 'gemini', 'codex', 'claude')
|
|
1967
|
+
* @param body - The message payload (type: 'message' | 'reasoning' | 'tool-call' | 'tool-result')
|
|
1968
|
+
*/
|
|
1969
|
+
sendAgentMessage(provider, body) {
|
|
1970
|
+
let content = {
|
|
1971
|
+
role: "agent",
|
|
1972
|
+
content: {
|
|
1973
|
+
type: "acp",
|
|
1974
|
+
provider,
|
|
1975
|
+
data: body
|
|
1976
|
+
},
|
|
1977
|
+
meta: {
|
|
1978
|
+
sentFrom: "cli"
|
|
1979
|
+
}
|
|
1980
|
+
};
|
|
1981
|
+
logger.logger.debug(`[SOCKET] Sending ACP message from ${provider}:`, { type: body.type, hasMessage: "message" in body });
|
|
1982
|
+
this.enqueueMessage(content);
|
|
1983
|
+
}
|
|
1984
|
+
sendSessionEvent(event, id) {
|
|
1985
|
+
let content = {
|
|
1986
|
+
role: "agent",
|
|
1987
|
+
content: {
|
|
1988
|
+
id: id ?? node_crypto.randomUUID(),
|
|
1989
|
+
type: "event",
|
|
1990
|
+
data: event
|
|
1991
|
+
}
|
|
1992
|
+
};
|
|
1993
|
+
this.enqueueMessage(content);
|
|
1994
|
+
}
|
|
1995
|
+
/**
|
|
1996
|
+
* Send a ping message to keep the connection alive
|
|
1997
|
+
*/
|
|
1998
|
+
/** B-466: turn edges (and a periodic lease) reported to the local daemon so
|
|
1999
|
+
* auto-update waits for "no turn in flight" instead of "no sessions". */
|
|
2000
|
+
turnReporter = new TurnReporter((event) => void controlClient.notifyDaemonTurnEvent(this.sessionId, event).catch((error) => logger.logger.debug("[API] Failed to report turn event to daemon:", error)));
|
|
2001
|
+
/** B-507: background tasks in flight, published on change and renewed every
|
|
2002
|
+
* 60s to the daemon (`/session-event background_tasks`) and into agentState
|
|
2003
|
+
* (`backgroundTasks: { updatedAt, tasks }`) — the two readers that outlive a
|
|
2004
|
+
* single heartbeat. The heartbeat itself carries only the count. */
|
|
2005
|
+
backgroundTaskReporter = new backgroundTaskActivity.BackgroundTaskReporter((tasks, now) => {
|
|
2006
|
+
void controlClient.notifyDaemonBackgroundTasks(this.sessionId, tasks).catch((error) => logger.logger.debug("[API] Failed to report background tasks to daemon:", error));
|
|
2007
|
+
this.updateAgentState((state) => ({
|
|
2008
|
+
...state,
|
|
2009
|
+
backgroundTasks: tasks.length > 0 ? { updatedAt: now, tasks } : void 0
|
|
2010
|
+
}));
|
|
2011
|
+
});
|
|
2012
|
+
keepAlive(thinking, mode, backgroundTasks) {
|
|
2013
|
+
if (process.env.DEBUG) {
|
|
2014
|
+
logger.logger.debug(`[API] Sending keep alive message: ${thinking}`);
|
|
2015
|
+
}
|
|
2016
|
+
this.turnReporter.observe(thinking);
|
|
2017
|
+
if (backgroundTasks) this.backgroundTaskReporter.observe(backgroundTasks);
|
|
2018
|
+
this.socket.volatile.emit("session-alive", {
|
|
2019
|
+
sid: this.sessionId,
|
|
2020
|
+
time: Date.now(),
|
|
2021
|
+
thinking,
|
|
2022
|
+
mode,
|
|
2023
|
+
...backgroundTasks ? { backgroundTasks: backgroundTasks.length } : {}
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
/**
|
|
2027
|
+
* B-309: push one live stream frame (thinking/text delta or progress) to
|
|
2028
|
+
* this account's web clients.
|
|
2029
|
+
*
|
|
2030
|
+
* Encrypted with the SESSION key and relayed without being stored —
|
|
2031
|
+
* clipboard-push's shape, for clipboard-push's reasons: the relay must not
|
|
2032
|
+
* be able to read thinking text, and the session id is stamped by the
|
|
2033
|
+
* server from the authenticated connection rather than trusted from here.
|
|
2034
|
+
*
|
|
2035
|
+
* `volatile` on purpose. A draft that misses the wire while disconnected
|
|
2036
|
+
* is worth nothing — the persisted message is still coming, and replaying
|
|
2037
|
+
* stale deltas after a reconnect would paint text that has already landed.
|
|
2038
|
+
*/
|
|
2039
|
+
sendStreamFrame(frame) {
|
|
2040
|
+
if (!this.socket.connected) return;
|
|
2041
|
+
try {
|
|
2042
|
+
this.socket.volatile.emit("session-stream", {
|
|
2043
|
+
// The frame object, NOT a JSON string: `encrypt` serializes
|
|
2044
|
+
// internally, so pre-stringifying would double-encode and
|
|
2045
|
+
// escape every quote in the streamed text.
|
|
2046
|
+
payload: authRelay.encodeBase64(authRelay.encrypt(this.encryptionKey, this.encryptionVariant, frame)),
|
|
2047
|
+
enc: true
|
|
2048
|
+
});
|
|
2049
|
+
} catch (error) {
|
|
2050
|
+
logger.logger.debug(`[API] Failed to relay stream frame: ${error}`);
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
/**
|
|
2054
|
+
* Send session death message
|
|
2055
|
+
*/
|
|
2056
|
+
sendSessionDeath() {
|
|
2057
|
+
this.socket.emit("session-end", { sid: this.sessionId, time: Date.now() });
|
|
2058
|
+
}
|
|
2059
|
+
/**
|
|
2060
|
+
* Push text to the clipboard of every web client the user has open.
|
|
2061
|
+
* Encrypted with the SESSION key (same primitive the message stream uses,
|
|
2062
|
+
* proven to interop with the web's SessionEncryption.decryptRaw); the
|
|
2063
|
+
* server relays it to the user's web clients without reading it.
|
|
2064
|
+
* Returns delivery info for the MCP tool to report back to the model.
|
|
2065
|
+
*/
|
|
2066
|
+
pushClipboard(text) {
|
|
2067
|
+
const prepared = limits.prepareClipboardText(text);
|
|
2068
|
+
const history = limits.prepareClipboardHistoryText(text);
|
|
2069
|
+
if (!this.socket.connected) {
|
|
2070
|
+
return { delivered: false, truncated: prepared.truncated, totalBytes: prepared.totalBytes };
|
|
2071
|
+
}
|
|
2072
|
+
this.socket.emit("clipboard-push", {
|
|
2073
|
+
payload: authRelay.encodeBase64(authRelay.encrypt(this.encryptionKey, this.encryptionVariant, prepared.text)),
|
|
2074
|
+
enc: true,
|
|
2075
|
+
historyPayload: authRelay.encodeBase64(authRelay.encrypt(this.encryptionKey, this.encryptionVariant, history.text)),
|
|
2076
|
+
historyTruncated: history.truncated,
|
|
2077
|
+
truncated: prepared.truncated,
|
|
2078
|
+
totalBytes: prepared.totalBytes
|
|
2079
|
+
});
|
|
2080
|
+
return { delivered: true, truncated: prepared.truncated, totalBytes: prepared.totalBytes };
|
|
2081
|
+
}
|
|
2082
|
+
/**
|
|
2083
|
+
* B-131: ask every web client the user has open to preview a file.
|
|
2084
|
+
*
|
|
2085
|
+
* Only the PATH travels — encrypted with the session key exactly like
|
|
2086
|
+
* clipboard-push. The web client then reads the file over the existing
|
|
2087
|
+
* machine-level fs-read RPC. Two reasons: it adds no new file access
|
|
2088
|
+
* (fs-read is already exposed) and it keeps the relay payload tiny, so
|
|
2089
|
+
* large files / images / PDFs ride the existing chunked read instead of
|
|
2090
|
+
* the 1MB relay cap.
|
|
2091
|
+
*
|
|
2092
|
+
* The path is encrypted rather than sent in the clear because every other
|
|
2093
|
+
* payload on this channel is (clipboard, fs RPC) — and a path leaks
|
|
2094
|
+
* project/client names and directory structure.
|
|
2095
|
+
*/
|
|
2096
|
+
pushFilePreview(path, mode = "file") {
|
|
2097
|
+
if (!this.socket.connected) {
|
|
2098
|
+
return { delivered: false, error: "not connected to the server" };
|
|
2099
|
+
}
|
|
2100
|
+
this.socket.emit("file-preview-push", {
|
|
2101
|
+
payload: authRelay.encodeBase64(authRelay.encrypt(this.encryptionKey, this.encryptionVariant, path)),
|
|
2102
|
+
enc: true,
|
|
2103
|
+
mode
|
|
2104
|
+
});
|
|
2105
|
+
return { delivered: true };
|
|
2106
|
+
}
|
|
2107
|
+
/**
|
|
2108
|
+
* Send usage data to the server
|
|
2109
|
+
*/
|
|
2110
|
+
sendUsageData(usage, model, eventId) {
|
|
2111
|
+
if (eventId && this.seenClaudeUsageEvents.has(eventId)) return;
|
|
2112
|
+
if (eventId) this.seenClaudeUsageEvents.add(eventId);
|
|
2113
|
+
const totalTokens = usage.input_tokens + usage.output_tokens + (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0);
|
|
2114
|
+
const costs = calculateCost(usage, model);
|
|
2115
|
+
this.claudeUsageTotals.total += totalTokens;
|
|
2116
|
+
this.claudeUsageTotals.input += usage.input_tokens;
|
|
2117
|
+
this.claudeUsageTotals.output += usage.output_tokens;
|
|
2118
|
+
this.claudeUsageTotals.cache_creation += usage.cache_creation_input_tokens || 0;
|
|
2119
|
+
this.claudeUsageTotals.cache_read += usage.cache_read_input_tokens || 0;
|
|
2120
|
+
this.claudeUsageTotals.costTotal += costs.total;
|
|
2121
|
+
this.claudeUsageTotals.costInput += costs.input;
|
|
2122
|
+
this.claudeUsageTotals.costOutput += costs.output;
|
|
2123
|
+
const usageReport = {
|
|
2124
|
+
// Keep the legacy key so upgrading replaces the old last-call row
|
|
2125
|
+
// instead of making old + new clients double-count one session.
|
|
2126
|
+
key: "claude-session",
|
|
2127
|
+
sessionId: this.sessionId,
|
|
2128
|
+
tokens: {
|
|
2129
|
+
total: this.claudeUsageTotals.total,
|
|
2130
|
+
input: this.claudeUsageTotals.input,
|
|
2131
|
+
output: this.claudeUsageTotals.output,
|
|
2132
|
+
cache_creation: this.claudeUsageTotals.cache_creation,
|
|
2133
|
+
cache_read: this.claudeUsageTotals.cache_read
|
|
2134
|
+
},
|
|
2135
|
+
cost: {
|
|
2136
|
+
total: this.claudeUsageTotals.costTotal,
|
|
2137
|
+
input: this.claudeUsageTotals.costInput,
|
|
2138
|
+
output: this.claudeUsageTotals.costOutput
|
|
2139
|
+
}
|
|
2140
|
+
};
|
|
2141
|
+
logger.logger.debugLargeJson("[SOCKET] Sending usage data:", usageReport);
|
|
2142
|
+
this.socket.emit("usage-report", usageReport);
|
|
2143
|
+
}
|
|
2144
|
+
/** Save a provider's cumulative session snapshot under one upsert key. */
|
|
2145
|
+
sendAgentUsageSnapshot(agent, rawUsage) {
|
|
2146
|
+
const tokens = normalizeAgentUsage(rawUsage);
|
|
2147
|
+
if (!tokens) return false;
|
|
2148
|
+
const usageReport = {
|
|
2149
|
+
key: `usage:${usageAgentKey(agent)}:session`,
|
|
2150
|
+
sessionId: this.sessionId,
|
|
2151
|
+
tokens,
|
|
2152
|
+
// Provider token-count events do not expose reliable billed cost.
|
|
2153
|
+
// The Web treats this sentinel as unknown, not as real $0.00.
|
|
2154
|
+
cost: { total: 0 }
|
|
2155
|
+
};
|
|
2156
|
+
logger.logger.debugLargeJson("[SOCKET] Sending agent usage snapshot:", usageReport);
|
|
2157
|
+
this.socket.emit("usage-report", usageReport);
|
|
2158
|
+
return true;
|
|
2159
|
+
}
|
|
2160
|
+
/**
|
|
2161
|
+
* Returns the latest session metadata known to the client.
|
|
2162
|
+
*/
|
|
2163
|
+
getMetadata() {
|
|
2164
|
+
return this.metadata;
|
|
2165
|
+
}
|
|
2166
|
+
/**
|
|
2167
|
+
* Returns the latest agent state known to the client (requests,
|
|
2168
|
+
* controlledByUser, etc.). Used by the notification producer to classify
|
|
2169
|
+
* turn-end events.
|
|
2170
|
+
*/
|
|
2171
|
+
getAgentState() {
|
|
2172
|
+
return this.agentState;
|
|
2173
|
+
}
|
|
2174
|
+
/**
|
|
2175
|
+
* Update session metadata
|
|
2176
|
+
* @param handler - Handler function that returns the updated metadata
|
|
2177
|
+
*/
|
|
2178
|
+
suppressNextArchiveSignal() {
|
|
2179
|
+
this.ignoreArchiveSignal = true;
|
|
2180
|
+
}
|
|
2181
|
+
skipExistingMessages() {
|
|
2182
|
+
this.skipInitialMessages = true;
|
|
2183
|
+
}
|
|
2184
|
+
updateMetadata(handler) {
|
|
2185
|
+
this.metadataLock.inLock(async () => {
|
|
2186
|
+
let rateRefusals = 0;
|
|
2187
|
+
await time.backoff(async () => {
|
|
2188
|
+
let updated = handler(this.metadata);
|
|
2189
|
+
const answer = await this.socket.emitWithAck("update-metadata", { sid: this.sessionId, expectedVersion: this.metadataVersion, metadata: authRelay.encodeBase64(authRelay.encrypt(this.encryptionKey, this.encryptionVariant, updated)) });
|
|
2190
|
+
if (answer.result === "success") {
|
|
2191
|
+
this.metadata = authRelay.decrypt(this.encryptionKey, this.encryptionVariant, authRelay.decodeBase64(answer.metadata));
|
|
2192
|
+
this.metadataVersion = answer.version;
|
|
2193
|
+
rateRefusals = 0;
|
|
2194
|
+
} else if (answer.result === "version-mismatch") {
|
|
2195
|
+
if (answer.version > this.metadataVersion) {
|
|
2196
|
+
this.metadataVersion = answer.version;
|
|
2197
|
+
this.metadata = authRelay.decrypt(this.encryptionKey, this.encryptionVariant, authRelay.decodeBase64(answer.metadata));
|
|
2198
|
+
}
|
|
2199
|
+
throw new Error("Metadata version mismatch");
|
|
2200
|
+
} else if (answer.result === "error") {
|
|
2201
|
+
if (lidState.isRateQuotaCode(answer.error)) {
|
|
2202
|
+
rateRefusals += 1;
|
|
2203
|
+
await lidState.pauseForRateQuota({
|
|
2204
|
+
label: "session metadata",
|
|
2205
|
+
code: answer.error,
|
|
2206
|
+
attempt: rateRefusals,
|
|
2207
|
+
sleep: time.delay,
|
|
2208
|
+
log: (message) => logger.logger.debug(message)
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
throw new Error("Metadata update failed");
|
|
2212
|
+
}
|
|
2213
|
+
});
|
|
2214
|
+
});
|
|
2215
|
+
}
|
|
2216
|
+
/**
|
|
2217
|
+
* Update session agent state
|
|
2218
|
+
* @param handler - Handler function that returns the updated agent state
|
|
2219
|
+
*/
|
|
2220
|
+
updateAgentState(handler) {
|
|
2221
|
+
logger.logger.debugLargeJson("Updating agent state", this.agentState);
|
|
2222
|
+
this.agentStateLock.inLock(async () => {
|
|
2223
|
+
let rateRefusals = 0;
|
|
2224
|
+
await time.backoff(async () => {
|
|
2225
|
+
let updated = handler(this.agentState || {});
|
|
2226
|
+
const answer = await this.socket.emitWithAck("update-state", { sid: this.sessionId, expectedVersion: this.agentStateVersion, agentState: updated ? authRelay.encodeBase64(authRelay.encrypt(this.encryptionKey, this.encryptionVariant, updated)) : null });
|
|
2227
|
+
if (answer.result === "success") {
|
|
2228
|
+
this.agentState = answer.agentState ? authRelay.decrypt(this.encryptionKey, this.encryptionVariant, authRelay.decodeBase64(answer.agentState)) : null;
|
|
2229
|
+
this.agentStateVersion = answer.version;
|
|
2230
|
+
rateRefusals = 0;
|
|
2231
|
+
logger.logger.debug("Agent state updated", this.agentState);
|
|
2232
|
+
} else if (answer.result === "version-mismatch") {
|
|
2233
|
+
if (answer.version > this.agentStateVersion) {
|
|
2234
|
+
this.agentStateVersion = answer.version;
|
|
2235
|
+
this.agentState = answer.agentState ? authRelay.decrypt(this.encryptionKey, this.encryptionVariant, authRelay.decodeBase64(answer.agentState)) : null;
|
|
2236
|
+
}
|
|
2237
|
+
throw new Error("Agent state version mismatch");
|
|
2238
|
+
} else if (answer.result === "error") {
|
|
2239
|
+
if (lidState.isRateQuotaCode(answer.error)) {
|
|
2240
|
+
rateRefusals += 1;
|
|
2241
|
+
await lidState.pauseForRateQuota({
|
|
2242
|
+
label: "session agent state",
|
|
2243
|
+
code: answer.error,
|
|
2244
|
+
attempt: rateRefusals,
|
|
2245
|
+
sleep: time.delay,
|
|
2246
|
+
log: (message) => logger.logger.debug(message)
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
throw new Error("Agent state update failed");
|
|
2250
|
+
}
|
|
2251
|
+
});
|
|
2252
|
+
});
|
|
2253
|
+
}
|
|
2254
|
+
/**
|
|
2255
|
+
* Wait for socket buffer to flush
|
|
2256
|
+
*/
|
|
2257
|
+
async flush() {
|
|
2258
|
+
await Promise.race([
|
|
2259
|
+
this.sendSync.invalidateAndAwait(),
|
|
2260
|
+
time.delay(1e4)
|
|
2261
|
+
]);
|
|
2262
|
+
if (!this.socket.connected) {
|
|
2263
|
+
return;
|
|
2264
|
+
}
|
|
2265
|
+
return new Promise((resolve) => {
|
|
2266
|
+
this.socket.emit("ping", () => {
|
|
2267
|
+
resolve();
|
|
2268
|
+
});
|
|
2269
|
+
setTimeout(() => {
|
|
2270
|
+
resolve();
|
|
2271
|
+
}, 1e4);
|
|
2272
|
+
});
|
|
2273
|
+
}
|
|
2274
|
+
async close() {
|
|
2275
|
+
logger.logger.debug("[API] socket.close() called");
|
|
2276
|
+
this.promptQueueDrain?.close();
|
|
2277
|
+
this.sendSync.stop();
|
|
2278
|
+
this.receiveSync.stop();
|
|
2279
|
+
if (this.reconnectInterval) {
|
|
2280
|
+
clearInterval(this.reconnectInterval);
|
|
2281
|
+
this.reconnectInterval = null;
|
|
2282
|
+
}
|
|
2283
|
+
this.socket.close();
|
|
2284
|
+
this.relaySocket?.close();
|
|
2285
|
+
this.relaySocket = null;
|
|
2286
|
+
}
|
|
2287
|
+
startSmartReconnect() {
|
|
2288
|
+
if (this.reconnectInterval) return;
|
|
2289
|
+
this.reconnectInterval = setInterval(() => {
|
|
2290
|
+
if (this.socket.connected) {
|
|
2291
|
+
clearInterval(this.reconnectInterval);
|
|
2292
|
+
this.reconnectInterval = null;
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
if (!lidState.shouldReconnect()) {
|
|
2296
|
+
logger.logger.debug("[API] Still not ready to reconnect");
|
|
2297
|
+
return;
|
|
2298
|
+
}
|
|
2299
|
+
logger.logger.debug("[API] Attempting reconnect");
|
|
2300
|
+
this.socket.connect();
|
|
2301
|
+
}, 3e3);
|
|
2302
|
+
if (lidState.shouldReconnect()) {
|
|
2303
|
+
logger.logger.debug("[API] Network up + lid open \u2014 reconnecting in 1s");
|
|
2304
|
+
setTimeout(() => {
|
|
2305
|
+
if (!this.socket.connected) this.socket.connect();
|
|
2306
|
+
}, 1e3);
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
function getSessionTitle(metadata) {
|
|
2312
|
+
const summaryText = metadata?.summary?.text?.trim();
|
|
2313
|
+
if (summaryText) {
|
|
2314
|
+
return summaryText;
|
|
2315
|
+
}
|
|
2316
|
+
const path = metadata?.path?.trim();
|
|
2317
|
+
if (!path) {
|
|
2318
|
+
return "Session";
|
|
2319
|
+
}
|
|
2320
|
+
const segments = path.split(/[\\/]/).filter(Boolean);
|
|
2321
|
+
return segments[segments.length - 1] || "Session";
|
|
2322
|
+
}
|
|
2323
|
+
function getSessionNotificationUrl(data) {
|
|
2324
|
+
const sessionId = data?.sessionId;
|
|
2325
|
+
if (typeof sessionId !== "string") {
|
|
2326
|
+
return null;
|
|
2327
|
+
}
|
|
2328
|
+
const trimmedSessionId = sessionId.trim();
|
|
2329
|
+
if (!trimmedSessionId) {
|
|
2330
|
+
return null;
|
|
2331
|
+
}
|
|
2332
|
+
return `/session/${encodeURIComponent(trimmedSessionId)}`;
|
|
2333
|
+
}
|
|
2334
|
+
function getSessionNotificationTitle(kind) {
|
|
2335
|
+
switch (kind) {
|
|
2336
|
+
case "done":
|
|
2337
|
+
return "It's ready!";
|
|
2338
|
+
case "permission":
|
|
2339
|
+
return "Permission request";
|
|
2340
|
+
case "question":
|
|
2341
|
+
return "Clarification needed";
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
function getSessionNotificationBody(metadata) {
|
|
2345
|
+
return getSessionTitle(metadata);
|
|
2346
|
+
}
|
|
2347
|
+
function getSessionNotificationCopy(kind, metadata) {
|
|
2348
|
+
return {
|
|
2349
|
+
title: getSessionNotificationTitle(kind),
|
|
2350
|
+
body: getSessionNotificationBody(metadata)
|
|
2351
|
+
};
|
|
2352
|
+
}
|
|
2353
|
+
class PushNotificationClient {
|
|
2354
|
+
token;
|
|
2355
|
+
baseUrl;
|
|
2356
|
+
expoLoad = null;
|
|
2357
|
+
constructor(token, baseUrl = "https://api.cluster-fluster.com") {
|
|
2358
|
+
this.token = token;
|
|
2359
|
+
this.baseUrl = baseUrl;
|
|
2360
|
+
}
|
|
2361
|
+
loadExpo() {
|
|
2362
|
+
if (!this.expoLoad) {
|
|
2363
|
+
const load = import('expo-server-sdk').then(({ Expo }) => ({ Expo, expo: new Expo() }));
|
|
2364
|
+
load.catch(() => {
|
|
2365
|
+
if (this.expoLoad === load) this.expoLoad = null;
|
|
2366
|
+
});
|
|
2367
|
+
this.expoLoad = load;
|
|
2368
|
+
}
|
|
2369
|
+
return this.expoLoad;
|
|
2370
|
+
}
|
|
2371
|
+
/**
|
|
2372
|
+
* Fetch all push tokens for the authenticated user.
|
|
2373
|
+
* Retries up to 3 times with exponential backoff on transient errors.
|
|
2374
|
+
*/
|
|
2375
|
+
async fetchPushTokens() {
|
|
2376
|
+
const maxAttempts = 3;
|
|
2377
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2378
|
+
try {
|
|
2379
|
+
const response = await axios.get(
|
|
2380
|
+
`${this.baseUrl}/v1/push-tokens`,
|
|
2381
|
+
{
|
|
2382
|
+
headers: {
|
|
2383
|
+
"Authorization": `Bearer ${this.token}`,
|
|
2384
|
+
"Content-Type": "application/json",
|
|
2385
|
+
"X-Happy-Client": `cli-daemon/${configuration.configuration.currentCliVersion}`
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
);
|
|
2389
|
+
logger.logger.debug(`Fetched ${response.data.tokens.length} push tokens`);
|
|
2390
|
+
response.data.tokens.forEach((token, index) => {
|
|
2391
|
+
logger.logger.debug(`[PUSH] Token ${index + 1}: id=${token.id}, created=${new Date(token.createdAt).toISOString()}, updated=${new Date(token.updatedAt).toISOString()}`);
|
|
2392
|
+
});
|
|
2393
|
+
return response.data.tokens;
|
|
2394
|
+
} catch (error) {
|
|
2395
|
+
logger.logger.debug(`[PUSH] [ERROR] Failed to fetch push tokens (attempt ${attempt}/${maxAttempts}):`, error);
|
|
2396
|
+
if (attempt < maxAttempts) {
|
|
2397
|
+
const delay = 1e3 * Math.pow(2, attempt - 1);
|
|
2398
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
logger.logger.debug("[PUSH] [ERROR] All push token fetch attempts failed");
|
|
2403
|
+
return [];
|
|
2404
|
+
}
|
|
2405
|
+
/**
|
|
2406
|
+
* Send push notification via Expo Push API with retry
|
|
2407
|
+
* @param messages - Array of push messages to send
|
|
2408
|
+
*/
|
|
2409
|
+
async sendPushNotifications(messages) {
|
|
2410
|
+
logger.logger.debug(`Sending ${messages.length} push notifications`);
|
|
2411
|
+
const { Expo, expo } = await this.loadExpo();
|
|
2412
|
+
const validMessages = messages.filter((message) => {
|
|
2413
|
+
if (Array.isArray(message.to)) {
|
|
2414
|
+
return message.to.every((token) => Expo.isExpoPushToken(token));
|
|
2415
|
+
}
|
|
2416
|
+
return Expo.isExpoPushToken(message.to);
|
|
2417
|
+
});
|
|
2418
|
+
if (validMessages.length === 0) {
|
|
2419
|
+
logger.logger.debug("No valid Expo push tokens found");
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
const chunks = expo.chunkPushNotifications(validMessages);
|
|
2423
|
+
for (const chunk of chunks) {
|
|
2424
|
+
const startTime = Date.now();
|
|
2425
|
+
const timeout = 3e5;
|
|
2426
|
+
let attempt = 0;
|
|
2427
|
+
while (true) {
|
|
2428
|
+
try {
|
|
2429
|
+
const ticketChunk = await expo.sendPushNotificationsAsync(chunk);
|
|
2430
|
+
const errors = ticketChunk.filter((ticket) => ticket.status === "error");
|
|
2431
|
+
if (errors.length > 0) {
|
|
2432
|
+
const errorDetails = errors.map((e) => ({ message: e.message, details: e.details }));
|
|
2433
|
+
logger.logger.debug("[PUSH] Some notifications failed:", errorDetails);
|
|
2434
|
+
}
|
|
2435
|
+
if (errors.length === ticketChunk.length) {
|
|
2436
|
+
throw new Error("All push notifications in chunk failed");
|
|
2437
|
+
}
|
|
2438
|
+
break;
|
|
2439
|
+
} catch (error) {
|
|
2440
|
+
const elapsed = Date.now() - startTime;
|
|
2441
|
+
if (elapsed >= timeout) {
|
|
2442
|
+
logger.logger.debug("[PUSH] Timeout reached after 5 minutes, giving up on chunk");
|
|
2443
|
+
break;
|
|
2444
|
+
}
|
|
2445
|
+
attempt++;
|
|
2446
|
+
const delay = Math.min(1e3 * Math.pow(2, attempt), 3e4);
|
|
2447
|
+
const remainingTime = timeout - elapsed;
|
|
2448
|
+
const waitTime = Math.min(delay, remainingTime);
|
|
2449
|
+
if (waitTime > 0) {
|
|
2450
|
+
logger.logger.debug(`[PUSH] Retrying in ${waitTime}ms (attempt ${attempt})`);
|
|
2451
|
+
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
logger.logger.debug(`Push notifications sent successfully`);
|
|
2457
|
+
}
|
|
2458
|
+
/**
|
|
2459
|
+
* Send a push notification to all registered devices for the user
|
|
2460
|
+
* @param title - Notification title
|
|
2461
|
+
* @param body - Notification body
|
|
2462
|
+
* @param data - Additional data to send with the notification
|
|
2463
|
+
*/
|
|
2464
|
+
sendToAllDevices(title, body, data) {
|
|
2465
|
+
logger.logger.debug("[PUSH] sendToAllDevices called:", {
|
|
2466
|
+
title: contentLogMetadata.contentLogMetadata(title),
|
|
2467
|
+
body: contentLogMetadata.contentLogMetadata(body),
|
|
2468
|
+
dataKeyCount: data ? Object.keys(data).length : 0
|
|
2469
|
+
});
|
|
2470
|
+
(async () => {
|
|
2471
|
+
try {
|
|
2472
|
+
logger.logger.debug("[PUSH] Fetching push tokens...");
|
|
2473
|
+
const tokens = await this.fetchPushTokens();
|
|
2474
|
+
logger.logger.debug(`[PUSH] Fetched ${tokens.length} push tokens`);
|
|
2475
|
+
tokens.forEach((token, index) => {
|
|
2476
|
+
logger.logger.debug(`[PUSH] Using token ${index + 1}: id=${token.id}`);
|
|
2477
|
+
});
|
|
2478
|
+
if (tokens.length === 0) {
|
|
2479
|
+
logger.logger.debug("No push tokens found for user");
|
|
2480
|
+
return;
|
|
2481
|
+
}
|
|
2482
|
+
const messages = tokens.map((token, index) => {
|
|
2483
|
+
logger.logger.debug(`[PUSH] Creating message ${index + 1} for token`);
|
|
2484
|
+
return {
|
|
2485
|
+
to: token.token,
|
|
2486
|
+
title,
|
|
2487
|
+
body: body && body.length > 0 ? body : void 0,
|
|
2488
|
+
data,
|
|
2489
|
+
// TODO: For brutalist session artwork, attach rich media via a public HTTPS image URL.
|
|
2490
|
+
// Bundled app asset paths / require(...) / local file paths will not work in push payloads.
|
|
2491
|
+
// iOS also needs a Notification Service Extension to render richContent.image reliably.
|
|
2492
|
+
sound: "default",
|
|
2493
|
+
priority: "high"
|
|
2494
|
+
};
|
|
2495
|
+
});
|
|
2496
|
+
logger.logger.debug(`[PUSH] Sending ${messages.length} push notifications...`);
|
|
2497
|
+
await this.sendPushNotifications(messages);
|
|
2498
|
+
logger.logger.debug("[PUSH] Push notifications sent successfully");
|
|
2499
|
+
} catch (error) {
|
|
2500
|
+
logger.logger.debug("[PUSH] Error sending to all devices:", error);
|
|
2501
|
+
}
|
|
2502
|
+
})();
|
|
2503
|
+
}
|
|
2504
|
+
/**
|
|
2505
|
+
* Routes session-event pushes through the server so it can apply
|
|
2506
|
+
* presence-based suppression (active desktop/web, mobile foreground).
|
|
2507
|
+
* Falls back to direct Expo send only when sessionId is missing — that
|
|
2508
|
+
* shouldn't happen for session notifications but guards against regressions.
|
|
2509
|
+
*/
|
|
2510
|
+
sendSessionNotification(params) {
|
|
2511
|
+
const { title, body } = getSessionNotificationCopy(params.kind, params.metadata);
|
|
2512
|
+
const sessionTitle = getSessionNotificationBody(params.metadata);
|
|
2513
|
+
const url = getSessionNotificationUrl(params.data);
|
|
2514
|
+
const payloadData = {
|
|
2515
|
+
...params.data,
|
|
2516
|
+
kind: params.kind,
|
|
2517
|
+
sessionTitle,
|
|
2518
|
+
...url ? { url } : {}
|
|
2519
|
+
};
|
|
2520
|
+
const sessionId = typeof params.data?.sessionId === "string" ? params.data.sessionId : null;
|
|
2521
|
+
if (!sessionId) {
|
|
2522
|
+
logger.logger.debug("[PUSH] sendSessionNotification: missing sessionId, falling back to direct send");
|
|
2523
|
+
this.sendToAllDevices(title, body, payloadData);
|
|
2524
|
+
return;
|
|
2525
|
+
}
|
|
2526
|
+
void (async () => {
|
|
2527
|
+
try {
|
|
2528
|
+
await axios.post(
|
|
2529
|
+
`${this.baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}/push-event`,
|
|
2530
|
+
{
|
|
2531
|
+
kind: params.kind,
|
|
2532
|
+
title,
|
|
2533
|
+
body,
|
|
2534
|
+
data: payloadData
|
|
2535
|
+
},
|
|
2536
|
+
{
|
|
2537
|
+
headers: {
|
|
2538
|
+
"Authorization": `Bearer ${this.token}`,
|
|
2539
|
+
"Content-Type": "application/json",
|
|
2540
|
+
"X-Happy-Client": `cli-daemon/${configuration.configuration.currentCliVersion}`
|
|
2541
|
+
},
|
|
2542
|
+
timeout: 15e3
|
|
2543
|
+
}
|
|
2544
|
+
);
|
|
2545
|
+
logger.logger.debug(`[PUSH] sendSessionNotification dispatched via server (kind=${params.kind})`);
|
|
2546
|
+
} catch (error) {
|
|
2547
|
+
logger.logger.debug("[PUSH] sendSessionNotification failed:", error);
|
|
2548
|
+
}
|
|
2549
|
+
})();
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
function startOfflineReconnection(config) {
|
|
2554
|
+
let reconnected = false;
|
|
2555
|
+
let session = null;
|
|
2556
|
+
let timeoutId = null;
|
|
2557
|
+
let failureCount = 0;
|
|
2558
|
+
let cancelled = false;
|
|
2559
|
+
const defaultHealthCheck = async () => {
|
|
2560
|
+
await axios.get(`${config.serverUrl}/v1/sessions`, {
|
|
2561
|
+
timeout: 5e3,
|
|
2562
|
+
validateStatus: (status) => status < 500,
|
|
2563
|
+
// 4xx = server is up, 5xx = server error
|
|
2564
|
+
headers: {
|
|
2565
|
+
"X-Happy-Client": `cli-daemon/${configuration.configuration.currentCliVersion}`
|
|
2566
|
+
}
|
|
2567
|
+
});
|
|
2568
|
+
};
|
|
2569
|
+
const healthCheck = config.healthCheck ?? defaultHealthCheck;
|
|
2570
|
+
const initialDelayMs = config.initialDelayMs ?? 5e3;
|
|
2571
|
+
const retryDelayMs = config.retryDelayMs ?? ((count) => time.exponentialBackoffDelay(count, 5e3, 6e4, 10));
|
|
2572
|
+
const attemptReconnect = async () => {
|
|
2573
|
+
if (reconnected || cancelled) return;
|
|
2574
|
+
try {
|
|
2575
|
+
await healthCheck();
|
|
2576
|
+
if (cancelled) return;
|
|
2577
|
+
session = await config.onReconnected();
|
|
2578
|
+
if (cancelled) return;
|
|
2579
|
+
reconnected = true;
|
|
2580
|
+
config.onNotify("\u2705 Reconnected! Session syncing in background.");
|
|
2581
|
+
logger.logger.debug("[OfflineReconnection] Successfully reconnected");
|
|
2582
|
+
} catch (e) {
|
|
2583
|
+
if (axios.isAxiosError(e) && e.response?.status === 401) {
|
|
2584
|
+
logger.logger.debug("[OfflineReconnection] Authentication error, stopping retries");
|
|
2585
|
+
config.onNotify("\u274C Authentication failed. Please re-authenticate with `very-happy auth`.");
|
|
2586
|
+
return;
|
|
2587
|
+
}
|
|
2588
|
+
failureCount++;
|
|
2589
|
+
const delay = retryDelayMs(failureCount);
|
|
2590
|
+
logger.logger.debug(`[OfflineReconnection] Attempt ${failureCount} failed, retrying in ${delay}ms`);
|
|
2591
|
+
if (!cancelled) {
|
|
2592
|
+
timeoutId = setTimeout(attemptReconnect, delay);
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
};
|
|
2596
|
+
timeoutId = setTimeout(attemptReconnect, initialDelayMs);
|
|
2597
|
+
return {
|
|
2598
|
+
cancel: () => {
|
|
2599
|
+
cancelled = true;
|
|
2600
|
+
if (timeoutId) {
|
|
2601
|
+
clearTimeout(timeoutId);
|
|
2602
|
+
timeoutId = null;
|
|
2603
|
+
}
|
|
2604
|
+
config.onCleanup?.();
|
|
2605
|
+
},
|
|
2606
|
+
getSession: () => session,
|
|
2607
|
+
isReconnected: () => reconnected
|
|
2608
|
+
};
|
|
2609
|
+
}
|
|
2610
|
+
const NETWORK_ERROR_CODES = [
|
|
2611
|
+
"ECONNREFUSED",
|
|
2612
|
+
"ENOTFOUND",
|
|
2613
|
+
"ETIMEDOUT",
|
|
2614
|
+
"ECONNRESET",
|
|
2615
|
+
"EHOSTUNREACH",
|
|
2616
|
+
"ENETUNREACH"
|
|
2617
|
+
];
|
|
2618
|
+
function isNetworkError(code) {
|
|
2619
|
+
return code !== void 0 && NETWORK_ERROR_CODES.includes(code);
|
|
2620
|
+
}
|
|
2621
|
+
const ERROR_DESCRIPTIONS = {
|
|
2622
|
+
// Network errors (Node.js)
|
|
2623
|
+
ECONNREFUSED: "server not accepting connections",
|
|
2624
|
+
ENOTFOUND: "server hostname not found",
|
|
2625
|
+
ETIMEDOUT: "connection timed out",
|
|
2626
|
+
ECONNRESET: "connection reset by server",
|
|
2627
|
+
EHOSTUNREACH: "server host unreachable",
|
|
2628
|
+
ENETUNREACH: "network unreachable",
|
|
2629
|
+
// HTTP errors
|
|
2630
|
+
"401": "authentication failed - run `very-happy auth`",
|
|
2631
|
+
"403": "access forbidden",
|
|
2632
|
+
"404": "endpoint not found, check server deployment",
|
|
2633
|
+
"500": "server internal error",
|
|
2634
|
+
"502": "bad gateway",
|
|
2635
|
+
"503": "service unavailable"
|
|
2636
|
+
};
|
|
2637
|
+
class OfflineState {
|
|
2638
|
+
state = "online";
|
|
2639
|
+
failures = /* @__PURE__ */ new Map();
|
|
2640
|
+
// Dedupe by operation
|
|
2641
|
+
backend = "Claude";
|
|
2642
|
+
/** Report failure - accumulates context, prints once on first offline transition */
|
|
2643
|
+
fail(failure) {
|
|
2644
|
+
this.failures.set(failure.operation, failure);
|
|
2645
|
+
if (this.state === "online") {
|
|
2646
|
+
this.state = "offline";
|
|
2647
|
+
this.print();
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
/** Reset on reconnection */
|
|
2651
|
+
recover() {
|
|
2652
|
+
this.state = "online";
|
|
2653
|
+
this.failures.clear();
|
|
2654
|
+
}
|
|
2655
|
+
/** Set backend name before API calls */
|
|
2656
|
+
setBackend(name) {
|
|
2657
|
+
this.backend = name;
|
|
2658
|
+
}
|
|
2659
|
+
/** Check current state */
|
|
2660
|
+
isOffline() {
|
|
2661
|
+
return this.state === "offline";
|
|
2662
|
+
}
|
|
2663
|
+
/** Reset for testing - clears all state */
|
|
2664
|
+
reset() {
|
|
2665
|
+
this.state = "online";
|
|
2666
|
+
this.failures.clear();
|
|
2667
|
+
this.backend = "Claude";
|
|
2668
|
+
}
|
|
2669
|
+
print() {
|
|
2670
|
+
const summary = [...this.failures.values()].map((f) => {
|
|
2671
|
+
const desc = f.errorCode ? `${f.errorCode} - ${ERROR_DESCRIPTIONS[f.errorCode] || "unknown error"}` : "unknown error";
|
|
2672
|
+
const url = f.url ? ` at ${f.url}` : "";
|
|
2673
|
+
return `${f.operation} failed: ${desc}${url}`;
|
|
2674
|
+
}).join("; ");
|
|
2675
|
+
console.log(`\u26A0\uFE0F Happy server unreachable, offline mode with auto-reconnect enabled - error details: ${summary}`);
|
|
2676
|
+
const allDetails = [...this.failures.values()].flatMap((f) => f.details || []);
|
|
2677
|
+
allDetails.forEach((line) => console.log(chalk.yellow(` \u2192 ${line}`)));
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
const connectionState = new OfflineState();
|
|
2681
|
+
|
|
2682
|
+
function accountBoxPublicKeyFromCredentials(credential) {
|
|
2683
|
+
if (credential.encryption.type === "legacy") {
|
|
2684
|
+
return authRelay.libsodiumPublicKeyFromSecretKey(credential.encryption.secret);
|
|
2685
|
+
}
|
|
2686
|
+
return credential.encryption.publicKey;
|
|
2687
|
+
}
|
|
2688
|
+
class NotificationProducer {
|
|
2689
|
+
token;
|
|
2690
|
+
accountBoxPubKey;
|
|
2691
|
+
sessionId;
|
|
2692
|
+
getMetadata;
|
|
2693
|
+
/** Last repeatKey we successfully (or attempted to) emit, used for local dedup. */
|
|
2694
|
+
lastEmittedAt = /* @__PURE__ */ new Map();
|
|
2695
|
+
constructor(opts) {
|
|
2696
|
+
this.token = opts.credential.token;
|
|
2697
|
+
this.accountBoxPubKey = accountBoxPublicKeyFromCredentials(opts.credential);
|
|
2698
|
+
this.sessionId = opts.sessionId;
|
|
2699
|
+
this.getMetadata = opts.getMetadata;
|
|
2700
|
+
}
|
|
2701
|
+
/**
|
|
2702
|
+
* A new non-empty permission request was added to agentState.requests.
|
|
2703
|
+
*/
|
|
2704
|
+
permissionRequest(toolName) {
|
|
2705
|
+
const project = this.projectLabel();
|
|
2706
|
+
this.emit("permission_request", {
|
|
2707
|
+
title: project ? `Permission needed \xB7 ${project}` : "Permission needed",
|
|
2708
|
+
snippet: toolName ? `Claude wants to use ${toolName}` : "Claude is requesting permission"
|
|
2709
|
+
});
|
|
2710
|
+
}
|
|
2711
|
+
/**
|
|
2712
|
+
* A turn finished and Claude produced a reply (had assistant output during
|
|
2713
|
+
* the turn). thinking went true → false with new assistant message.
|
|
2714
|
+
*/
|
|
2715
|
+
replyDone(snippet) {
|
|
2716
|
+
const project = this.projectLabel();
|
|
2717
|
+
this.emit("reply_done", {
|
|
2718
|
+
title: project ? `Claude replied \xB7 ${project}` : "Claude replied",
|
|
2719
|
+
snippet: this.clip(snippet) || "Claude finished responding"
|
|
2720
|
+
});
|
|
2721
|
+
}
|
|
2722
|
+
/**
|
|
2723
|
+
* A turn ended with the session idle and waiting for user input:
|
|
2724
|
+
* no pending permission requests, not controlled by user, not thinking.
|
|
2725
|
+
*/
|
|
2726
|
+
inputNeeded() {
|
|
2727
|
+
const project = this.projectLabel();
|
|
2728
|
+
this.emit("input_needed", {
|
|
2729
|
+
title: project ? `Waiting for you \xB7 ${project}` : "Waiting for you",
|
|
2730
|
+
snippet: "Claude is idle and waiting for your input"
|
|
2731
|
+
});
|
|
2732
|
+
}
|
|
2733
|
+
/**
|
|
2734
|
+
* The session errored.
|
|
2735
|
+
*/
|
|
2736
|
+
error(message) {
|
|
2737
|
+
const project = this.projectLabel();
|
|
2738
|
+
this.emit("error", {
|
|
2739
|
+
title: project ? `Session error \xB7 ${project}` : "Session error",
|
|
2740
|
+
snippet: this.clip(message) || "The session encountered an error"
|
|
2741
|
+
});
|
|
2742
|
+
}
|
|
2743
|
+
/**
|
|
2744
|
+
* Build, encrypt, dedup and POST a notification. Best-effort, fire-and-forget.
|
|
2745
|
+
*/
|
|
2746
|
+
emit(notifType, parts) {
|
|
2747
|
+
const repeatKey = `${this.sessionId}:${notifType}`;
|
|
2748
|
+
const now = Date.now();
|
|
2749
|
+
const last = this.lastEmittedAt.get(repeatKey);
|
|
2750
|
+
if (last !== void 0 && now - last < DEDUP_WINDOW_MS) {
|
|
2751
|
+
logger.logger.debug(`[notif] Suppressing duplicate ${notifType} for ${this.sessionId}`);
|
|
2752
|
+
return;
|
|
2753
|
+
}
|
|
2754
|
+
this.lastEmittedAt.set(repeatKey, now);
|
|
2755
|
+
const body = {
|
|
2756
|
+
title: parts.title,
|
|
2757
|
+
snippet: parts.snippet,
|
|
2758
|
+
sessionId: this.sessionId,
|
|
2759
|
+
ts: now
|
|
2760
|
+
};
|
|
2761
|
+
let enc;
|
|
2762
|
+
try {
|
|
2763
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(body));
|
|
2764
|
+
const bundle = authRelay.libsodiumEncryptForPublicKey(plaintext, this.accountBoxPubKey);
|
|
2765
|
+
enc = authRelay.encodeBase64(bundle);
|
|
2766
|
+
} catch (err) {
|
|
2767
|
+
logger.logger.debug("[notif] Failed to encrypt notification body:", err);
|
|
2768
|
+
return;
|
|
2769
|
+
}
|
|
2770
|
+
void this.post(notifType, enc, repeatKey);
|
|
2771
|
+
}
|
|
2772
|
+
async post(notifType, enc, repeatKey) {
|
|
2773
|
+
try {
|
|
2774
|
+
await axios.post(
|
|
2775
|
+
`${configuration.configuration.serverUrl}/v1/feed`,
|
|
2776
|
+
{
|
|
2777
|
+
notifType,
|
|
2778
|
+
sessionId: this.sessionId,
|
|
2779
|
+
enc,
|
|
2780
|
+
repeatKey
|
|
2781
|
+
},
|
|
2782
|
+
{
|
|
2783
|
+
headers: {
|
|
2784
|
+
"Authorization": `Bearer ${this.token}`,
|
|
2785
|
+
"Content-Type": "application/json",
|
|
2786
|
+
"X-Happy-Client": `cli-coding-session/${configuration.configuration.currentCliVersion}`
|
|
2787
|
+
},
|
|
2788
|
+
timeout: 5e3
|
|
2789
|
+
}
|
|
2790
|
+
);
|
|
2791
|
+
logger.logger.debug(`[notif] Posted ${notifType} for session ${this.sessionId}`);
|
|
2792
|
+
} catch (err) {
|
|
2793
|
+
logger.logger.debug(`[notif] Failed to post ${notifType}:`, err);
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
/** Short, human-friendly label for the project/session for use in titles. */
|
|
2797
|
+
projectLabel() {
|
|
2798
|
+
const meta = this.getMetadata();
|
|
2799
|
+
if (!meta) return void 0;
|
|
2800
|
+
if (meta.name) return meta.name;
|
|
2801
|
+
if (meta.path) {
|
|
2802
|
+
const parts = meta.path.split("/").filter(Boolean);
|
|
2803
|
+
return parts.length ? parts[parts.length - 1] : meta.path;
|
|
2804
|
+
}
|
|
2805
|
+
return void 0;
|
|
2806
|
+
}
|
|
2807
|
+
clip(text, max = 140) {
|
|
2808
|
+
if (!text) return void 0;
|
|
2809
|
+
const trimmed = text.replace(/\s+/g, " ").trim();
|
|
2810
|
+
if (!trimmed) return void 0;
|
|
2811
|
+
return trimmed.length > max ? trimmed.slice(0, max - 1) + "\u2026" : trimmed;
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
const DEDUP_WINDOW_MS = 3e3;
|
|
2815
|
+
|
|
2816
|
+
function describeSessionCreateError(error) {
|
|
2817
|
+
const base = error instanceof Error ? error.message : "Unknown error";
|
|
2818
|
+
if (!axios.isAxiosError(error) || !error.response) return base;
|
|
2819
|
+
const data = error.response.data;
|
|
2820
|
+
const code = data && typeof data === "object" && typeof data.error === "string" ? data.error : null;
|
|
2821
|
+
return code ? `${base} (${code})` : base;
|
|
2822
|
+
}
|
|
2823
|
+
class ApiClient {
|
|
2824
|
+
static async create(credential) {
|
|
2825
|
+
return new ApiClient(credential);
|
|
2826
|
+
}
|
|
2827
|
+
credential;
|
|
2828
|
+
pushClient;
|
|
2829
|
+
constructor(credential) {
|
|
2830
|
+
this.credential = credential;
|
|
2831
|
+
this.pushClient = new PushNotificationClient(credential.token, configuration.configuration.serverUrl);
|
|
2832
|
+
}
|
|
2833
|
+
/**
|
|
2834
|
+
* Create a new session or load existing one with the given tag
|
|
2835
|
+
*/
|
|
2836
|
+
async getOrCreateSession(opts) {
|
|
2837
|
+
let dataEncryptionKey = null;
|
|
2838
|
+
let encryptionKey;
|
|
2839
|
+
let encryptionVariant;
|
|
2840
|
+
if (this.credential.encryption.type === "dataKey") {
|
|
2841
|
+
encryptionKey = authRelay.getRandomBytes(32);
|
|
2842
|
+
encryptionVariant = "dataKey";
|
|
2843
|
+
let encryptedDataKey = authRelay.libsodiumEncryptForPublicKey(encryptionKey, this.credential.encryption.publicKey);
|
|
2844
|
+
dataEncryptionKey = new Uint8Array(encryptedDataKey.length + 1);
|
|
2845
|
+
dataEncryptionKey.set([0], 0);
|
|
2846
|
+
dataEncryptionKey.set(encryptedDataKey, 1);
|
|
2847
|
+
} else {
|
|
2848
|
+
encryptionKey = this.credential.encryption.secret;
|
|
2849
|
+
encryptionVariant = "legacy";
|
|
2850
|
+
}
|
|
2851
|
+
try {
|
|
2852
|
+
const response = await axios.post(
|
|
2853
|
+
`${configuration.configuration.serverUrl}/v1/sessions`,
|
|
2854
|
+
{
|
|
2855
|
+
tag: opts.tag,
|
|
2856
|
+
metadata: authRelay.encodeBase64(authRelay.encrypt(encryptionKey, encryptionVariant, opts.metadata)),
|
|
2857
|
+
agentState: opts.state ? authRelay.encodeBase64(authRelay.encrypt(encryptionKey, encryptionVariant, opts.state)) : null,
|
|
2858
|
+
dataEncryptionKey: dataEncryptionKey ? authRelay.encodeBase64(dataEncryptionKey) : null
|
|
2859
|
+
},
|
|
2860
|
+
{
|
|
2861
|
+
headers: {
|
|
2862
|
+
"Authorization": `Bearer ${this.credential.token}`,
|
|
2863
|
+
"Content-Type": "application/json",
|
|
2864
|
+
"X-Happy-Client": `cli-coding-session/${configuration.configuration.currentCliVersion}`
|
|
2865
|
+
},
|
|
2866
|
+
timeout: 6e4
|
|
2867
|
+
// 1 minute timeout for very bad network connections
|
|
2868
|
+
}
|
|
2869
|
+
);
|
|
2870
|
+
logger.logger.debug(`Session created/loaded: ${response.data.session.id} (tag: ${opts.tag})`);
|
|
2871
|
+
let raw = response.data.session;
|
|
2872
|
+
let session = {
|
|
2873
|
+
id: raw.id,
|
|
2874
|
+
seq: raw.seq,
|
|
2875
|
+
metadata: authRelay.decrypt(encryptionKey, encryptionVariant, authRelay.decodeBase64(raw.metadata)),
|
|
2876
|
+
metadataVersion: raw.metadataVersion,
|
|
2877
|
+
agentState: raw.agentState ? authRelay.decrypt(encryptionKey, encryptionVariant, authRelay.decodeBase64(raw.agentState)) : null,
|
|
2878
|
+
agentStateVersion: raw.agentStateVersion,
|
|
2879
|
+
encryptionKey,
|
|
2880
|
+
encryptionVariant
|
|
2881
|
+
};
|
|
2882
|
+
return session;
|
|
2883
|
+
} catch (error) {
|
|
2884
|
+
logger.logger.debug("[API] [ERROR] Failed to get or create session:", error);
|
|
2885
|
+
if (error && typeof error === "object" && "code" in error) {
|
|
2886
|
+
const errorCode = error.code;
|
|
2887
|
+
if (isNetworkError(errorCode)) {
|
|
2888
|
+
connectionState.fail({
|
|
2889
|
+
operation: "Session creation",
|
|
2890
|
+
caller: "api.getOrCreateSession",
|
|
2891
|
+
errorCode,
|
|
2892
|
+
url: `${configuration.configuration.serverUrl}/v1/sessions`
|
|
2893
|
+
});
|
|
2894
|
+
return null;
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
const is404Error = axios.isAxiosError(error) && error.response?.status === 404 || error && typeof error === "object" && "response" in error && error.response?.status === 404;
|
|
2898
|
+
if (is404Error) {
|
|
2899
|
+
connectionState.fail({
|
|
2900
|
+
operation: "Session creation",
|
|
2901
|
+
errorCode: "404",
|
|
2902
|
+
url: `${configuration.configuration.serverUrl}/v1/sessions`
|
|
2903
|
+
});
|
|
2904
|
+
return null;
|
|
2905
|
+
}
|
|
2906
|
+
if (axios.isAxiosError(error) && error.response?.status) {
|
|
2907
|
+
const status = error.response.status;
|
|
2908
|
+
if (status >= 500) {
|
|
2909
|
+
connectionState.fail({
|
|
2910
|
+
operation: "Session creation",
|
|
2911
|
+
errorCode: String(status),
|
|
2912
|
+
url: `${configuration.configuration.serverUrl}/v1/sessions`,
|
|
2913
|
+
details: ["Server encountered an error, will retry automatically"]
|
|
2914
|
+
});
|
|
2915
|
+
return null;
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
throw new Error(`Failed to get or create session: ${describeSessionCreateError(error)}`);
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
/**
|
|
2922
|
+
* Register or update machine with the server
|
|
2923
|
+
* Returns the current machine state from the server with decrypted metadata and daemonState
|
|
2924
|
+
*/
|
|
2925
|
+
async getOrCreateMachine(opts) {
|
|
2926
|
+
let dataEncryptionKey = null;
|
|
2927
|
+
let encryptionKey;
|
|
2928
|
+
let encryptionVariant;
|
|
2929
|
+
if (this.credential.encryption.type === "dataKey") {
|
|
2930
|
+
encryptionVariant = "dataKey";
|
|
2931
|
+
encryptionKey = this.credential.encryption.machineKey;
|
|
2932
|
+
let encryptedDataKey = authRelay.libsodiumEncryptForPublicKey(this.credential.encryption.machineKey, this.credential.encryption.publicKey);
|
|
2933
|
+
dataEncryptionKey = new Uint8Array(encryptedDataKey.length + 1);
|
|
2934
|
+
dataEncryptionKey.set([0], 0);
|
|
2935
|
+
dataEncryptionKey.set(encryptedDataKey, 1);
|
|
2936
|
+
} else {
|
|
2937
|
+
encryptionKey = this.credential.encryption.secret;
|
|
2938
|
+
encryptionVariant = "legacy";
|
|
2939
|
+
}
|
|
2940
|
+
const createMinimalMachine = () => ({
|
|
2941
|
+
id: opts.machineId,
|
|
2942
|
+
encryptionKey,
|
|
2943
|
+
encryptionVariant,
|
|
2944
|
+
metadata: opts.metadata,
|
|
2945
|
+
metadataVersion: 0,
|
|
2946
|
+
daemonState: opts.daemonState || null,
|
|
2947
|
+
daemonStateVersion: 0
|
|
2948
|
+
});
|
|
2949
|
+
try {
|
|
2950
|
+
const response = await axios.post(
|
|
2951
|
+
`${configuration.configuration.serverUrl}/v1/machines`,
|
|
2952
|
+
{
|
|
2953
|
+
id: opts.machineId,
|
|
2954
|
+
metadata: authRelay.encodeBase64(authRelay.encrypt(encryptionKey, encryptionVariant, opts.metadata)),
|
|
2955
|
+
daemonState: opts.daemonState ? authRelay.encodeBase64(authRelay.encrypt(encryptionKey, encryptionVariant, opts.daemonState)) : void 0,
|
|
2956
|
+
dataEncryptionKey: dataEncryptionKey ? authRelay.encodeBase64(dataEncryptionKey) : void 0
|
|
2957
|
+
},
|
|
2958
|
+
{
|
|
2959
|
+
headers: {
|
|
2960
|
+
"Authorization": `Bearer ${this.credential.token}`,
|
|
2961
|
+
"Content-Type": "application/json",
|
|
2962
|
+
"X-Happy-Client": `cli-coding-session/${configuration.configuration.currentCliVersion}`
|
|
2963
|
+
},
|
|
2964
|
+
timeout: 6e4
|
|
2965
|
+
// 1 minute timeout for very bad network connections
|
|
2966
|
+
}
|
|
2967
|
+
);
|
|
2968
|
+
const raw = response.data.machine;
|
|
2969
|
+
logger.logger.debug(`[API] Machine ${opts.machineId} registered/updated with server`);
|
|
2970
|
+
const machine = {
|
|
2971
|
+
id: raw.id,
|
|
2972
|
+
encryptionKey,
|
|
2973
|
+
encryptionVariant,
|
|
2974
|
+
metadata: raw.metadata ? authRelay.decrypt(encryptionKey, encryptionVariant, authRelay.decodeBase64(raw.metadata)) : null,
|
|
2975
|
+
metadataVersion: raw.metadataVersion || 0,
|
|
2976
|
+
daemonState: raw.daemonState ? authRelay.decrypt(encryptionKey, encryptionVariant, authRelay.decodeBase64(raw.daemonState)) : null,
|
|
2977
|
+
daemonStateVersion: raw.daemonStateVersion || 0
|
|
2978
|
+
};
|
|
2979
|
+
return machine;
|
|
2980
|
+
} catch (error) {
|
|
2981
|
+
if (axios.isAxiosError(error) && error.code && isNetworkError(error.code)) {
|
|
2982
|
+
connectionState.fail({
|
|
2983
|
+
operation: "Machine registration",
|
|
2984
|
+
caller: "api.getOrCreateMachine",
|
|
2985
|
+
errorCode: error.code,
|
|
2986
|
+
url: `${configuration.configuration.serverUrl}/v1/machines`
|
|
2987
|
+
});
|
|
2988
|
+
return createMinimalMachine();
|
|
2989
|
+
}
|
|
2990
|
+
if (axios.isAxiosError(error) && error.response?.status) {
|
|
2991
|
+
const status = error.response.status;
|
|
2992
|
+
if (status === 403 || status === 409) {
|
|
2993
|
+
console.log(chalk.yellow(
|
|
2994
|
+
`\u26A0\uFE0F Machine registration rejected by the server with status ${status}`
|
|
2995
|
+
));
|
|
2996
|
+
console.log(chalk.yellow(
|
|
2997
|
+
` \u2192 This machine ID is already registered to another account on the server`
|
|
2998
|
+
));
|
|
2999
|
+
console.log(chalk.yellow(
|
|
3000
|
+
` \u2192 This usually happens after re-authenticating with a different account`
|
|
3001
|
+
));
|
|
3002
|
+
console.log(chalk.yellow(
|
|
3003
|
+
// B-297: `doctor clean` only kills processes — it has never reset local
|
|
3004
|
+
// state or minted a machine id. `auth login --force` is the command
|
|
3005
|
+
// that does (it regenerates machineId, src/ui/auth.ts).
|
|
3006
|
+
` \u2192 Run 'very-happy auth login --force' to re-authenticate and generate a new machine ID`
|
|
3007
|
+
));
|
|
3008
|
+
console.log(chalk.yellow(
|
|
3009
|
+
` \u2192 Open a GitHub issue if this problem persists`
|
|
3010
|
+
));
|
|
3011
|
+
return createMinimalMachine();
|
|
3012
|
+
}
|
|
3013
|
+
if (status === 429 && error.response?.data?.error === "machine_state_rate_quota_exceeded") {
|
|
3014
|
+
connectionState.fail({
|
|
3015
|
+
operation: "Machine registration",
|
|
3016
|
+
errorCode: "429",
|
|
3017
|
+
url: `${configuration.configuration.serverUrl}/v1/machines`,
|
|
3018
|
+
details: ["Relay write quota is cooling down; realtime connection will retry automatically"]
|
|
3019
|
+
});
|
|
3020
|
+
return createMinimalMachine();
|
|
3021
|
+
}
|
|
3022
|
+
if (status >= 500) {
|
|
3023
|
+
connectionState.fail({
|
|
3024
|
+
operation: "Machine registration",
|
|
3025
|
+
errorCode: String(status),
|
|
3026
|
+
url: `${configuration.configuration.serverUrl}/v1/machines`,
|
|
3027
|
+
details: ["Server encountered an error, will retry automatically"]
|
|
3028
|
+
});
|
|
3029
|
+
return createMinimalMachine();
|
|
3030
|
+
}
|
|
3031
|
+
if (status === 404) {
|
|
3032
|
+
connectionState.fail({
|
|
3033
|
+
operation: "Machine registration",
|
|
3034
|
+
errorCode: "404",
|
|
3035
|
+
url: `${configuration.configuration.serverUrl}/v1/machines`
|
|
3036
|
+
});
|
|
3037
|
+
return createMinimalMachine();
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
throw error;
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
sessionSyncClient(session, opts) {
|
|
3044
|
+
return new ApiSessionClient(this.credential.token, session, opts);
|
|
3045
|
+
}
|
|
3046
|
+
async machineSyncClient(machine) {
|
|
3047
|
+
const { ApiMachineClient } = await Promise.resolve().then(function () { return require('./apiMachine-Bn8482VM.cjs'); });
|
|
3048
|
+
return new ApiMachineClient(this.credential.token, machine);
|
|
3049
|
+
}
|
|
3050
|
+
push() {
|
|
3051
|
+
return this.pushClient;
|
|
3052
|
+
}
|
|
3053
|
+
/**
|
|
3054
|
+
* Create an account-encrypted notification producer bound to a session.
|
|
3055
|
+
* The producer derives the account box public key from the stored
|
|
3056
|
+
* credentials (no raw secret seed needed for the dataKey credential shape)
|
|
3057
|
+
* and posts encrypted notifications to the server feed. Best-effort:
|
|
3058
|
+
* failures never propagate into the session loop.
|
|
3059
|
+
*/
|
|
3060
|
+
notificationProducer(sessionId, getMetadata) {
|
|
3061
|
+
return new NotificationProducer({
|
|
3062
|
+
credential: this.credential,
|
|
3063
|
+
sessionId,
|
|
3064
|
+
getMetadata
|
|
3065
|
+
});
|
|
3066
|
+
}
|
|
3067
|
+
/**
|
|
3068
|
+
* Read a single value from the account KV store (same HTTP endpoint the
|
|
3069
|
+
* web app uses). Returns null when the key does not exist or on ANY
|
|
3070
|
+
* failure — callers (boardAnalyzer) treat KV as best-effort context.
|
|
3071
|
+
*/
|
|
3072
|
+
async kvGet(key) {
|
|
3073
|
+
try {
|
|
3074
|
+
const response = await axios.get(
|
|
3075
|
+
`${configuration.configuration.serverUrl}/v1/kv/${encodeURIComponent(key)}`,
|
|
3076
|
+
{
|
|
3077
|
+
headers: {
|
|
3078
|
+
"Authorization": `Bearer ${this.credential.token}`,
|
|
3079
|
+
"X-Happy-Client": `cli-coding-session/${configuration.configuration.currentCliVersion}`
|
|
3080
|
+
},
|
|
3081
|
+
timeout: 5e3,
|
|
3082
|
+
validateStatus: (s) => s === 200 || s === 404
|
|
3083
|
+
}
|
|
3084
|
+
);
|
|
3085
|
+
if (response.status === 404) return null;
|
|
3086
|
+
return response.data;
|
|
3087
|
+
} catch (error) {
|
|
3088
|
+
logger.logger.debug(`[API] kvGet(${key}) failed:`, error);
|
|
3089
|
+
return null;
|
|
3090
|
+
}
|
|
3091
|
+
}
|
|
3092
|
+
/**
|
|
3093
|
+
* Register a vendor API token with the server
|
|
3094
|
+
* The token is sent as a JSON string - server handles encryption
|
|
3095
|
+
*/
|
|
3096
|
+
async registerVendorToken(vendor, apiKey) {
|
|
3097
|
+
try {
|
|
3098
|
+
const response = await axios.post(
|
|
3099
|
+
`${configuration.configuration.serverUrl}/v1/connect/${vendor}/register`,
|
|
3100
|
+
{
|
|
3101
|
+
token: JSON.stringify(apiKey)
|
|
3102
|
+
},
|
|
3103
|
+
{
|
|
3104
|
+
headers: {
|
|
3105
|
+
"Authorization": `Bearer ${this.credential.token}`,
|
|
3106
|
+
"Content-Type": "application/json",
|
|
3107
|
+
"X-Happy-Client": `cli-coding-session/${configuration.configuration.currentCliVersion}`
|
|
3108
|
+
},
|
|
3109
|
+
timeout: 5e3
|
|
3110
|
+
}
|
|
3111
|
+
);
|
|
3112
|
+
if (response.status !== 200 && response.status !== 201) {
|
|
3113
|
+
throw new Error(`Server returned status ${response.status}`);
|
|
3114
|
+
}
|
|
3115
|
+
logger.logger.debug(`[API] Vendor token for ${vendor} registered successfully`);
|
|
3116
|
+
} catch (error) {
|
|
3117
|
+
logger.logger.debug(`[API] [ERROR] Failed to register vendor token:`, logger.redactLogValue(error));
|
|
3118
|
+
throw new Error(`Failed to register vendor token: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
/**
|
|
3122
|
+
* Get vendor API token from the server
|
|
3123
|
+
* Returns the token if it exists, null otherwise
|
|
3124
|
+
*/
|
|
3125
|
+
async getVendorToken(vendor) {
|
|
3126
|
+
try {
|
|
3127
|
+
const response = await axios.get(
|
|
3128
|
+
`${configuration.configuration.serverUrl}/v1/connect/${vendor}/token`,
|
|
3129
|
+
{
|
|
3130
|
+
headers: {
|
|
3131
|
+
"Authorization": `Bearer ${this.credential.token}`,
|
|
3132
|
+
"Content-Type": "application/json",
|
|
3133
|
+
"X-Happy-Client": `cli-coding-session/${configuration.configuration.currentCliVersion}`
|
|
3134
|
+
},
|
|
3135
|
+
timeout: 5e3
|
|
3136
|
+
}
|
|
3137
|
+
);
|
|
3138
|
+
if (response.status === 404) {
|
|
3139
|
+
logger.logger.debug(`[API] No vendor token found for ${vendor}`);
|
|
3140
|
+
return null;
|
|
3141
|
+
}
|
|
3142
|
+
if (response.status !== 200) {
|
|
3143
|
+
throw new Error(`Server returned status ${response.status}`);
|
|
3144
|
+
}
|
|
3145
|
+
logger.logger.debug(`[API] Raw vendor token response:`, {
|
|
3146
|
+
status: response.status,
|
|
3147
|
+
dataKeys: Object.keys(response.data || {}),
|
|
3148
|
+
hasToken: "token" in (response.data || {}),
|
|
3149
|
+
tokenType: typeof response.data?.token
|
|
3150
|
+
});
|
|
3151
|
+
let tokenData = null;
|
|
3152
|
+
if (response.data?.token) {
|
|
3153
|
+
if (typeof response.data.token === "string") {
|
|
3154
|
+
try {
|
|
3155
|
+
tokenData = JSON.parse(response.data.token);
|
|
3156
|
+
} catch (parseError) {
|
|
3157
|
+
logger.logger.debug(`[API] Failed to parse token as JSON, using as string:`, parseError);
|
|
3158
|
+
tokenData = response.data.token;
|
|
3159
|
+
}
|
|
3160
|
+
} else if (response.data.token !== null) {
|
|
3161
|
+
tokenData = response.data.token;
|
|
3162
|
+
} else {
|
|
3163
|
+
logger.logger.debug(`[API] Token is null for ${vendor}, treating as not found`);
|
|
3164
|
+
return null;
|
|
3165
|
+
}
|
|
3166
|
+
} else if (response.data && typeof response.data === "object") {
|
|
3167
|
+
if (response.data.token === null && Object.keys(response.data).length === 1) {
|
|
3168
|
+
logger.logger.debug(`[API] Response contains only null token for ${vendor}, treating as not found`);
|
|
3169
|
+
return null;
|
|
3170
|
+
}
|
|
3171
|
+
tokenData = response.data;
|
|
3172
|
+
}
|
|
3173
|
+
if (tokenData === null || tokenData && typeof tokenData === "object" && tokenData.token === null && Object.keys(tokenData).length === 1) {
|
|
3174
|
+
logger.logger.debug(`[API] Token data is null for ${vendor}`);
|
|
3175
|
+
return null;
|
|
3176
|
+
}
|
|
3177
|
+
logger.logger.debug(`[API] Vendor token for ${vendor} retrieved successfully`, {
|
|
3178
|
+
tokenDataType: typeof tokenData,
|
|
3179
|
+
tokenDataKeys: tokenData && typeof tokenData === "object" ? Object.keys(tokenData) : "not an object"
|
|
3180
|
+
});
|
|
3181
|
+
return tokenData;
|
|
3182
|
+
} catch (error) {
|
|
3183
|
+
if (error.response?.status === 404) {
|
|
3184
|
+
logger.logger.debug(`[API] No vendor token found for ${vendor}`);
|
|
3185
|
+
return null;
|
|
3186
|
+
}
|
|
3187
|
+
logger.logger.debug(`[API] [ERROR] Failed to get vendor token:`, logger.redactLogValue(error));
|
|
3188
|
+
return null;
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
/**
|
|
3192
|
+
* B-505: mark a session OFFLINE on the server (active=false, `archivedAt`
|
|
3193
|
+
* untouched) — the same row state a missed keepalive produces, so the web
|
|
3194
|
+
* shows it offline and resumable instead of archived.
|
|
3195
|
+
*
|
|
3196
|
+
* Used when the wrapper exits for an infrastructure reason (SIGTERM /
|
|
3197
|
+
* SIGINT: daemon stop, supervisor restart, machine shutdown) as a
|
|
3198
|
+
* synchronous fallback for the socket `session-end` signal: even if the
|
|
3199
|
+
* socket emit doesn't drain before the process exits, the HTTP response
|
|
3200
|
+
* confirms the row went inactive.
|
|
3201
|
+
*
|
|
3202
|
+
* This MUST NOT hit `/archive`: since B-265 that route writes the
|
|
3203
|
+
* server-owned `archivedAt` tombstone (the only thing that distinguishes
|
|
3204
|
+
* "archived" from "offline"), and the server answers it with a
|
|
3205
|
+
* `session-archive` command that makes the wrapper archive itself. That is
|
|
3206
|
+
* exactly how every dev-sg upgrade archived the sessions mid-turn on
|
|
3207
|
+
* 2026-09-25. A server without `/deactivate` returns 404, which is a
|
|
3208
|
+
* compatible no-op: `session-end` already sets active=false there.
|
|
3209
|
+
*/
|
|
3210
|
+
async deactivateSession(sessionId) {
|
|
3211
|
+
try {
|
|
3212
|
+
const response = await axios.post(
|
|
3213
|
+
`${configuration.configuration.serverUrl}/v1/sessions/${sessionId}/deactivate`,
|
|
3214
|
+
{},
|
|
3215
|
+
{
|
|
3216
|
+
headers: {
|
|
3217
|
+
"Authorization": `Bearer ${this.credential.token}`,
|
|
3218
|
+
"X-Happy-Client": `cli-coding-session/${configuration.configuration.currentCliVersion}`
|
|
3219
|
+
},
|
|
3220
|
+
timeout: 3e3,
|
|
3221
|
+
validateStatus: (status) => status >= 200 && status < 300 || status === 404
|
|
3222
|
+
}
|
|
3223
|
+
);
|
|
3224
|
+
if (response.status === 404) {
|
|
3225
|
+
logger.logger.debug("[API] deactivateSession: server has no /deactivate route; relying on session-end");
|
|
3226
|
+
return false;
|
|
3227
|
+
}
|
|
3228
|
+
return true;
|
|
3229
|
+
} catch (error) {
|
|
3230
|
+
logger.logger.debug("[API] deactivateSession failed:", error);
|
|
3231
|
+
return false;
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
/**
|
|
3235
|
+
* Archive a session on the server: writes the `archivedAt` tombstone and
|
|
3236
|
+
* makes the server tell every wrapper of this session to exit. Only for an
|
|
3237
|
+
* intentional end of life — the user's Archive / kill, a crash the wrapper
|
|
3238
|
+
* cannot recover from, a terminal mirror whose pane went back to a shell.
|
|
3239
|
+
* Never call this from a signal handler; see `deactivateSession`.
|
|
3240
|
+
*/
|
|
3241
|
+
async archiveSession(sessionId) {
|
|
3242
|
+
try {
|
|
3243
|
+
const response = await axios.post(
|
|
3244
|
+
`${configuration.configuration.serverUrl}/v1/sessions/${sessionId}/archive`,
|
|
3245
|
+
{},
|
|
3246
|
+
{
|
|
3247
|
+
headers: {
|
|
3248
|
+
"Authorization": `Bearer ${this.credential.token}`,
|
|
3249
|
+
"X-Happy-Client": `cli-coding-session/${configuration.configuration.currentCliVersion}`
|
|
3250
|
+
},
|
|
3251
|
+
timeout: 3e3
|
|
3252
|
+
}
|
|
3253
|
+
);
|
|
3254
|
+
return response.status >= 200 && response.status < 300;
|
|
3255
|
+
} catch (error) {
|
|
3256
|
+
logger.logger.debug("[API] archiveSession failed:", error);
|
|
3257
|
+
return false;
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
/**
|
|
3261
|
+
* B-265: one session by id — the message cursor (`seq`), metadata and
|
|
3262
|
+
* agent state with their versions, decrypted with the session key the
|
|
3263
|
+
* reconnecting process already holds. `unsupported` = the server has no
|
|
3264
|
+
* such route (or the row is gone): callers fall back to the legacy
|
|
3265
|
+
* skip-all-history reconnect. Transient failures are retried a few times
|
|
3266
|
+
* first, because falling back silently discards the very message a restore
|
|
3267
|
+
* is meant to deliver.
|
|
3268
|
+
*/
|
|
3269
|
+
async getSession(sessionId, encryptionKey, encryptionVariant, opts) {
|
|
3270
|
+
const attempts = opts?.attempts ?? 3;
|
|
3271
|
+
const delays = opts?.delaysMs ?? [500, 1500];
|
|
3272
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
3273
|
+
try {
|
|
3274
|
+
const response = await axios.get(
|
|
3275
|
+
`${configuration.configuration.serverUrl}/v1/sessions/${encodeURIComponent(sessionId)}`,
|
|
3276
|
+
{
|
|
3277
|
+
headers: {
|
|
3278
|
+
"Authorization": `Bearer ${this.credential.token}`,
|
|
3279
|
+
"X-Happy-Client": `cli-coding-session/${configuration.configuration.currentCliVersion}`
|
|
3280
|
+
},
|
|
3281
|
+
timeout: opts?.timeoutMs ?? 4e3,
|
|
3282
|
+
validateStatus: () => true
|
|
3283
|
+
}
|
|
3284
|
+
);
|
|
3285
|
+
if (response.status === 404 || response.status === 401 || response.status === 403) {
|
|
3286
|
+
return { ok: false, reason: "unsupported" };
|
|
3287
|
+
}
|
|
3288
|
+
if (response.status >= 200 && response.status < 300) {
|
|
3289
|
+
const row = response.data.session;
|
|
3290
|
+
if (!row || typeof row.seq !== "number" || typeof row.metadata !== "string") {
|
|
3291
|
+
return { ok: false, reason: "unsupported" };
|
|
3292
|
+
}
|
|
3293
|
+
const metadata = authRelay.decrypt(encryptionKey, encryptionVariant, authRelay.decodeBase64(row.metadata));
|
|
3294
|
+
if (!metadata) return { ok: false, reason: "unavailable" };
|
|
3295
|
+
const agentState = row.agentState ? authRelay.decrypt(encryptionKey, encryptionVariant, authRelay.decodeBase64(row.agentState)) : null;
|
|
3296
|
+
return {
|
|
3297
|
+
ok: true,
|
|
3298
|
+
session: { seq: row.seq, metadata, metadataVersion: row.metadataVersion, agentState, agentStateVersion: row.agentStateVersion }
|
|
3299
|
+
};
|
|
3300
|
+
}
|
|
3301
|
+
logger.logger.debug(`[API] getSession ${sessionId}: status ${response.status} (attempt ${attempt + 1}/${attempts})`);
|
|
3302
|
+
} catch (error) {
|
|
3303
|
+
logger.logger.debug(`[API] getSession ${sessionId} failed (attempt ${attempt + 1}/${attempts}):`, error);
|
|
3304
|
+
}
|
|
3305
|
+
const delayMs = delays[Math.min(attempt, delays.length - 1)];
|
|
3306
|
+
if (attempt < attempts - 1 && delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
|
|
3307
|
+
}
|
|
3308
|
+
return { ok: false, reason: "unavailable" };
|
|
3309
|
+
}
|
|
3310
|
+
/** Clear the server-owned archive tombstone before an intentional resume.
|
|
3311
|
+
* Older servers return 404 because the endpoint does not exist; that is a
|
|
3312
|
+
* compatible no-op because those servers also have no durable tombstone. */
|
|
3313
|
+
async reactivateSession(sessionId) {
|
|
3314
|
+
try {
|
|
3315
|
+
const response = await axios.post(
|
|
3316
|
+
`${configuration.configuration.serverUrl}/v1/sessions/${sessionId}/unarchive`,
|
|
3317
|
+
{},
|
|
3318
|
+
{
|
|
3319
|
+
headers: {
|
|
3320
|
+
"Authorization": `Bearer ${this.credential.token}`,
|
|
3321
|
+
"X-Happy-Client": `cli-coding-session/${configuration.configuration.currentCliVersion}`
|
|
3322
|
+
},
|
|
3323
|
+
timeout: 3e3,
|
|
3324
|
+
validateStatus: (status) => status >= 200 && status < 300 || status === 404
|
|
3325
|
+
}
|
|
3326
|
+
);
|
|
3327
|
+
return response.status >= 200 && response.status < 300 || response.status === 404;
|
|
3328
|
+
} catch (error) {
|
|
3329
|
+
logger.logger.debug("[API] reactivateSession failed:", error);
|
|
3330
|
+
return false;
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
var api = /*#__PURE__*/Object.freeze({
|
|
3336
|
+
__proto__: null,
|
|
3337
|
+
ApiClient: ApiClient,
|
|
3338
|
+
describeSessionCreateError: describeSessionCreateError
|
|
3339
|
+
});
|
|
3340
|
+
|
|
3341
|
+
exports.ApiClient = ApiClient;
|
|
3342
|
+
exports.ApiSessionClient = ApiSessionClient;
|
|
3343
|
+
exports.TurnActivityTracker = TurnActivityTracker;
|
|
3344
|
+
exports.api = api;
|
|
3345
|
+
exports.connectionState = connectionState;
|
|
3346
|
+
exports.startOfflineReconnection = startOfflineReconnection;
|