very-happy-cli 0.2.156 → 0.2.158
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-BHe1_F1h.cjs → AcpBackend-DIDRig5G.cjs} +2 -2
- package/dist/{AcpBackend-C3nc1u-s.mjs → AcpBackend-vDVLHuYJ.mjs} +2 -2
- package/dist/{AcpSessionManager-BVxkQnNf.cjs → AcpSessionManager-C44U99CC.cjs} +1 -1
- package/dist/{AcpSessionManager-dN3k1LbI.mjs → AcpSessionManager-CmRjfhn3.mjs} +1 -1
- package/dist/{agentGuidance-C604n0Zq.mjs → agentGuidance-CrR9l62U.mjs} +562 -30
- package/dist/{agentGuidance-C-CmV03f.cjs → agentGuidance-DwOWbX55.cjs} +581 -29
- package/dist/{agentHome-D2XGsoqs.mjs → agentHome-CyLmGH5L.mjs} +2 -2
- package/dist/{agentHome-CmIJNV7f.cjs → agentHome-Dw8Jx_CJ.cjs} +2 -2
- package/dist/{auto-B1I6G_Nf.mjs → auto-BlMZPwOk.mjs} +19 -10
- package/dist/{auto-CMDq4oIu.cjs → auto-DZbNcTXx.cjs} +19 -10
- package/dist/codex/happyMcpStdioBridge.cjs +2 -2
- package/dist/codex/happyMcpStdioBridge.mjs +2 -2
- package/dist/{config-BkTu25bH.mjs → config-BtPArqRH.mjs} +4 -4
- package/dist/{config-BYHSRY2E.cjs → config-DG84bjwF.cjs} +4 -4
- package/dist/{index-BbtR-Qto.cjs → index-BG932eCh.cjs} +292 -47
- package/dist/{index-DAAreiRF.mjs → index-CVCt3kSt.mjs} +289 -44
- package/dist/{index-CB8QnjvS.cjs → index-D5ScEErM.cjs} +4 -4
- package/dist/{index-B_-7NpGZ.cjs → index-DPCsWlWK.cjs} +7 -7
- package/dist/{index-PYO8Mdz4.mjs → index-DgO0FHOx.mjs} +4 -4
- package/dist/{index-B5QbzEga.mjs → index-ZChGsuB7.mjs} +8 -8
- package/dist/index.cjs +4 -4
- package/dist/index.mjs +4 -4
- package/dist/{install-CNtXzwT_.mjs → install-DarS_Wco.mjs} +1 -1
- package/dist/{install-CO1ps6Wt.cjs → install-wKQGgXvN.cjs} +1 -1
- package/dist/{installTerminalHooks-CMpOV3RU.mjs → installTerminalHooks-CpNbRFPz.mjs} +4 -4
- package/dist/{installTerminalHooks-BOAnQ1Qe.cjs → installTerminalHooks-DcQfee_O.cjs} +4 -4
- package/dist/lib.cjs +2 -2
- package/dist/lib.d.cts +77 -1
- package/dist/lib.d.mts +77 -1
- package/dist/lib.mjs +2 -2
- package/dist/{limits-DmMdchTz.mjs → limits-D2uN090G.mjs} +163 -11
- package/dist/{limits-o29KXBgj.cjs → limits-kqFqhfsh.cjs} +170 -10
- package/dist/{mcp-C_uejotl.mjs → mcp-BN6bKIGb.mjs} +4 -4
- package/dist/{mcp-CwK1nI7Y.cjs → mcp-DGdeUUDJ.cjs} +4 -4
- package/dist/{piRuntime-Dj5WnqzJ.cjs → piRuntime-3qtbKujX.cjs} +2 -2
- package/dist/{piRuntime-Cu2-8nOT.mjs → piRuntime-bkw2NAZU.mjs} +2 -2
- package/dist/{piTerminal-D-BIbov9.cjs → piTerminal-BYxp3LMr.cjs} +6 -6
- package/dist/{piTerminal-DanZeBrO.mjs → piTerminal-DilKwM7B.mjs} +6 -6
- package/dist/{runGemini--QqK-Ap5.cjs → runGemini-28O2sqyI.cjs} +6 -6
- package/dist/{runGemini-CZ_lhMWY.mjs → runGemini-Cjii069p.mjs} +6 -6
- package/dist/{runOpenClaw-C0xLhTvk.cjs → runOpenClaw-CiMZad-H.cjs} +5 -5
- package/dist/{runOpenClaw-CxgLpm38.mjs → runOpenClaw-jaLr7KF1.mjs} +5 -5
- package/dist/{send-BaOkDgH0.mjs → send-C9mOtChi.mjs} +43 -31
- package/dist/{send-CTcGMVuG.cjs → send-D5ejHPXG.cjs} +43 -31
- package/dist/{sessions-OmvyJb0n.cjs → sessions-BNMPQoLE.cjs} +133 -49
- package/dist/{sessions-Bn304xF1.mjs → sessions-W6771g3X.mjs} +133 -49
- package/dist/{spawn-BKNabutV.mjs → spawn-D0QVhzRb.mjs} +4 -4
- package/dist/{spawn-CC6V2KiA.cjs → spawn-Ddtom1cb.cjs} +4 -4
- package/dist/{teams-XtrH3_h3.cjs → teams-BZ1tayg_.cjs} +5 -5
- package/dist/{teams-CLbS2_4Q.mjs → teams-CqSwtw2O.mjs} +5 -5
- package/dist/{todo-CEDvKNXK.cjs → todo-BYMg0fR0.cjs} +2 -2
- package/dist/{todo-BcPbNKt2.mjs → todo-BbusB1zM.mjs} +2 -2
- package/dist/{types-BEVzYSoV.mjs → types-DIBF4U0Y.mjs} +78 -5
- package/dist/{types-CphWjqHc.cjs → types-DnrPeN80.cjs} +78 -5
- package/package.json +7 -7
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { G as normalizeHttpEndpoint, c as configuration, r as readSettings, d as readPersistedSessions, h as readCredentialsForConfiguredRelay, H as AutomationNameSchema, I as AutomationConcurrencySchema, J as AutomationActionSchema, K as AutomationTriggerSchema, w as encodeBase64, L as encrypt, M as decodeBase64, g as delay, N as decrypt, O as listDaemonSessions, n as stopDaemonSession, P as resumeDaemonSession, i as isValidSessionId,
|
|
3
|
+
import { G as normalizeHttpEndpoint, c as configuration, r as readSettings, d as readPersistedSessions, h as readCredentialsForConfiguredRelay, H as AutomationNameSchema, I as AutomationConcurrencySchema, J as AutomationActionSchema, K as AutomationTriggerSchema, w as encodeBase64, L as encrypt, M as decodeBase64, g as delay, N as decrypt, O as listDaemonSessions, n as stopDaemonSession, P as backgroundTasksReport, Q as resumeDaemonSession, i as isValidSessionId, R as listDaemonPeers } from './limits-D2uN090G.mjs';
|
|
4
4
|
import { open, mkdir, rename, unlink } from 'node:fs/promises';
|
|
5
5
|
import { constants, existsSync, readFileSync, statSync } from 'node:fs';
|
|
6
6
|
import { isAbsolute, join, dirname, resolve as resolve$1 } from 'node:path';
|
|
7
|
-
import {
|
|
7
|
+
import { hostname, userInfo } from 'node:os';
|
|
8
8
|
import axios from 'axios';
|
|
9
9
|
import { io } from 'socket.io-client';
|
|
10
10
|
|
|
@@ -303,7 +303,8 @@ function createAutomationsClient(options) {
|
|
|
303
303
|
}).then((r) => r.runs),
|
|
304
304
|
getRun: (id) => request("GET", `/v1/automations/runs/${enc(id)}`).then((r) => r.run),
|
|
305
305
|
cancel: (id) => request("POST", `/v1/automations/runs/${enc(id)}/cancel`).then((r) => r.run),
|
|
306
|
-
|
|
306
|
+
/** B-508: `by` records what resolved the flag (server default `owner`; older servers ignore the body). */
|
|
307
|
+
ack: (id, by) => request("POST", `/v1/automations/runs/${enc(id)}/ack`, by === void 0 ? void 0 : { by }).then((r) => r.run),
|
|
307
308
|
report: (id, input) => request("POST", `/v1/automations/runs/${enc(id)}/report`, input).then((r) => r.run),
|
|
308
309
|
claim: (input) => request("POST", "/v1/automations/claim", input),
|
|
309
310
|
stickies: (id, key) => request("GET", `/v1/automations/${enc(id)}/stickies`, void 0, { key }).then((r) => r.stickies),
|
|
@@ -882,7 +883,7 @@ async function resolvePermissionRequest(sessionId, requestId, verdict, deps = {}
|
|
|
882
883
|
`No local key for session ${sessionId} \u2014 approve/deny needs the session key from ~/.happy/sessions.json (the RPC payload is encrypted with it), so only sessions this machine's daemon spawned within 14 days can be answered from here.`
|
|
883
884
|
);
|
|
884
885
|
}
|
|
885
|
-
const token = await (deps.bearerToken ?? bearerToken$
|
|
886
|
+
const token = await (deps.bearerToken ?? bearerToken$2)();
|
|
886
887
|
const fetchState = deps.fetchAgentState ?? fetchDecryptedAgentState;
|
|
887
888
|
const before = pendingRequestsOf(await fetchState(sessionId, persisted, token), now());
|
|
888
889
|
if (!before.some((request) => request.id === requestId)) {
|
|
@@ -932,7 +933,7 @@ async function resolvePermissionRequest(sessionId, requestId, verdict, deps = {}
|
|
|
932
933
|
}
|
|
933
934
|
return { sessionId, requestId, payload, outcome, settled };
|
|
934
935
|
}
|
|
935
|
-
async function bearerToken$
|
|
936
|
+
async function bearerToken$2() {
|
|
936
937
|
const credentials = await readCredentialsForConfiguredRelay();
|
|
937
938
|
if (!credentials) throw new Error("CLI is not authenticated (no ~/.happy/access.key)");
|
|
938
939
|
return credentials.token;
|
|
@@ -961,6 +962,18 @@ async function fetchDecryptedAgentState(sessionId, persisted, token) {
|
|
|
961
962
|
const SESSION_OPS_CLIENT = "session-ops";
|
|
962
963
|
const DEFAULT_RECENT_LIMIT = 15;
|
|
963
964
|
const MAX_READ_LIMIT = 100;
|
|
965
|
+
function backgroundTasksOf(child) {
|
|
966
|
+
const raw = child.backgroundTasks;
|
|
967
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
968
|
+
const r = raw;
|
|
969
|
+
if (typeof r.count !== "number" || !Array.isArray(r.tasks)) return void 0;
|
|
970
|
+
return {
|
|
971
|
+
count: r.count,
|
|
972
|
+
tasks: r.tasks.filter((task) => !!task && typeof task === "object" && typeof task.id === "string"),
|
|
973
|
+
...typeof r.reportedAt === "number" ? { reportedAt: r.reportedAt } : {},
|
|
974
|
+
...r.stale === true ? { stale: true } : {}
|
|
975
|
+
};
|
|
976
|
+
}
|
|
964
977
|
function mergeSessionSummaries(live, persisted, options = {}) {
|
|
965
978
|
const recentLimit = Math.max(0, options.recentLimit ?? DEFAULT_RECENT_LIMIT);
|
|
966
979
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -980,7 +993,8 @@ function mergeSessionSummaries(live, persisted, options = {}) {
|
|
|
980
993
|
return summaries.filter((summary) => summary.tags?.includes(wanted) === true);
|
|
981
994
|
}
|
|
982
995
|
function livenessOf(child) {
|
|
983
|
-
|
|
996
|
+
const backgroundTasks = backgroundTasksOf(child);
|
|
997
|
+
return { live: true, pid: typeof child.pid === "number" ? child.pid : void 0, ...backgroundTasks ? { backgroundTasks } : {} };
|
|
984
998
|
}
|
|
985
999
|
function sessionLiveness(live, sessionId) {
|
|
986
1000
|
const child = live.find((entry) => entry.happySessionId === sessionId);
|
|
@@ -992,6 +1006,7 @@ function toSummary(id, persisted, extra) {
|
|
|
992
1006
|
id,
|
|
993
1007
|
live: extra.live,
|
|
994
1008
|
...extra.pid !== void 0 ? { pid: extra.pid } : {},
|
|
1009
|
+
...extra.backgroundTasks ? { backgroundTasks: extra.backgroundTasks } : {},
|
|
995
1010
|
...meta?.summary?.text ? { title: meta.summary.text } : {},
|
|
996
1011
|
...meta?.path ? { cwd: meta.path } : {},
|
|
997
1012
|
...meta?.flavor ? { flavor: meta.flavor } : {},
|
|
@@ -1011,6 +1026,7 @@ function summarizeAccountSession(row, persisted, liveIds, now) {
|
|
|
1011
1026
|
live: liveIds.has(row.id),
|
|
1012
1027
|
url: sessionWebUrl(row.id),
|
|
1013
1028
|
decryptable: false,
|
|
1029
|
+
readable: false,
|
|
1014
1030
|
active: row.active === true,
|
|
1015
1031
|
archived: typeof row.archivedAt === "number",
|
|
1016
1032
|
...typeof row.activeAt === "number" ? { activeAt: row.activeAt } : {},
|
|
@@ -1030,9 +1046,12 @@ function summarizeAccountSession(row, persisted, liveIds, now) {
|
|
|
1030
1046
|
if (!metadata) return base;
|
|
1031
1047
|
const meta = metadata;
|
|
1032
1048
|
const pending = pendingRequestsOf(agentState, now);
|
|
1049
|
+
const backgroundTasks = backgroundTasksReport(agentState?.backgroundTasks, now, { active: row.active === true });
|
|
1033
1050
|
return {
|
|
1034
1051
|
...base,
|
|
1035
1052
|
decryptable: true,
|
|
1053
|
+
readable: true,
|
|
1054
|
+
backgroundTasks,
|
|
1036
1055
|
...meta.summary?.text ? { title: meta.summary.text } : {},
|
|
1037
1056
|
...meta.path ? { cwd: meta.path } : {},
|
|
1038
1057
|
...meta.flavor ? { flavor: meta.flavor } : {},
|
|
@@ -1062,7 +1081,7 @@ function orderAccountSessions(summaries, options = {}) {
|
|
|
1062
1081
|
return rows.filter((summary) => rank(summary) < 2 || idleKept++ < recentLimit);
|
|
1063
1082
|
}
|
|
1064
1083
|
async function listAccountSessions(options = {}) {
|
|
1065
|
-
const token = await bearerToken();
|
|
1084
|
+
const token = await bearerToken$1();
|
|
1066
1085
|
const response = await axios.get(`${configuration.serverUrl}/v1/sessions`, {
|
|
1067
1086
|
headers: {
|
|
1068
1087
|
"Authorization": `Bearer ${token}`,
|
|
@@ -1078,7 +1097,7 @@ async function listAccountSessions(options = {}) {
|
|
|
1078
1097
|
const summaries = rows.filter((row) => typeof row?.id === "string").map((row) => summarizeAccountSession(row, persisted[row.id], liveIds, now)).filter((summary) => options.includeArchived || !summary.archived);
|
|
1079
1098
|
return orderAccountSessions(summaries, options);
|
|
1080
1099
|
}
|
|
1081
|
-
async function bearerToken() {
|
|
1100
|
+
async function bearerToken$1() {
|
|
1082
1101
|
const credentials = await readCredentialsForConfiguredRelay();
|
|
1083
1102
|
if (!credentials) throw new Error("CLI is not authenticated (no ~/.happy/access.key)");
|
|
1084
1103
|
return credentials.token;
|
|
@@ -1093,7 +1112,7 @@ function requirePersisted(sessionId) {
|
|
|
1093
1112
|
return persisted;
|
|
1094
1113
|
}
|
|
1095
1114
|
async function readSessionLog(sessionId, persisted, limit) {
|
|
1096
|
-
const token = await bearerToken();
|
|
1115
|
+
const token = await bearerToken$1();
|
|
1097
1116
|
const response = await axios.get(
|
|
1098
1117
|
`${configuration.serverUrl}/v3/sessions/${encodeURIComponent(sessionId)}/messages`,
|
|
1099
1118
|
{
|
|
@@ -1132,7 +1151,7 @@ async function readSessionTranscript(sessionId, limit, options = {}) {
|
|
|
1132
1151
|
};
|
|
1133
1152
|
}
|
|
1134
1153
|
async function readSessionMetadata(sessionId, persisted) {
|
|
1135
|
-
const token = await bearerToken();
|
|
1154
|
+
const token = await bearerToken$1();
|
|
1136
1155
|
const response = await axios.get(`${configuration.serverUrl}/v1/sessions/${encodeURIComponent(sessionId)}`, {
|
|
1137
1156
|
headers: {
|
|
1138
1157
|
"Authorization": `Bearer ${token}`,
|
|
@@ -1145,7 +1164,7 @@ async function readSessionMetadata(sessionId, persisted) {
|
|
|
1145
1164
|
return decrypt(decodeBase64(persisted.encryptionKey), persisted.encryptionVariant, decodeBase64(ciphertext));
|
|
1146
1165
|
}
|
|
1147
1166
|
async function readSessionState(sessionId, persisted) {
|
|
1148
|
-
const token = await bearerToken();
|
|
1167
|
+
const token = await bearerToken$1();
|
|
1149
1168
|
const response = await axios.get(`${configuration.serverUrl}/v1/sessions/${encodeURIComponent(sessionId)}`, {
|
|
1150
1169
|
headers: {
|
|
1151
1170
|
"Authorization": `Bearer ${token}`,
|
|
@@ -1197,7 +1216,7 @@ async function stopSession(sessionId) {
|
|
|
1197
1216
|
return await stopDaemonSession(sessionId);
|
|
1198
1217
|
}
|
|
1199
1218
|
async function archiveSession(sessionId) {
|
|
1200
|
-
const token = await bearerToken();
|
|
1219
|
+
const token = await bearerToken$1();
|
|
1201
1220
|
await axios.post(
|
|
1202
1221
|
`${configuration.serverUrl}/v1/sessions/${encodeURIComponent(sessionId)}/archive`,
|
|
1203
1222
|
{},
|
|
@@ -1211,7 +1230,7 @@ async function archiveSession(sessionId) {
|
|
|
1211
1230
|
);
|
|
1212
1231
|
}
|
|
1213
1232
|
async function unarchiveSession(sessionId) {
|
|
1214
|
-
const token = await bearerToken();
|
|
1233
|
+
const token = await bearerToken$1();
|
|
1215
1234
|
await axios.post(
|
|
1216
1235
|
`${configuration.serverUrl}/v1/sessions/${encodeURIComponent(sessionId)}/unarchive`,
|
|
1217
1236
|
{},
|
|
@@ -1247,7 +1266,7 @@ function explainNotLive(sessionId, status, resumeHint) {
|
|
|
1247
1266
|
}
|
|
1248
1267
|
}
|
|
1249
1268
|
const CLI_RESUME_HINT = "Use --resume to bring it back on this machine, or spawn a new session.";
|
|
1250
|
-
const defaultDeps = {
|
|
1269
|
+
const defaultDeps$1 = {
|
|
1251
1270
|
listLive: () => listDaemonSessions(),
|
|
1252
1271
|
readState: (sessionId, persisted) => readSessionState(sessionId, persisted),
|
|
1253
1272
|
send: (sessionId, persisted, text, client, options) => sendUserMessage(sessionId, persisted, text, client, options),
|
|
@@ -1258,7 +1277,7 @@ const defaultDeps = {
|
|
|
1258
1277
|
now: () => Date.now()
|
|
1259
1278
|
};
|
|
1260
1279
|
async function deliverToSession(sessionId, persisted, text, client, options = {}, deps = {}) {
|
|
1261
|
-
const d = { ...defaultDeps, ...deps };
|
|
1280
|
+
const d = { ...defaultDeps$1, ...deps };
|
|
1262
1281
|
const resumeHint = options.resumeHint ?? CLI_RESUME_HINT;
|
|
1263
1282
|
const classify = async () => {
|
|
1264
1283
|
const [live, server] = await Promise.all([d.listLive(), d.readState(sessionId, persisted)]);
|
|
@@ -1349,6 +1368,10 @@ async function waitUntilLive(classify, d, options) {
|
|
|
1349
1368
|
const SESSION_PEER_SENT_FROM = "session-peer";
|
|
1350
1369
|
const CLI_PEER_SENDER_ID = "cli";
|
|
1351
1370
|
const MESSAGE_FOOTER = "This message comes from another agent session on this machine, not from the user.";
|
|
1371
|
+
const MESSAGE_FOOTER_PREFIX = "This message comes from another agent session";
|
|
1372
|
+
function messageFooterFor(sender) {
|
|
1373
|
+
return sender.machine ? `${MESSAGE_FOOTER_PREFIX} on machine ${sanitizeHeaderValue(sender.machine, 128)}, not from the user.` : MESSAGE_FOOTER;
|
|
1374
|
+
}
|
|
1352
1375
|
function sanitizeHeaderValue(value, max = 200) {
|
|
1353
1376
|
if (!value) return "";
|
|
1354
1377
|
const collapsed = value.replace(/\s+/g, " ").trim().slice(0, max);
|
|
@@ -1358,6 +1381,7 @@ function senderFields(sender) {
|
|
|
1358
1381
|
const parts = [];
|
|
1359
1382
|
if (sender.flavor) parts.push(`agent ${sanitizeHeaderValue(sender.flavor, 32)}`);
|
|
1360
1383
|
if (sender.cwd) parts.push(`cwd ${sanitizeHeaderValue(sender.cwd, 400)}`);
|
|
1384
|
+
if (sender.machine) parts.push(`machine ${sanitizeHeaderValue(sender.machine, 128)}`);
|
|
1361
1385
|
return parts.map((part) => `; ${part}`).join("");
|
|
1362
1386
|
}
|
|
1363
1387
|
function quotedTitle(sender) {
|
|
@@ -1365,7 +1389,9 @@ function quotedTitle(sender) {
|
|
|
1365
1389
|
}
|
|
1366
1390
|
function formatSessionPeerMessage(input) {
|
|
1367
1391
|
const header = `[Very Happy session message ${input.id} from ${quotedTitle(input.from)} ${input.from.sessionId}${senderFields(input.from)}${input.replyTo ? `; re ${sanitizeHeaderValue(input.replyTo, 64)}` : ""}]`;
|
|
1368
|
-
const
|
|
1392
|
+
const base = messageFooterFor(input.from);
|
|
1393
|
+
const routed = input.from.machine ? " (it is routed to that machine for you)" : "";
|
|
1394
|
+
const footer = input.from.sessionId === CLI_PEER_SENDER_ID ? `${base} It was sent from the command line (\`very-happy sessions message\`), so there is no session to reply to; act on it here.` : `${base} Reply with session_message(to: "${input.from.sessionId}")${routed} \u2014 or \`very-happy sessions message ${input.from.sessionId} "<text>"\` \u2014 only when you have something to add (a question, a decision, a heads-up). Do not reply just to acknowledge or thank; the exchange ends when nothing is left to coordinate.`;
|
|
1369
1395
|
return `${header}
|
|
1370
1396
|
${input.body.trim()}
|
|
1371
1397
|
|
|
@@ -1427,7 +1453,7 @@ function resolveRepoIdentity(cwd, fs = realFs) {
|
|
|
1427
1453
|
}
|
|
1428
1454
|
return { root: null, common: null };
|
|
1429
1455
|
}
|
|
1430
|
-
const PEER_SCOPES = ["repo", "cwd", "machine"];
|
|
1456
|
+
const PEER_SCOPES$1 = ["repo", "cwd", "machine"];
|
|
1431
1457
|
function isPeerInScope(scope, self, other) {
|
|
1432
1458
|
if (scope === "machine") return true;
|
|
1433
1459
|
if (scope === "cwd") return resolve$1(self.cwd) === resolve$1(other.cwd);
|
|
@@ -1435,24 +1461,508 @@ function isPeerInScope(scope, self, other) {
|
|
|
1435
1461
|
return resolve$1(self.cwd) === resolve$1(other.cwd);
|
|
1436
1462
|
}
|
|
1437
1463
|
|
|
1464
|
+
const REMOTE_SESSION_OPS_MIN_CLI_VERSION = "0.2.157";
|
|
1465
|
+
const REMOTE_OPS_PROTOCOL_VERSION = 1;
|
|
1466
|
+
const REMOTE_TEXT_MAX_BYTES = 64 * 1024;
|
|
1467
|
+
const REMOTE_TRANSCRIPT_MAX_BYTES = 200 * 1024;
|
|
1468
|
+
const REMOTE_CALLS_PER_MINUTE = 60;
|
|
1469
|
+
const REMOTE_WRITE_CALLS_PER_MINUTE = 30;
|
|
1470
|
+
const REMOTE_LOCATE_MAX_IDS = 200;
|
|
1471
|
+
const REMOTE_OP_DEADLINE_MS = 25e3;
|
|
1472
|
+
const REMOTE_SEND_COMMIT_DEADLINE_MS = 18e3;
|
|
1473
|
+
const REMOTE_RESUME_WAIT_MS = 1e4;
|
|
1474
|
+
const REMOTE_WAIT_POLL_MS = 5e3;
|
|
1475
|
+
const REMOTE_ANSWER_MAX_BYTES = 64 * 1024;
|
|
1476
|
+
const REMOTE_TITLE_MAX_BYTES = 1024;
|
|
1477
|
+
const REMOTE_SENT_FROM_ALLOWED = ["cli", "assistant", "automation"];
|
|
1478
|
+
const LOCAL_ID_RE = /^[a-zA-Z0-9_.:-]{1,128}$/;
|
|
1479
|
+
function remoteError(code, message) {
|
|
1480
|
+
return { ok: false, error: { code, message } };
|
|
1481
|
+
}
|
|
1482
|
+
const SESSION_ID_RE = /^[a-zA-Z0-9_-]{1,128}$/;
|
|
1483
|
+
const PEER_SCOPES = ["repo", "cwd", "machine"];
|
|
1484
|
+
function isSessionId(value) {
|
|
1485
|
+
return typeof value === "string" && SESSION_ID_RE.test(value);
|
|
1486
|
+
}
|
|
1487
|
+
function utf8Bytes(text) {
|
|
1488
|
+
return Buffer.byteLength(text, "utf8");
|
|
1489
|
+
}
|
|
1490
|
+
function sanitizeSender(raw) {
|
|
1491
|
+
if (!raw || typeof raw !== "object") return null;
|
|
1492
|
+
const r = raw;
|
|
1493
|
+
if (typeof r.sessionId !== "string" || r.sessionId.length === 0 || r.sessionId.length > 128) return null;
|
|
1494
|
+
const optional = (key, max) => typeof r[key] === "string" && r[key].length > 0 ? { [key]: r[key].slice(0, max) } : {};
|
|
1495
|
+
return {
|
|
1496
|
+
sessionId: r.sessionId,
|
|
1497
|
+
...optional("title", 200),
|
|
1498
|
+
...optional("flavor", 32),
|
|
1499
|
+
...optional("cwd", 1024),
|
|
1500
|
+
...optional("machine", 128)
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
function guardRemoteSessionOpsRequest(method, raw, options) {
|
|
1504
|
+
if (!options.enabled) {
|
|
1505
|
+
return remoteError("disabled", 'Remote session operations are turned off on this machine (settings.json remoteSessionOps = "off")');
|
|
1506
|
+
}
|
|
1507
|
+
if (!raw || typeof raw !== "object") return remoteError("bad_request", "Request must be an object");
|
|
1508
|
+
const r = raw;
|
|
1509
|
+
if (r.v !== REMOTE_OPS_PROTOCOL_VERSION) return remoteError("bad_request", `Unsupported protocol version ${String(r.v)} (this daemon speaks v${REMOTE_OPS_PROTOCOL_VERSION})`);
|
|
1510
|
+
const from = r.from;
|
|
1511
|
+
if (!from || typeof from !== "object" || typeof from.host !== "string" || from.host.length === 0 || typeof from.cli !== "string") {
|
|
1512
|
+
return remoteError("bad_request", "from.host and from.cli are required");
|
|
1513
|
+
}
|
|
1514
|
+
const caller = {
|
|
1515
|
+
host: from.host.slice(0, 128),
|
|
1516
|
+
cli: from.cli.slice(0, 32),
|
|
1517
|
+
...typeof from.machineId === "string" && from.machineId.length > 0 ? { machineId: from.machineId.slice(0, 128) } : {},
|
|
1518
|
+
...isSessionId(from.sessionId) ? { sessionId: from.sessionId } : {}
|
|
1519
|
+
};
|
|
1520
|
+
const args = r.args && typeof r.args === "object" ? r.args : {};
|
|
1521
|
+
const limit = typeof args.limit === "number" && Number.isFinite(args.limit) && args.limit >= 1 ? Math.floor(args.limit) : void 0;
|
|
1522
|
+
let clean;
|
|
1523
|
+
switch (method) {
|
|
1524
|
+
case "sessions.list": {
|
|
1525
|
+
if (args.ids !== void 0) {
|
|
1526
|
+
if (!Array.isArray(args.ids) || !args.ids.every(isSessionId)) return remoteError("bad_request", "ids must be an array of session ids");
|
|
1527
|
+
if (args.ids.length > REMOTE_LOCATE_MAX_IDS) return remoteError("too_large", `ids: at most ${REMOTE_LOCATE_MAX_IDS} per call`);
|
|
1528
|
+
}
|
|
1529
|
+
const out = {
|
|
1530
|
+
...args.ids !== void 0 ? { ids: args.ids } : {},
|
|
1531
|
+
...args.all === true ? { all: true } : {},
|
|
1532
|
+
...typeof args.tag === "string" && args.tag.length > 0 ? { tag: args.tag.slice(0, 128) } : {},
|
|
1533
|
+
...limit !== void 0 ? { limit } : {}
|
|
1534
|
+
};
|
|
1535
|
+
clean = out;
|
|
1536
|
+
break;
|
|
1537
|
+
}
|
|
1538
|
+
case "sessions.read": {
|
|
1539
|
+
if (!isSessionId(args.sessionId)) return remoteError("bad_request", "sessionId is required");
|
|
1540
|
+
const out = { sessionId: args.sessionId, ...limit !== void 0 ? { limit } : {}, ...args.full === true ? { full: true } : {} };
|
|
1541
|
+
clean = out;
|
|
1542
|
+
break;
|
|
1543
|
+
}
|
|
1544
|
+
case "sessions.send": {
|
|
1545
|
+
if (!isSessionId(args.sessionId)) return remoteError("bad_request", "sessionId is required");
|
|
1546
|
+
if (typeof args.text !== "string" || args.text.trim().length === 0) return remoteError("bad_request", "text must be non-empty");
|
|
1547
|
+
if (utf8Bytes(args.text) > REMOTE_TEXT_MAX_BYTES) return remoteError("too_large", `text exceeds ${REMOTE_TEXT_MAX_BYTES} bytes`);
|
|
1548
|
+
if (args.localId !== void 0 && (typeof args.localId !== "string" || !LOCAL_ID_RE.test(args.localId))) return remoteError("bad_request", "localId must be a short id");
|
|
1549
|
+
const out = {
|
|
1550
|
+
sessionId: args.sessionId,
|
|
1551
|
+
text: args.text,
|
|
1552
|
+
...args.resume === true ? { resume: true } : {},
|
|
1553
|
+
...args.model === null || typeof args.model === "string" && args.model.length <= 128 ? { model: args.model } : {},
|
|
1554
|
+
...REMOTE_SENT_FROM_ALLOWED.includes(args.sentFrom) ? { sentFrom: args.sentFrom } : {},
|
|
1555
|
+
...typeof args.localId === "string" ? { localId: args.localId } : {}
|
|
1556
|
+
};
|
|
1557
|
+
clean = out;
|
|
1558
|
+
break;
|
|
1559
|
+
}
|
|
1560
|
+
case "sessions.peers": {
|
|
1561
|
+
if (args.scope !== void 0 && !PEER_SCOPES.includes(args.scope)) return remoteError("bad_request", `scope must be one of ${PEER_SCOPES.join(", ")}`);
|
|
1562
|
+
const out = {
|
|
1563
|
+
...args.scope !== void 0 ? { scope: args.scope } : {},
|
|
1564
|
+
...typeof args.cwd === "string" && args.cwd.length > 0 ? { cwd: args.cwd.slice(0, 4096) } : {}
|
|
1565
|
+
};
|
|
1566
|
+
clean = out;
|
|
1567
|
+
break;
|
|
1568
|
+
}
|
|
1569
|
+
case "sessions.message": {
|
|
1570
|
+
if (!isSessionId(args.to)) return remoteError("bad_request", "to is required");
|
|
1571
|
+
if (typeof args.body !== "string" || args.body.trim().length === 0) return remoteError("bad_request", "body must be non-empty");
|
|
1572
|
+
if (utf8Bytes(args.body) > REMOTE_TEXT_MAX_BYTES) return remoteError("too_large", `body exceeds ${REMOTE_TEXT_MAX_BYTES} bytes`);
|
|
1573
|
+
const sender = sanitizeSender(args.from);
|
|
1574
|
+
if (!sender) return remoteError("bad_request", "from.sessionId (the sender) is required");
|
|
1575
|
+
const out = {
|
|
1576
|
+
to: args.to,
|
|
1577
|
+
body: args.body,
|
|
1578
|
+
...typeof args.replyTo === "string" && args.replyTo.length > 0 ? { replyTo: args.replyTo.slice(0, 64) } : {},
|
|
1579
|
+
// The recipient's header names the machine the call came
|
|
1580
|
+
// from, as the caller identified itself — never a value the
|
|
1581
|
+
// sender picked for someone else.
|
|
1582
|
+
from: { ...sender, machine: caller.host }
|
|
1583
|
+
};
|
|
1584
|
+
clean = out;
|
|
1585
|
+
break;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
return { ok: true, request: { v: REMOTE_OPS_PROTOCOL_VERSION, from: caller, args: clean } };
|
|
1589
|
+
}
|
|
1590
|
+
class RemoteCallBudget {
|
|
1591
|
+
constructor(perMinute = REMOTE_CALLS_PER_MINUTE, now = Date.now) {
|
|
1592
|
+
this.perMinute = perMinute;
|
|
1593
|
+
this.now = now;
|
|
1594
|
+
this.tokens = perMinute;
|
|
1595
|
+
this.lastRefill = now();
|
|
1596
|
+
}
|
|
1597
|
+
tokens;
|
|
1598
|
+
lastRefill;
|
|
1599
|
+
/** Take one token; false = over budget (with ms until the next token). */
|
|
1600
|
+
take() {
|
|
1601
|
+
if (this.perMinute <= 0) return { ok: true };
|
|
1602
|
+
const t = this.now();
|
|
1603
|
+
const refill = (t - this.lastRefill) / 6e4 * this.perMinute;
|
|
1604
|
+
if (refill > 0) {
|
|
1605
|
+
this.tokens = Math.min(this.perMinute, this.tokens + refill);
|
|
1606
|
+
this.lastRefill = t;
|
|
1607
|
+
}
|
|
1608
|
+
if (this.tokens >= 1) {
|
|
1609
|
+
this.tokens -= 1;
|
|
1610
|
+
return { ok: true };
|
|
1611
|
+
}
|
|
1612
|
+
return { ok: false, retryAfterMs: Math.ceil((1 - this.tokens) / this.perMinute * 6e4) };
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
function sanitizeAuditValue(value, max = 64) {
|
|
1616
|
+
if (typeof value !== "string" || value.length === 0) return "?";
|
|
1617
|
+
return value.slice(0, max).replace(/[^\x20-\x7e]/g, "?");
|
|
1618
|
+
}
|
|
1619
|
+
function formatRemoteAuditLine(input) {
|
|
1620
|
+
const from = input.from;
|
|
1621
|
+
const opt = (value) => value === void 0 ? "-" : sanitizeAuditValue(value);
|
|
1622
|
+
return `[REMOTE SESSION OPS] ${sanitizeAuditValue(input.method)} from machine=${sanitizeAuditValue(from?.machineId)} host=${sanitizeAuditValue(from?.host)} cli=${sanitizeAuditValue(from?.cli, 32)} session=${opt(from?.sessionId)} target=${opt(input.sessionId)} \u2192 ${input.outcome} (${Math.max(0, Math.round(input.durationMs))}ms)`;
|
|
1623
|
+
}
|
|
1624
|
+
function capRemoteText(text, maxBytes, keep = "head") {
|
|
1625
|
+
if (utf8Bytes(text) <= maxBytes) return text;
|
|
1626
|
+
const buffer = Buffer.from(text, "utf8");
|
|
1627
|
+
const marker = "\u2026";
|
|
1628
|
+
const room = Math.max(0, maxBytes - Buffer.byteLength(marker));
|
|
1629
|
+
const slice = keep === "head" ? buffer.subarray(0, room) : buffer.subarray(buffer.length - room);
|
|
1630
|
+
const clean = slice.toString("utf8").replace(/^�+|�+$/g, "");
|
|
1631
|
+
return keep === "head" ? clean + marker : marker + clean;
|
|
1632
|
+
}
|
|
1633
|
+
function capRemoteTranscript(transcript, maxBytes = REMOTE_TRANSCRIPT_MAX_BYTES) {
|
|
1634
|
+
if (utf8Bytes(transcript) <= maxBytes) return { transcript, truncated: false };
|
|
1635
|
+
const buffer = Buffer.from(transcript, "utf8");
|
|
1636
|
+
const marker = "\u2026 [truncated by the remote machine: transcript exceeded the RPC budget; use --limit] \u2026\n";
|
|
1637
|
+
const tail = buffer.subarray(buffer.length - (maxBytes - Buffer.byteLength(marker))).toString("utf8");
|
|
1638
|
+
return { transcript: marker + tail.replace(/^�+/, ""), truncated: true };
|
|
1639
|
+
}
|
|
1640
|
+
function parseHappyClientVersion(value) {
|
|
1641
|
+
if (typeof value !== "string") return null;
|
|
1642
|
+
const match = value.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
1643
|
+
return match ? `${match[1]}.${match[2]}.${match[3]}` : null;
|
|
1644
|
+
}
|
|
1645
|
+
function compareVersions(a, b) {
|
|
1646
|
+
const pa = a.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
1647
|
+
const pb = b.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
1648
|
+
for (let i = 0; i < 3; i++) {
|
|
1649
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
1650
|
+
if (d !== 0) return d;
|
|
1651
|
+
}
|
|
1652
|
+
return 0;
|
|
1653
|
+
}
|
|
1654
|
+
function supportsRemoteSessionOps(cliVersion) {
|
|
1655
|
+
if (!cliVersion) return null;
|
|
1656
|
+
return compareVersions(cliVersion, REMOTE_SESSION_OPS_MIN_CLI_VERSION) >= 0;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
const REMOTE_CLIENT_TAG = "session-ops";
|
|
1660
|
+
const REMOTE_RPC_TIMEOUT_MS = 3e4;
|
|
1661
|
+
const REMOTE_LOCATE_MAX_MACHINES = 8;
|
|
1662
|
+
class RemoteSessionOpsError extends Error {
|
|
1663
|
+
constructor(code, message, machineId, ambiguous = false) {
|
|
1664
|
+
super(message);
|
|
1665
|
+
this.code = code;
|
|
1666
|
+
this.machineId = machineId;
|
|
1667
|
+
this.ambiguous = ambiguous;
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
async function bearerToken() {
|
|
1671
|
+
const credentials = await readCredentialsForConfiguredRelay();
|
|
1672
|
+
if (!credentials) throw new Error("CLI is not authenticated (no ~/.happy/access.key)");
|
|
1673
|
+
return credentials.token;
|
|
1674
|
+
}
|
|
1675
|
+
async function listAccountMachines() {
|
|
1676
|
+
const token = await bearerToken();
|
|
1677
|
+
const response = await axios.get(`${configuration.serverUrl}/v1/machines`, {
|
|
1678
|
+
headers: { "Authorization": `Bearer ${token}`, "X-Happy-Client": `${REMOTE_CLIENT_TAG}/${configuration.currentCliVersion}` },
|
|
1679
|
+
timeout: 15e3
|
|
1680
|
+
});
|
|
1681
|
+
const rows = Array.isArray(response.data) ? response.data : [];
|
|
1682
|
+
return rows.filter((row) => !!row && typeof row === "object" && typeof row.id === "string").map((row) => ({
|
|
1683
|
+
id: row.id,
|
|
1684
|
+
active: row.active === true,
|
|
1685
|
+
...typeof row.activeAt === "number" ? { activeAt: row.activeAt } : {},
|
|
1686
|
+
cliVersion: parseHappyClientVersion(row.lastHappyClient)
|
|
1687
|
+
}));
|
|
1688
|
+
}
|
|
1689
|
+
async function callerIdentity(env = process.env) {
|
|
1690
|
+
const settings = await readSettings().catch(() => null);
|
|
1691
|
+
const sessionId = env.VH_PEER_SESSION_ID;
|
|
1692
|
+
return {
|
|
1693
|
+
...settings?.machineId ? { machineId: settings.machineId } : {},
|
|
1694
|
+
host: hostname(),
|
|
1695
|
+
cli: configuration.currentCliVersion,
|
|
1696
|
+
...sessionId && /^[a-zA-Z0-9_-]{1,128}$/.test(sessionId) ? { sessionId } : {}
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
const defaultDeps = {
|
|
1700
|
+
listMachines: listAccountMachines,
|
|
1701
|
+
selfMachineId: async () => (await readSettings().catch(() => null))?.machineId,
|
|
1702
|
+
openTransport: async () => {
|
|
1703
|
+
const socket = await openUserScopedSocket(await bearerToken());
|
|
1704
|
+
return {
|
|
1705
|
+
call: (method, paramsJson) => socket.rpcCall({ method, params: paramsJson }, REMOTE_RPC_TIMEOUT_MS),
|
|
1706
|
+
close: () => socket.close()
|
|
1707
|
+
};
|
|
1708
|
+
},
|
|
1709
|
+
caller: () => callerIdentity(),
|
|
1710
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
1711
|
+
now: () => Date.now()
|
|
1712
|
+
};
|
|
1713
|
+
function withDefaults(deps) {
|
|
1714
|
+
return { ...defaultDeps, ...deps };
|
|
1715
|
+
}
|
|
1716
|
+
function pickRemoteCandidates(machines, selfId) {
|
|
1717
|
+
const candidates = [];
|
|
1718
|
+
const skipped = [];
|
|
1719
|
+
for (const machine of machines) {
|
|
1720
|
+
if (selfId && machine.id === selfId) {
|
|
1721
|
+
skipped.push({ machine, reason: "self" });
|
|
1722
|
+
continue;
|
|
1723
|
+
}
|
|
1724
|
+
if (!machine.active) {
|
|
1725
|
+
skipped.push({ machine, reason: "offline" });
|
|
1726
|
+
continue;
|
|
1727
|
+
}
|
|
1728
|
+
if (supportsRemoteSessionOps(machine.cliVersion) === false) {
|
|
1729
|
+
skipped.push({ machine, reason: "too_old" });
|
|
1730
|
+
continue;
|
|
1731
|
+
}
|
|
1732
|
+
candidates.push(machine);
|
|
1733
|
+
}
|
|
1734
|
+
candidates.sort((a, b) => (b.activeAt ?? 0) - (a.activeAt ?? 0));
|
|
1735
|
+
return { candidates: candidates.slice(0, REMOTE_LOCATE_MAX_MACHINES), skipped };
|
|
1736
|
+
}
|
|
1737
|
+
function describeAge(activeAt, now) {
|
|
1738
|
+
if (activeAt === void 0) return "never seen";
|
|
1739
|
+
const minutes = Math.round((now - activeAt) / 6e4);
|
|
1740
|
+
if (minutes < 2) return "seen just now";
|
|
1741
|
+
if (minutes < 120) return `last seen ${minutes}m ago`;
|
|
1742
|
+
if (minutes < 48 * 60) return `last seen ${Math.round(minutes / 60)}h ago`;
|
|
1743
|
+
return `last seen ${Math.round(minutes / 1440)}d ago`;
|
|
1744
|
+
}
|
|
1745
|
+
async function resolveExplicitMachine(machineId, deps) {
|
|
1746
|
+
const machines = await deps.listMachines();
|
|
1747
|
+
const machine = machines.find((row) => row.id === machineId);
|
|
1748
|
+
if (!machine) throw new RemoteSessionOpsError("unknown_machine", `Machine ${machineId} is not on this account`, machineId);
|
|
1749
|
+
if (!machine.active) throw new RemoteSessionOpsError("offline", `Machine ${machineId} is offline (${describeAge(machine.activeAt, deps.now())}); its sessions cannot be reached until its daemon reconnects`, machineId);
|
|
1750
|
+
if (supportsRemoteSessionOps(machine.cliVersion) === false) {
|
|
1751
|
+
throw new RemoteSessionOpsError("too_old", `Machine ${machineId} runs CLI ${machine.cliVersion}; cross-machine session operations need \u2265 ${REMOTE_SESSION_OPS_MIN_CLI_VERSION} on the target`, machineId);
|
|
1752
|
+
}
|
|
1753
|
+
return machine;
|
|
1754
|
+
}
|
|
1755
|
+
async function callRemoteSessionOp(transport, machineId, method, args, from) {
|
|
1756
|
+
const params = JSON.stringify({ v: REMOTE_OPS_PROTOCOL_VERSION, from, args });
|
|
1757
|
+
let ack;
|
|
1758
|
+
try {
|
|
1759
|
+
ack = await transport.call(`${machineId}:${method}`, params);
|
|
1760
|
+
} catch (error) {
|
|
1761
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1762
|
+
throw new RemoteSessionOpsError("unreachable", `Machine ${machineId} did not answer ${method} (${reason})`, machineId, true);
|
|
1763
|
+
}
|
|
1764
|
+
if (!ack?.ok) {
|
|
1765
|
+
const reason = ack?.error ?? "no ack";
|
|
1766
|
+
if (/not available/i.test(reason)) {
|
|
1767
|
+
throw new RemoteSessionOpsError("unreachable", `Machine ${machineId} did not answer ${method}: its daemon is offline, restarting, or runs a CLI older than ${REMOTE_SESSION_OPS_MIN_CLI_VERSION}`, machineId);
|
|
1768
|
+
}
|
|
1769
|
+
throw new RemoteSessionOpsError("unreachable", `Machine ${machineId}: ${reason}`, machineId, true);
|
|
1770
|
+
}
|
|
1771
|
+
let envelope;
|
|
1772
|
+
try {
|
|
1773
|
+
envelope = typeof ack.result === "string" ? JSON.parse(ack.result) : ack.result;
|
|
1774
|
+
} catch {
|
|
1775
|
+
throw new RemoteSessionOpsError("unreachable", `Machine ${machineId} returned an unreadable ${method} response`, machineId);
|
|
1776
|
+
}
|
|
1777
|
+
if (!envelope || typeof envelope !== "object") throw new RemoteSessionOpsError("unreachable", `Machine ${machineId} returned an empty ${method} response`, machineId);
|
|
1778
|
+
if ("ok" in envelope) {
|
|
1779
|
+
if (envelope.ok) return envelope.result;
|
|
1780
|
+
throw new RemoteSessionOpsError(envelope.error.code, `Machine ${machineId}: ${envelope.error.message}`, machineId);
|
|
1781
|
+
}
|
|
1782
|
+
throw new RemoteSessionOpsError("internal", `Machine ${machineId}: ${envelope.error ?? "remote handler failed"}`, machineId);
|
|
1783
|
+
}
|
|
1784
|
+
async function locateRemoteSession(transport, sessionId, options, deps) {
|
|
1785
|
+
const from = await deps.caller();
|
|
1786
|
+
if (options.machineId) {
|
|
1787
|
+
const machine = await resolveExplicitMachine(options.machineId, deps);
|
|
1788
|
+
const listing = await callRemoteSessionOp(transport, machine.id, "sessions.list", { ids: [sessionId] }, from);
|
|
1789
|
+
if (!listing.sessions.some((row) => row.id === sessionId)) {
|
|
1790
|
+
throw new RemoteSessionOpsError("no_local_key", `Machine ${machine.id} (${listing.host}) has no key for session ${sessionId}: it was not spawned there (or is older than 14 days)`, machine.id);
|
|
1791
|
+
}
|
|
1792
|
+
return { machine, host: listing.host };
|
|
1793
|
+
}
|
|
1794
|
+
const { candidates, skipped } = pickRemoteCandidates(await deps.listMachines(), await deps.selfMachineId());
|
|
1795
|
+
const failures = [];
|
|
1796
|
+
for (const machine of candidates) {
|
|
1797
|
+
try {
|
|
1798
|
+
const listing = await callRemoteSessionOp(transport, machine.id, "sessions.list", { ids: [sessionId] }, from);
|
|
1799
|
+
if (listing.sessions.some((row) => row.id === sessionId)) return { machine, host: listing.host };
|
|
1800
|
+
failures.push(`${listing.host || machine.id}: not there`);
|
|
1801
|
+
} catch (error) {
|
|
1802
|
+
failures.push(`${machine.id}: ${error instanceof RemoteSessionOpsError ? error.code : error instanceof Error ? error.message : String(error)}`);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
const skippedText = skipped.filter((s) => s.reason !== "self").map((s) => `${s.machine.id}: ${s.reason}`);
|
|
1806
|
+
const detail = [...failures, ...skippedText];
|
|
1807
|
+
throw new RemoteSessionOpsError(
|
|
1808
|
+
"not_located",
|
|
1809
|
+
`Session ${sessionId} is not on this machine and no online machine of this account reports it${detail.length ? ` (${detail.join("; ")})` : " (no other machines are online)"}. Pass --machine <id> to name one, or bring its machine online.`
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
async function withRemoteTransport(deps, fn) {
|
|
1813
|
+
const transport = await deps.openTransport();
|
|
1814
|
+
try {
|
|
1815
|
+
return await fn(transport);
|
|
1816
|
+
} finally {
|
|
1817
|
+
try {
|
|
1818
|
+
transport.close();
|
|
1819
|
+
} catch {
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
async function readRemoteTranscript(sessionId, options = {}, overrides = {}) {
|
|
1824
|
+
const deps = withDefaults(overrides);
|
|
1825
|
+
return withRemoteTransport(deps, async (transport) => {
|
|
1826
|
+
const located = await locateRemoteSession(transport, sessionId, { machineId: options.machineId }, deps);
|
|
1827
|
+
return callRemoteSessionOp(transport, located.machine.id, "sessions.read", {
|
|
1828
|
+
sessionId,
|
|
1829
|
+
...options.limit !== void 0 ? { limit: options.limit } : {},
|
|
1830
|
+
...options.full ? { full: true } : {}
|
|
1831
|
+
}, await deps.caller());
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
async function waitForRemoteTurnEnd(sessionId, options, overrides = {}) {
|
|
1835
|
+
const deps = withDefaults(overrides);
|
|
1836
|
+
const pollMs = options.pollMs ?? REMOTE_WAIT_POLL_MS;
|
|
1837
|
+
return withRemoteTransport(deps, async (transport) => {
|
|
1838
|
+
const located = await locateRemoteSession(transport, sessionId, { machineId: options.machineId }, deps);
|
|
1839
|
+
const from = await deps.caller();
|
|
1840
|
+
const deadline = deps.now() + options.timeoutMs;
|
|
1841
|
+
const args = { sessionId, ...options.limit !== void 0 ? { limit: options.limit } : {}, ...options.full ? { full: true } : {} };
|
|
1842
|
+
while (true) {
|
|
1843
|
+
const read = await callRemoteSessionOp(transport, located.machine.id, "sessions.read", args, from);
|
|
1844
|
+
if (read.turn.ended) return { read, timedOut: false };
|
|
1845
|
+
if (deps.now() + pollMs > deadline) return { read, timedOut: true };
|
|
1846
|
+
await deps.sleep(pollMs);
|
|
1847
|
+
}
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
async function sendRemoteMessage(sessionId, text, options = {}, overrides = {}) {
|
|
1851
|
+
const deps = withDefaults(overrides);
|
|
1852
|
+
return withRemoteTransport(deps, async (transport) => {
|
|
1853
|
+
const located = await locateRemoteSession(transport, sessionId, { machineId: options.machineId }, deps);
|
|
1854
|
+
const from = await deps.caller();
|
|
1855
|
+
const localId = options.localId ?? `remote-send-${randomUUID()}`;
|
|
1856
|
+
const args = {
|
|
1857
|
+
sessionId,
|
|
1858
|
+
text,
|
|
1859
|
+
localId,
|
|
1860
|
+
...options.resume ? { resume: true } : {},
|
|
1861
|
+
...options.model !== void 0 ? { model: options.model } : {},
|
|
1862
|
+
...options.sentFrom !== void 0 ? { sentFrom: options.sentFrom } : {}
|
|
1863
|
+
};
|
|
1864
|
+
const attempt = () => callRemoteSessionOp(transport, located.machine.id, "sessions.send", args, from);
|
|
1865
|
+
let result;
|
|
1866
|
+
try {
|
|
1867
|
+
result = await attempt();
|
|
1868
|
+
} catch (error) {
|
|
1869
|
+
const retryable = error instanceof RemoteSessionOpsError && (error.ambiguous || error.code === "timeout");
|
|
1870
|
+
if (!retryable) throw error;
|
|
1871
|
+
result = await attempt();
|
|
1872
|
+
}
|
|
1873
|
+
return { ...result, machine: { id: located.machine.id, host: located.host } };
|
|
1874
|
+
});
|
|
1875
|
+
}
|
|
1876
|
+
async function sendRemotePeerMessage(args, options = {}, overrides = {}) {
|
|
1877
|
+
const deps = withDefaults(overrides);
|
|
1878
|
+
return withRemoteTransport(deps, async (transport) => {
|
|
1879
|
+
const located = await locateRemoteSession(transport, args.to, { machineId: options.machineId }, deps);
|
|
1880
|
+
const caller = await deps.caller();
|
|
1881
|
+
const result = await callRemoteSessionOp(transport, located.machine.id, "sessions.message", {
|
|
1882
|
+
to: args.to,
|
|
1883
|
+
body: args.body,
|
|
1884
|
+
...args.replyTo ? { replyTo: args.replyTo } : {},
|
|
1885
|
+
from: { ...args.from, machine: args.from.machine ?? caller.host }
|
|
1886
|
+
}, caller);
|
|
1887
|
+
return { ...result, machine: { id: located.machine.id, host: located.host } };
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
async function listRemotePeers(machineId, options = {}, overrides = {}) {
|
|
1891
|
+
const deps = withDefaults(overrides);
|
|
1892
|
+
return withRemoteTransport(deps, async (transport) => {
|
|
1893
|
+
const machine = await resolveExplicitMachine(machineId, deps);
|
|
1894
|
+
return callRemoteSessionOp(transport, machine.id, "sessions.peers", {
|
|
1895
|
+
...options.scope ? { scope: options.scope } : {},
|
|
1896
|
+
...options.cwd ? { cwd: options.cwd } : {}
|
|
1897
|
+
}, await deps.caller());
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
async function listRemoteSessions(machineId, options = {}, overrides = {}) {
|
|
1901
|
+
const deps = withDefaults(overrides);
|
|
1902
|
+
return withRemoteTransport(deps, async (transport) => {
|
|
1903
|
+
const machine = await resolveExplicitMachine(machineId, deps);
|
|
1904
|
+
return callRemoteSessionOp(transport, machine.id, "sessions.list", {
|
|
1905
|
+
...options.tag ? { tag: options.tag } : {},
|
|
1906
|
+
...options.limit !== void 0 ? { limit: options.limit } : {}
|
|
1907
|
+
}, await deps.caller());
|
|
1908
|
+
});
|
|
1909
|
+
}
|
|
1910
|
+
async function fillForeignAccountRows(rows, overrides = {}) {
|
|
1911
|
+
const deps = withDefaults(overrides);
|
|
1912
|
+
const pending = new Set(rows.filter((row) => !row.decryptable).map((row) => row.id));
|
|
1913
|
+
const asked = [];
|
|
1914
|
+
if (pending.size === 0) return { rows, asked, skipped: [] };
|
|
1915
|
+
const { candidates, skipped } = pickRemoteCandidates(await deps.listMachines(), await deps.selfMachineId());
|
|
1916
|
+
const filled = /* @__PURE__ */ new Map();
|
|
1917
|
+
if (candidates.length > 0) {
|
|
1918
|
+
await withRemoteTransport(deps, async (transport) => {
|
|
1919
|
+
const from = await deps.caller();
|
|
1920
|
+
for (const machine of candidates) {
|
|
1921
|
+
if (pending.size === 0) break;
|
|
1922
|
+
try {
|
|
1923
|
+
const listing = await callRemoteSessionOp(transport, machine.id, "sessions.list", { ids: [...pending], all: true }, from);
|
|
1924
|
+
let count = 0;
|
|
1925
|
+
for (const row of listing.sessions) {
|
|
1926
|
+
if (!pending.has(row.id)) continue;
|
|
1927
|
+
pending.delete(row.id);
|
|
1928
|
+
count++;
|
|
1929
|
+
filled.set(row.id, { ...row, decryptable: false, readable: true, via: machine.id, machine: { id: machine.id, host: listing.host } });
|
|
1930
|
+
}
|
|
1931
|
+
asked.push({ machineId: machine.id, host: listing.host, filled: count });
|
|
1932
|
+
} catch (error) {
|
|
1933
|
+
asked.push({ machineId: machine.id, filled: 0, error: error instanceof Error ? error.message : String(error) });
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
return {
|
|
1939
|
+
rows: rows.map((row) => filled.get(row.id) ?? row),
|
|
1940
|
+
asked,
|
|
1941
|
+
skipped: skipped.filter((s) => s.reason !== "self").map((s) => ({ machineId: s.machine.id, reason: s.reason }))
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1438
1945
|
const SESSION_PEER_TOOL_NAMES = ["session_message", "session_peers"];
|
|
1439
1946
|
const SESSION_PEER_TOOL_SCHEMAS = {
|
|
1440
1947
|
session_message: {
|
|
1441
1948
|
title: "Message Another Session",
|
|
1442
1949
|
readOnly: false,
|
|
1443
|
-
description: "Send a message to another agent session
|
|
1950
|
+
description: "Send a message to another agent session (find ids with session_peers). It lands in that session's chat with your title, session id and cwd, and tells the peer how to reply to you. Use it to coordinate when you both edit the same files: say what you are changing and ask what they are changing. Returns as soon as the message is queued; the reply arrives later as a message from that session. Sessions on this machine are reached directly; a session spawned by another machine of this account is routed to that machine's daemon (it must be online). Terminal-mirror sessions cannot receive messages.",
|
|
1444
1951
|
inputSchema: {
|
|
1445
1952
|
to: z.string().min(1).max(128).describe("Target session id (from session_peers, or a peer's message header)"),
|
|
1446
1953
|
body: z.string().min(1).max(16e3).describe("The message text"),
|
|
1447
|
-
replyTo: z.string().min(1).max(64).optional().describe("Id of the peer message you are answering (from its header), if any")
|
|
1954
|
+
replyTo: z.string().min(1).max(64).optional().describe("Id of the peer message you are answering (from its header), if any"),
|
|
1955
|
+
machineId: z.string().min(1).max(128).optional().describe("Machine id the target session runs on, when known (skips the lookup across the account's machines)")
|
|
1448
1956
|
}
|
|
1449
1957
|
},
|
|
1450
1958
|
session_peers: {
|
|
1451
1959
|
title: "List Peer Sessions",
|
|
1452
1960
|
readOnly: true,
|
|
1453
|
-
description: 'List the other agent sessions running on this machine and the files each edited in the last 30 minutes. Default scope "repo": sessions in the same git repository, including its other worktrees (sameWorktree says whether they share your checkout). "cwd": the same directory only. "machine": every live session here.',
|
|
1961
|
+
description: 'List the other agent sessions running on this machine and the files each edited in the last 30 minutes. Default scope "repo": sessions in the same git repository, including its other worktrees (sameWorktree says whether they share your checkout). "cwd": the same directory only. "machine": every live session here. With machineId, list the live sessions on that other machine of this account instead (scope "machine" unless you pass a cwd that exists there).',
|
|
1454
1962
|
inputSchema: {
|
|
1455
|
-
scope: z.enum(["repo", "cwd", "machine"]).optional().describe("repo (default) | cwd | machine")
|
|
1963
|
+
scope: z.enum(["repo", "cwd", "machine"]).optional().describe("repo (default) | cwd | machine"),
|
|
1964
|
+
machineId: z.string().min(1).max(128).optional().describe("Another machine of this account (from the web's machine list) to list instead of this one"),
|
|
1965
|
+
cwd: z.string().min(1).max(4096).optional().describe("With machineId: a directory on that machine to compute repo/cwd scope from")
|
|
1456
1966
|
}
|
|
1457
1967
|
}
|
|
1458
1968
|
};
|
|
@@ -1493,19 +2003,27 @@ async function sendPeerMessage(context, args) {
|
|
|
1493
2003
|
if (args.to === self.sessionId) throw new Error("That is this session; message a peer instead");
|
|
1494
2004
|
const body = typeof args.body === "string" ? args.body.trim() : "";
|
|
1495
2005
|
if (!body) throw new Error("body must be non-empty");
|
|
2006
|
+
const now = (context.now ?? Date.now)();
|
|
1496
2007
|
const persisted = (context.readPersisted ?? readPersistedSessions)()[args.to];
|
|
1497
|
-
if (!persisted) {
|
|
1498
|
-
|
|
2008
|
+
if (!persisted || args.machineId) {
|
|
2009
|
+
const remote = context.remoteMessage === void 0 ? sendRemotePeerMessage : context.remoteMessage;
|
|
2010
|
+
if (remote === null) {
|
|
2011
|
+
throw new Error(`Session ${args.to} is not on this machine (no local key)`);
|
|
2012
|
+
}
|
|
2013
|
+
if (context.sentTo) {
|
|
2014
|
+
const budget = checkPeerSendBudget(context.sentTo, args.to, now);
|
|
2015
|
+
if (!budget.ok) throw new Error(replyLoopRefusal(budget.count, args.to));
|
|
2016
|
+
}
|
|
2017
|
+
const result2 = await remote({ to: args.to, body, replyTo: args.replyTo, from: self, machineId: args.machineId });
|
|
2018
|
+
context.sentTo?.get(args.to)?.push(now);
|
|
2019
|
+
return result2;
|
|
1499
2020
|
}
|
|
1500
2021
|
const live = (await (context.listPeers ?? listDaemonPeers)()).find((session) => session.sessionId === args.to);
|
|
1501
2022
|
if (!live) throw new Error(`Session ${args.to} is not running on this machine; nothing would read the message`);
|
|
1502
2023
|
if (live.kind === "mirror") throw new Error(`Session ${args.to} is a terminal mirror (a person typing claude in a terminal); it cannot receive messages`);
|
|
1503
|
-
const now = (context.now ?? Date.now)();
|
|
1504
2024
|
if (context.sentTo) {
|
|
1505
2025
|
const budget = checkPeerSendBudget(context.sentTo, args.to, now);
|
|
1506
|
-
if (!budget.ok)
|
|
1507
|
-
throw new Error(`Refused: this session already sent ${budget.count} messages to ${args.to} in the last ${Math.round(PEER_MESSAGE_RATE_WINDOW_MS / 6e4)} minutes. That is a reply loop, not coordination \u2014 STOP replying to that session now; do not send acknowledgements or thanks. Continue your own work.`);
|
|
1508
|
-
}
|
|
2026
|
+
if (!budget.ok) throw new Error(replyLoopRefusal(budget.count, args.to));
|
|
1509
2027
|
}
|
|
1510
2028
|
const messageId = randomUUID().slice(0, 8);
|
|
1511
2029
|
const text = formatSessionPeerMessage({ id: messageId, from: self, body, replyTo: args.replyTo });
|
|
@@ -1521,13 +2039,27 @@ async function sendPeerMessage(context, args) {
|
|
|
1521
2039
|
...result.error ? { error: result.error } : {}
|
|
1522
2040
|
};
|
|
1523
2041
|
}
|
|
2042
|
+
function replyLoopRefusal(count, to) {
|
|
2043
|
+
return `Refused: this session already sent ${count} messages to ${to} in the last ${Math.round(PEER_MESSAGE_RATE_WINDOW_MS / 6e4)} minutes. That is a reply loop, not coordination \u2014 STOP replying to that session now; do not send acknowledgements or thanks. Continue your own work.`;
|
|
2044
|
+
}
|
|
1524
2045
|
async function executeSessionPeerTool(name, args, context) {
|
|
1525
2046
|
switch (name) {
|
|
1526
2047
|
case "session_message":
|
|
1527
|
-
return sendPeerMessage(context, {
|
|
2048
|
+
return sendPeerMessage(context, {
|
|
2049
|
+
to: String(args?.to ?? ""),
|
|
2050
|
+
body: String(args?.body ?? ""),
|
|
2051
|
+
...args?.replyTo ? { replyTo: String(args.replyTo) } : {},
|
|
2052
|
+
...args?.machineId ? { machineId: String(args.machineId) } : {}
|
|
2053
|
+
});
|
|
1528
2054
|
case "session_peers": {
|
|
1529
2055
|
const scope = args?.scope;
|
|
1530
|
-
if (scope !== void 0 && !PEER_SCOPES.includes(scope)) throw new Error(`scope must be one of ${PEER_SCOPES.join(", ")}`);
|
|
2056
|
+
if (scope !== void 0 && !PEER_SCOPES$1.includes(scope)) throw new Error(`scope must be one of ${PEER_SCOPES$1.join(", ")}`);
|
|
2057
|
+
if (args?.machineId) {
|
|
2058
|
+
return (context.remotePeers ?? listRemotePeers)(String(args.machineId), {
|
|
2059
|
+
...scope !== void 0 ? { scope } : {},
|
|
2060
|
+
...typeof args.cwd === "string" && args.cwd ? { cwd: args.cwd } : {}
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
1531
2063
|
return listPeerSessions(context, scope ?? "repo");
|
|
1532
2064
|
}
|
|
1533
2065
|
}
|
|
@@ -1610,4 +2142,4 @@ const REPORT_PROGRESS_TOOL_NAME = "report_progress";
|
|
|
1610
2142
|
const REPORT_PROGRESS_TOOL_TITLE = "Report Progress to the Task Board";
|
|
1611
2143
|
const REPORT_PROGRESS_TOOL_DESCRIPTION = `Report what you are doing to the user's task board, so they can see the state of this session at a glance without reading the transcript. Call it at meaningful milestones only: when you start substantive work, when you get blocked and need the user, when something is ready for their review, and when you are done. Do NOT call it after every tool call or every file edit \u2014 it is a status line, not a log. Set attention to "blocked" when you cannot proceed without the user, "review" when you want them to look at something, and "none" for ordinary progress. Keep progress to one short line describing the current state (the user reads it on a card).`;
|
|
1612
2144
|
|
|
1613
|
-
export {
|
|
2145
|
+
export { capRemoteText as $, isAutomationsUnavailable as A, AUTOMATIONS_UNAVAILABLE_MESSAGE as B, readTeamScope as C, DEFAULT_RECENT_LIMIT as D, teamScopePath as E, readSessionMetadata as F, waitForSessionKey as G, sendUserMessage as H, teamRequest as I, createTeamsClient as J, trimIdent as K, AGENT_GUIDANCE as L, MAX_READ_LIMIT as M, formatEditConflictNotice as N, REMOTE_WRITE_CALLS_PER_MINUTE as O, PREVIEW_TOOL_NAME as P, guardRemoteSessionOpsRequest as Q, RemoteCallBudget as R, SESSION_PEER_SENT_FROM as S, TurnWaitTimeoutError as T, remoteError as U, REMOTE_OP_DEADLINE_MS as V, formatRemoteAuditLine as W, REMOTE_SEND_COMMIT_DEADLINE_MS as X, REMOTE_RESUME_WAIT_MS as Y, CLI_PEER_SENDER_ID as Z, capRemoteTranscript as _, PREVIEW_TOOL_TITLE as a, REMOTE_ANSWER_MAX_BYTES as a0, REMOTE_TITLE_MAX_BYTES as a1, createAutomationsClient as a2, AutomationsRequestError as a3, analyzeLatestTurn as a4, readSessionLog as a5, TURN_WINDOW as a6, userPromptText as a7, TeamConnectionPreflightError as a8, RemoteSessionOpsError as a9, REPORT_PROGRESS_TOOL_NAME as aa, TEAM_TOOL_NAMES as ab, AUTOMATION_TOOL_NAMES as ac, SESSION_PEER_TOOL_NAMES as ad, createSessionPeerToolExecutor as ae, REPORT_PROGRESS_TOOL_TITLE as af, REPORT_PROGRESS_TOOL_DESCRIPTION as ag, PREVIEW_TOOL_DESCRIPTION as b, registerAutomationTools as c, registerSessionPeerTools as d, listRemoteSessions as e, fillForeignAccountRows as f, listSessions as g, cliPeerSelf as h, listRemotePeers as i, listPeerSessions as j, readSessionTranscript as k, listAccountSessions as l, waitForRemoteTurnEnd as m, readRemoteTranscript as n, stopSession as o, resolvePermissionRequest as p, archiveSession as q, registerTeamsTools as r, sendPeerMessage as s, PEER_SCOPES$1 as t, deliverToSession as u, sendRemoteMessage as v, waitForTurnEnd as w, sessionWebUrl as x, authenticatedAutomationsClient as y, automationRunIdFromEnv as z };
|