svamp-cli 0.1.69 → 0.1.71

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.
@@ -1,5 +1,5 @@
1
1
  import{createRequire as _pkgrollCR}from"node:module";const require=_pkgrollCR(import.meta.url);import os__default from 'os';
2
- import fs, { mkdir as mkdir$1, readdir, readFile, writeFile, rename, unlink } from 'fs/promises';
2
+ import fs, { mkdir as mkdir$1, readdir, readFile, writeFile as writeFile$1, rename, unlink } from 'fs/promises';
3
3
  import { readFileSync as readFileSync$1, mkdirSync, writeFileSync, renameSync, existsSync as existsSync$1, copyFileSync, unlinkSync, watch, rmdirSync } from 'fs';
4
4
  import path, { join, dirname, resolve, basename } from 'path';
5
5
  import { fileURLToPath } from 'url';
@@ -15,7 +15,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
15
15
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
16
16
  import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
17
17
  import { z } from 'zod';
18
- import { mkdir, rm, chmod, access, mkdtemp, copyFile } from 'node:fs/promises';
18
+ import { mkdir, rm, chmod, access, mkdtemp, copyFile, writeFile } from 'node:fs/promises';
19
19
  import { promisify } from 'node:util';
20
20
 
21
21
  let connectToServerFn = null;
@@ -45,6 +45,11 @@ async function connectToHypha(config) {
45
45
  "Timeout connecting to Hypha server (30s). A previous daemon may still be connected. Retrying..."
46
46
  )), 3e4))
47
47
  ]);
48
+ if (!server.on && server.rpc) {
49
+ server.on = server.rpc.on.bind(server.rpc);
50
+ server.off = server.rpc.off.bind(server.rpc);
51
+ server.emit = server.rpc.emit.bind(server.rpc);
52
+ }
48
53
  return server;
49
54
  }
50
55
  function parseWorkspaceFromToken(token) {
@@ -378,6 +383,72 @@ async function registerMachineService(server, machineId, metadata, daemonState,
378
383
  authorizeRequest(context, currentMetadata.sharing, "view");
379
384
  return handlers.getTrackedSessions();
380
385
  },
386
+ /**
387
+ * Get summary info for all sessions (metadata, agent state, activity).
388
+ * Replaces the need to discover and query N individual session services.
389
+ */
390
+ getSessions: async (context) => {
391
+ authorizeRequest(context, currentMetadata.sharing, "view");
392
+ const sessionIds = handlers.getSessionIds?.() || [];
393
+ const sessions = [];
394
+ for (const sid of sessionIds) {
395
+ const rpc = handlers.getSessionRPCHandlers?.(sid);
396
+ if (!rpc) continue;
397
+ try {
398
+ const [metaResult, stateResult, activity] = await Promise.all([
399
+ rpc.getMetadata(context),
400
+ rpc.getAgentState(context),
401
+ rpc.getActivityState(context).catch(() => ({
402
+ active: false,
403
+ thinking: false,
404
+ time: Date.now()
405
+ }))
406
+ ]);
407
+ sessions.push({
408
+ id: sid,
409
+ metadata: metaResult.metadata,
410
+ metadataVersion: metaResult.version,
411
+ agentState: stateResult.agentState,
412
+ agentStateVersion: stateResult.version,
413
+ active: activity.active ?? false,
414
+ thinking: activity.thinking ?? false,
415
+ activeAt: activity.time || Date.now()
416
+ });
417
+ } catch {
418
+ }
419
+ }
420
+ return sessions;
421
+ },
422
+ /**
423
+ * Dispatch an RPC call to a specific session's handler.
424
+ * This consolidates all session RPCs through the machine service,
425
+ * eliminating the need for per-session Hypha service registration.
426
+ */
427
+ sessionRPC: async (sessionId, method, args, context) => {
428
+ authorizeRequest(context, currentMetadata.sharing, "view");
429
+ const rpc = handlers.getSessionRPCHandlers?.(sessionId);
430
+ if (!rpc) {
431
+ throw new Error(`Session ${sessionId} not found on this machine`);
432
+ }
433
+ const handler = rpc[method];
434
+ if (typeof handler !== "function") {
435
+ throw new Error(`Unknown session method: ${method}`);
436
+ }
437
+ const argArray = Array.isArray(args) ? args : args !== void 0 ? [args] : [];
438
+ return await handler(...argArray, context);
439
+ },
440
+ /**
441
+ * Register a listener for a specific session's real-time updates.
442
+ * Delegates to the session store's registerListener.
443
+ */
444
+ registerSessionListener: async (sessionId, callback, context) => {
445
+ authorizeRequest(context, currentMetadata.sharing, "view");
446
+ const rpc = handlers.getSessionRPCHandlers?.(sessionId);
447
+ if (!rpc) {
448
+ throw new Error(`Session ${sessionId} not found on this machine`);
449
+ }
450
+ return await rpc.registerListener(callback, context);
451
+ },
381
452
  // Spawn a new session
382
453
  spawnSession: async (options, context) => {
383
454
  authorizeRequest(context, currentMetadata.sharing, "interact");
@@ -771,6 +842,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
771
842
  console.log(`[HYPHA MACHINE] Machine service registered: ${serviceInfo.id}`);
772
843
  return {
773
844
  serviceInfo,
845
+ notifySessionEvent: notifyListeners,
774
846
  updateMetadata: (newMetadata) => {
775
847
  currentMetadata = newMetadata;
776
848
  metadataVersion++;
@@ -827,7 +899,7 @@ function appendMessage(messagesDir, sessionId, msg) {
827
899
  console.error(`[HYPHA SESSION ${sessionId}] Failed to persist message: ${err?.message ?? err}`);
828
900
  }
829
901
  }
830
- async function registerSessionService(server, sessionId, initialMetadata, initialAgentState, callbacks, options) {
902
+ function createSessionStore(server, sessionId, initialMetadata, initialAgentState, callbacks, options) {
831
903
  const messages = options?.messagesDir ? loadMessages(options.messagesDir) : [];
832
904
  let nextSeq = messages.length > 0 ? messages[messages.length - 1].seq + 1 : 1;
833
905
  let metadata = { ...initialMetadata };
@@ -854,6 +926,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
854
926
  }
855
927
  };
856
928
  const notifyListeners = (update) => {
929
+ options?.onSessionEvent?.(update);
857
930
  const snapshot = [...listeners];
858
931
  for (let i = snapshot.length - 1; i >= 0; i--) {
859
932
  const listener = snapshot[i];
@@ -880,7 +953,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
880
953
  }
881
954
  wrappedContent = { role: "agent", content: { type: "output", data } };
882
955
  } else if (role === "event") {
883
- wrappedContent = { role: "agent", content: { type: "event", data: content } };
956
+ wrappedContent = { role: "agent", content: { type: "event", id: randomUUID(), data: content } };
884
957
  } else if (role === "session") {
885
958
  wrappedContent = { role: "session", content: { type: "session", data: content } };
886
959
  } else {
@@ -907,356 +980,348 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
907
980
  });
908
981
  return msg;
909
982
  };
910
- const serviceInfo = await server.registerService(
911
- {
912
- id: `svamp-session-${sessionId}`,
913
- name: `Svamp Session ${sessionId.slice(0, 8)}`,
914
- type: "svamp-session",
915
- config: { visibility: "unlisted", require_context: true },
916
- // ── Messages ──
917
- getMessages: async (afterSeq, limit, context) => {
918
- authorizeRequest(context, metadata.sharing, "view");
919
- const after = afterSeq ?? 0;
920
- const lim = Math.min(limit ?? 100, 500);
921
- const filtered = messages.filter((m) => m.seq > after);
922
- const page = filtered.slice(0, lim);
923
- return {
924
- messages: page,
925
- hasMore: filtered.length > lim
926
- };
927
- },
928
- sendMessage: async (content, localId, meta, context) => {
929
- authorizeRequest(context, metadata.sharing, "interact");
930
- if (localId) {
931
- const existing = messages.find((m) => m.localId === localId);
932
- if (existing) {
933
- return { id: existing.id, seq: existing.seq, localId: existing.localId };
934
- }
935
- }
936
- let parsed = content;
937
- if (typeof parsed === "string") {
938
- try {
939
- parsed = JSON.parse(parsed);
940
- } catch {
941
- }
942
- }
943
- if (parsed && typeof parsed.content === "string" && !parsed.role) {
944
- try {
945
- const inner = JSON.parse(parsed.content);
946
- if (inner && typeof inner === "object") parsed = inner;
947
- } catch {
948
- }
983
+ const rpcHandlers = {
984
+ // ── Messages ──
985
+ getMessages: async (afterSeq, limit, context) => {
986
+ authorizeRequest(context, metadata.sharing, "view");
987
+ const after = afterSeq ?? 0;
988
+ const lim = Math.min(limit ?? 100, 500);
989
+ const filtered = messages.filter((m) => m.seq > after);
990
+ const page = filtered.slice(0, lim);
991
+ return {
992
+ messages: page,
993
+ hasMore: filtered.length > lim
994
+ };
995
+ },
996
+ sendMessage: async (content, localId, meta, context) => {
997
+ authorizeRequest(context, metadata.sharing, "interact");
998
+ if (localId) {
999
+ const existing = messages.find((m) => m.localId === localId);
1000
+ if (existing) {
1001
+ return { id: existing.id, seq: existing.seq, localId: existing.localId };
949
1002
  }
950
- const wrappedContent = parsed && parsed.role === "user" ? { role: "user", content: parsed.content } : { role: "user", content: { type: "text", text: typeof parsed === "string" ? parsed : JSON.stringify(parsed) } };
951
- const msg = {
952
- id: randomUUID(),
953
- seq: nextSeq++,
954
- content: wrappedContent,
955
- localId: localId || randomUUID(),
956
- createdAt: Date.now(),
957
- updatedAt: Date.now()
958
- };
959
- messages.push(msg);
960
- if (messages.length > 1e3) messages.splice(0, messages.length - 1e3);
961
- if (options?.messagesDir) {
962
- appendMessage(options.messagesDir, sessionId, msg);
1003
+ }
1004
+ let parsed = content;
1005
+ if (typeof parsed === "string") {
1006
+ try {
1007
+ parsed = JSON.parse(parsed);
1008
+ } catch {
963
1009
  }
964
- notifyListeners({
965
- type: "new-message",
966
- sessionId,
967
- message: msg
968
- });
969
- callbacks.onUserMessage(content, meta);
970
- return { id: msg.id, seq: msg.seq, localId: msg.localId };
971
- },
972
- // ── Metadata ──
973
- getMetadata: async (context) => {
974
- authorizeRequest(context, metadata.sharing, "view");
975
- return {
976
- metadata,
977
- version: metadataVersion
978
- };
979
- },
980
- updateMetadata: async (newMetadata, expectedVersion, context) => {
981
- authorizeRequest(context, metadata.sharing, "admin");
982
- if (expectedVersion !== void 0 && expectedVersion !== metadataVersion) {
983
- return {
984
- result: "version-mismatch",
985
- version: metadataVersion,
986
- metadata
987
- };
1010
+ }
1011
+ if (parsed && typeof parsed.content === "string" && !parsed.role) {
1012
+ try {
1013
+ const inner = JSON.parse(parsed.content);
1014
+ if (inner && typeof inner === "object") parsed = inner;
1015
+ } catch {
988
1016
  }
989
- metadata = newMetadata;
990
- metadataVersion++;
991
- notifyListeners({
992
- type: "update-session",
993
- sessionId,
994
- metadata: { value: metadata, version: metadataVersion }
995
- });
996
- callbacks.onMetadataUpdate?.(metadata);
1017
+ }
1018
+ const wrappedContent = parsed && parsed.role === "user" ? { role: "user", content: parsed.content } : { role: "user", content: { type: "text", text: typeof parsed === "string" ? parsed : JSON.stringify(parsed) } };
1019
+ const msg = {
1020
+ id: randomUUID(),
1021
+ seq: nextSeq++,
1022
+ content: wrappedContent,
1023
+ localId: localId || randomUUID(),
1024
+ createdAt: Date.now(),
1025
+ updatedAt: Date.now()
1026
+ };
1027
+ messages.push(msg);
1028
+ if (messages.length > 1e3) messages.splice(0, messages.length - 1e3);
1029
+ if (options?.messagesDir) {
1030
+ appendMessage(options.messagesDir, sessionId, msg);
1031
+ }
1032
+ notifyListeners({
1033
+ type: "new-message",
1034
+ sessionId,
1035
+ message: msg
1036
+ });
1037
+ callbacks.onUserMessage(content, meta);
1038
+ return { id: msg.id, seq: msg.seq, localId: msg.localId };
1039
+ },
1040
+ // ── Metadata ──
1041
+ getMetadata: async (context) => {
1042
+ authorizeRequest(context, metadata.sharing, "view");
1043
+ return {
1044
+ metadata,
1045
+ version: metadataVersion
1046
+ };
1047
+ },
1048
+ updateMetadata: async (newMetadata, expectedVersion, context) => {
1049
+ authorizeRequest(context, metadata.sharing, "admin");
1050
+ if (expectedVersion !== void 0 && expectedVersion !== metadataVersion) {
997
1051
  return {
998
- result: "success",
1052
+ result: "version-mismatch",
999
1053
  version: metadataVersion,
1000
1054
  metadata
1001
1055
  };
1002
- },
1003
- /**
1004
- * Patch the session config file (.svamp/{sessionId}/config.json).
1005
- * Used by the frontend to set title, session_link, ralph_loop, etc.
1006
- * Null values remove keys from the config.
1007
- */
1008
- updateConfig: async (patch, context) => {
1009
- authorizeRequest(context, metadata.sharing, "admin");
1010
- callbacks.onUpdateConfig?.(patch);
1011
- return { success: true };
1012
- },
1013
- // ── Agent State ──
1014
- getAgentState: async (context) => {
1015
- authorizeRequest(context, metadata.sharing, "view");
1016
- return {
1017
- agentState,
1018
- version: agentStateVersion
1019
- };
1020
- },
1021
- updateAgentState: async (newState, expectedVersion, context) => {
1022
- authorizeRequest(context, metadata.sharing, "admin");
1023
- if (expectedVersion !== void 0 && expectedVersion !== agentStateVersion) {
1024
- return {
1025
- result: "version-mismatch",
1026
- version: agentStateVersion,
1027
- agentState
1028
- };
1029
- }
1030
- agentState = newState;
1031
- agentStateVersion++;
1032
- notifyListeners({
1033
- type: "update-session",
1034
- sessionId,
1035
- agentState: { value: agentState, version: agentStateVersion }
1036
- });
1056
+ }
1057
+ metadata = newMetadata;
1058
+ metadataVersion++;
1059
+ notifyListeners({
1060
+ type: "update-session",
1061
+ sessionId,
1062
+ metadata: { value: metadata, version: metadataVersion }
1063
+ });
1064
+ callbacks.onMetadataUpdate?.(metadata);
1065
+ return {
1066
+ result: "success",
1067
+ version: metadataVersion,
1068
+ metadata
1069
+ };
1070
+ },
1071
+ /**
1072
+ * Patch the session config file (.svamp/{sessionId}/config.json).
1073
+ * Used by the frontend to set title, session_link, ralph_loop, etc.
1074
+ * Null values remove keys from the config.
1075
+ */
1076
+ updateConfig: async (patch, context) => {
1077
+ authorizeRequest(context, metadata.sharing, "admin");
1078
+ callbacks.onUpdateConfig?.(patch);
1079
+ return { success: true };
1080
+ },
1081
+ // ── Agent State ──
1082
+ getAgentState: async (context) => {
1083
+ authorizeRequest(context, metadata.sharing, "view");
1084
+ return {
1085
+ agentState,
1086
+ version: agentStateVersion
1087
+ };
1088
+ },
1089
+ updateAgentState: async (newState, expectedVersion, context) => {
1090
+ authorizeRequest(context, metadata.sharing, "admin");
1091
+ if (expectedVersion !== void 0 && expectedVersion !== agentStateVersion) {
1037
1092
  return {
1038
- result: "success",
1093
+ result: "version-mismatch",
1039
1094
  version: agentStateVersion,
1040
1095
  agentState
1041
1096
  };
1042
- },
1043
- // ── Session Control RPCs ──
1044
- abort: async (context) => {
1045
- authorizeRequest(context, metadata.sharing, "interact");
1046
- callbacks.onAbort();
1047
- return { success: true };
1048
- },
1049
- permissionResponse: async (params, context) => {
1050
- authorizeRequest(context, metadata.sharing, "interact");
1051
- callbacks.onPermissionResponse(params);
1052
- return { success: true };
1053
- },
1054
- switchMode: async (mode, context) => {
1055
- authorizeRequest(context, metadata.sharing, "admin");
1056
- callbacks.onSwitchMode(mode);
1057
- return { success: true };
1058
- },
1059
- restartClaude: async (context) => {
1060
- authorizeRequest(context, metadata.sharing, "admin");
1061
- return await callbacks.onRestartClaude();
1062
- },
1063
- killSession: async (context) => {
1064
- authorizeRequest(context, metadata.sharing, "admin");
1065
- callbacks.onKillSession();
1066
- return { success: true };
1067
- },
1068
- // ── Activity ──
1069
- keepAlive: async (thinking, mode, context) => {
1070
- authorizeRequest(context, metadata.sharing, "interact");
1071
- lastActivity = { active: true, thinking: thinking || false, mode: mode || "remote", time: Date.now() };
1072
- notifyListeners({
1073
- type: "activity",
1074
- sessionId,
1075
- ...lastActivity
1076
- });
1077
- },
1078
- sessionEnd: async (context) => {
1079
- authorizeRequest(context, metadata.sharing, "interact");
1080
- lastActivity = { active: false, thinking: false, mode: "remote", time: Date.now() };
1081
- notifyListeners({
1082
- type: "activity",
1083
- sessionId,
1084
- ...lastActivity
1085
- });
1086
- },
1087
- // ── Activity State Query ──
1088
- getActivityState: async (context) => {
1089
- authorizeRequest(context, metadata.sharing, "view");
1090
- const pendingPermissions = agentState?.requests ? Object.entries(agentState.requests).filter(([, req]) => req.status === "pending" || !req.status).map(([id, req]) => ({
1091
- id,
1092
- tool: req.tool,
1093
- arguments: req.arguments,
1094
- createdAt: req.createdAt
1095
- })) : [];
1096
- return { ...lastActivity, sessionId, pendingPermissions };
1097
- },
1098
- // ── File Operations (optional, admin-only) ──
1099
- readFile: async (path, context) => {
1100
- authorizeRequest(context, metadata.sharing, "admin");
1101
- if (!callbacks.onReadFile) throw new Error("readFile not supported");
1102
- return await callbacks.onReadFile(path);
1103
- },
1104
- writeFile: async (path, content, context) => {
1105
- authorizeRequest(context, metadata.sharing, "admin");
1106
- if (!callbacks.onWriteFile) throw new Error("writeFile not supported");
1107
- await callbacks.onWriteFile(path, content);
1108
- return { success: true };
1109
- },
1110
- listDirectory: async (path, context) => {
1111
- authorizeRequest(context, metadata.sharing, "admin");
1112
- if (!callbacks.onListDirectory) throw new Error("listDirectory not supported");
1113
- return await callbacks.onListDirectory(path);
1114
- },
1115
- bash: async (command, cwd, context) => {
1116
- authorizeRequest(context, metadata.sharing, "admin");
1117
- if (!callbacks.onBash) throw new Error("bash not supported");
1118
- return await callbacks.onBash(command, cwd);
1119
- },
1120
- ripgrep: async (args, cwd, context) => {
1121
- authorizeRequest(context, metadata.sharing, "admin");
1122
- if (!callbacks.onRipgrep) throw new Error("ripgrep not supported");
1097
+ }
1098
+ agentState = newState;
1099
+ agentStateVersion++;
1100
+ notifyListeners({
1101
+ type: "update-session",
1102
+ sessionId,
1103
+ agentState: { value: agentState, version: agentStateVersion }
1104
+ });
1105
+ return {
1106
+ result: "success",
1107
+ version: agentStateVersion,
1108
+ agentState
1109
+ };
1110
+ },
1111
+ // ── Session Control RPCs ──
1112
+ abort: async (context) => {
1113
+ authorizeRequest(context, metadata.sharing, "interact");
1114
+ callbacks.onAbort();
1115
+ return { success: true };
1116
+ },
1117
+ permissionResponse: async (params, context) => {
1118
+ authorizeRequest(context, metadata.sharing, "interact");
1119
+ callbacks.onPermissionResponse(params);
1120
+ return { success: true };
1121
+ },
1122
+ switchMode: async (mode, context) => {
1123
+ authorizeRequest(context, metadata.sharing, "admin");
1124
+ callbacks.onSwitchMode(mode);
1125
+ return { success: true };
1126
+ },
1127
+ restartClaude: async (context) => {
1128
+ authorizeRequest(context, metadata.sharing, "admin");
1129
+ return await callbacks.onRestartClaude();
1130
+ },
1131
+ killSession: async (context) => {
1132
+ authorizeRequest(context, metadata.sharing, "admin");
1133
+ callbacks.onKillSession();
1134
+ return { success: true };
1135
+ },
1136
+ // ── Activity ──
1137
+ keepAlive: async (thinking, mode, context) => {
1138
+ authorizeRequest(context, metadata.sharing, "interact");
1139
+ lastActivity = { active: true, thinking: thinking || false, mode: mode || "remote", time: Date.now() };
1140
+ notifyListeners({
1141
+ type: "activity",
1142
+ sessionId,
1143
+ ...lastActivity
1144
+ });
1145
+ },
1146
+ sessionEnd: async (context) => {
1147
+ authorizeRequest(context, metadata.sharing, "interact");
1148
+ lastActivity = { active: false, thinking: false, mode: "remote", time: Date.now() };
1149
+ notifyListeners({
1150
+ type: "activity",
1151
+ sessionId,
1152
+ ...lastActivity
1153
+ });
1154
+ },
1155
+ // ── Activity State Query ──
1156
+ getActivityState: async (context) => {
1157
+ authorizeRequest(context, metadata.sharing, "view");
1158
+ const pendingPermissions = agentState?.requests ? Object.entries(agentState.requests).filter(([, req]) => req.status === "pending" || !req.status).map(([id, req]) => ({
1159
+ id,
1160
+ tool: req.tool,
1161
+ arguments: req.arguments,
1162
+ createdAt: req.createdAt
1163
+ })) : [];
1164
+ return { ...lastActivity, sessionId, pendingPermissions };
1165
+ },
1166
+ // ── File Operations (optional, admin-only) ──
1167
+ readFile: async (path, context) => {
1168
+ authorizeRequest(context, metadata.sharing, "admin");
1169
+ if (!callbacks.onReadFile) throw new Error("readFile not supported");
1170
+ return await callbacks.onReadFile(path);
1171
+ },
1172
+ writeFile: async (path, content, context) => {
1173
+ authorizeRequest(context, metadata.sharing, "admin");
1174
+ if (!callbacks.onWriteFile) throw new Error("writeFile not supported");
1175
+ await callbacks.onWriteFile(path, content);
1176
+ return { success: true };
1177
+ },
1178
+ listDirectory: async (path, context) => {
1179
+ authorizeRequest(context, metadata.sharing, "admin");
1180
+ if (!callbacks.onListDirectory) throw new Error("listDirectory not supported");
1181
+ return await callbacks.onListDirectory(path);
1182
+ },
1183
+ bash: async (command, cwd, context) => {
1184
+ authorizeRequest(context, metadata.sharing, "admin");
1185
+ if (!callbacks.onBash) throw new Error("bash not supported");
1186
+ return await callbacks.onBash(command, cwd);
1187
+ },
1188
+ ripgrep: async (args, cwd, context) => {
1189
+ authorizeRequest(context, metadata.sharing, "admin");
1190
+ if (!callbacks.onRipgrep) throw new Error("ripgrep not supported");
1191
+ try {
1192
+ const stdout = await callbacks.onRipgrep(args, cwd);
1193
+ return { success: true, stdout, stderr: "", exitCode: 0 };
1194
+ } catch (err) {
1195
+ return { success: false, stdout: "", stderr: err.message || "", exitCode: 1, error: err.message };
1196
+ }
1197
+ },
1198
+ getDirectoryTree: async (path, maxDepth, context) => {
1199
+ authorizeRequest(context, metadata.sharing, "admin");
1200
+ if (!callbacks.onGetDirectoryTree) throw new Error("getDirectoryTree not supported");
1201
+ return await callbacks.onGetDirectoryTree(path, maxDepth ?? 3);
1202
+ },
1203
+ // ── Sharing Management ──
1204
+ getSharing: async (context) => {
1205
+ authorizeRequest(context, metadata.sharing, "view");
1206
+ return { sharing: metadata.sharing || null };
1207
+ },
1208
+ /** Returns the caller's effective role (null if no access). Does not throw. */
1209
+ getEffectiveRole: async (context) => {
1210
+ authorizeRequest(context, metadata.sharing, "view");
1211
+ const role = getEffectiveRole(context, metadata.sharing);
1212
+ return { role };
1213
+ },
1214
+ updateSharing: async (newSharing, context) => {
1215
+ authorizeRequest(context, metadata.sharing, "admin");
1216
+ if (metadata.sharing && context?.user?.email && metadata.sharing.owner && context.user.email.toLowerCase() !== metadata.sharing.owner.toLowerCase()) {
1217
+ throw new Error("Only the session owner can update sharing settings");
1218
+ }
1219
+ if (newSharing.enabled && !newSharing.owner && context?.user?.email) {
1220
+ newSharing = { ...newSharing, owner: context.user.email };
1221
+ }
1222
+ metadata = { ...metadata, sharing: newSharing };
1223
+ metadataVersion++;
1224
+ notifyListeners({
1225
+ type: "update-session",
1226
+ sessionId,
1227
+ metadata: { value: metadata, version: metadataVersion }
1228
+ });
1229
+ callbacks.onSharingUpdate?.(newSharing);
1230
+ return { success: true, sharing: newSharing };
1231
+ },
1232
+ /** Update security context and restart the agent process with new rules */
1233
+ updateSecurityContext: async (newSecurityContext, context) => {
1234
+ authorizeRequest(context, metadata.sharing, "admin");
1235
+ if (metadata.sharing && context?.user?.email && metadata.sharing.owner && context.user.email.toLowerCase() !== metadata.sharing.owner.toLowerCase()) {
1236
+ throw new Error("Only the session owner can update security context");
1237
+ }
1238
+ if (!callbacks.onUpdateSecurityContext) {
1239
+ throw new Error("Security context updates are not supported for this session");
1240
+ }
1241
+ metadata = { ...metadata, securityContext: newSecurityContext };
1242
+ metadataVersion++;
1243
+ notifyListeners({
1244
+ type: "update-session",
1245
+ sessionId,
1246
+ metadata: { value: metadata, version: metadataVersion }
1247
+ });
1248
+ return await callbacks.onUpdateSecurityContext(newSecurityContext);
1249
+ },
1250
+ /** Apply a new system prompt and restart the agent process */
1251
+ applySystemPrompt: async (prompt, context) => {
1252
+ authorizeRequest(context, metadata.sharing, "admin");
1253
+ if (!callbacks.onApplySystemPrompt) {
1254
+ throw new Error("System prompt updates are not supported for this session");
1255
+ }
1256
+ return await callbacks.onApplySystemPrompt(prompt);
1257
+ },
1258
+ // ── Listener Registration ──
1259
+ registerListener: async (callback, context) => {
1260
+ authorizeRequest(context, metadata.sharing, "view");
1261
+ listeners.push(callback);
1262
+ const replayMessages = messages.slice(-50);
1263
+ const REPLAY_MESSAGE_TIMEOUT_MS = 1e4;
1264
+ for (const msg of replayMessages) {
1265
+ if (listeners.indexOf(callback) < 0) break;
1123
1266
  try {
1124
- const stdout = await callbacks.onRipgrep(args, cwd);
1125
- return { success: true, stdout, stderr: "", exitCode: 0 };
1267
+ const result = callback.onUpdate({
1268
+ type: "new-message",
1269
+ sessionId,
1270
+ message: msg
1271
+ });
1272
+ if (result && typeof result.catch === "function") {
1273
+ try {
1274
+ await Promise.race([
1275
+ result,
1276
+ new Promise(
1277
+ (_, reject) => setTimeout(() => reject(new Error("Replay message timeout")), REPLAY_MESSAGE_TIMEOUT_MS)
1278
+ )
1279
+ ]);
1280
+ } catch (err) {
1281
+ console.error(`[HYPHA SESSION ${sessionId}] Replay listener error, removing:`, err?.message ?? err);
1282
+ removeListener(callback, "replay error");
1283
+ return { success: false, error: "Listener removed during replay" };
1284
+ }
1285
+ }
1126
1286
  } catch (err) {
1127
- return { success: false, stdout: "", stderr: err.message || "", exitCode: 1, error: err.message };
1287
+ console.error(`[HYPHA SESSION ${sessionId}] Replay listener error, removing:`, err?.message ?? err);
1288
+ removeListener(callback, "replay error");
1289
+ return { success: false, error: "Listener removed during replay" };
1128
1290
  }
1129
- },
1130
- getDirectoryTree: async (path, maxDepth, context) => {
1131
- authorizeRequest(context, metadata.sharing, "admin");
1132
- if (!callbacks.onGetDirectoryTree) throw new Error("getDirectoryTree not supported");
1133
- return await callbacks.onGetDirectoryTree(path, maxDepth ?? 3);
1134
- },
1135
- // ── Sharing Management ──
1136
- getSharing: async (context) => {
1137
- authorizeRequest(context, metadata.sharing, "view");
1138
- return { sharing: metadata.sharing || null };
1139
- },
1140
- /** Returns the caller's effective role (null if no access). Does not throw. */
1141
- getEffectiveRole: async (context) => {
1142
- authorizeRequest(context, metadata.sharing, "view");
1143
- const role = getEffectiveRole(context, metadata.sharing);
1144
- return { role };
1145
- },
1146
- updateSharing: async (newSharing, context) => {
1147
- authorizeRequest(context, metadata.sharing, "admin");
1148
- if (metadata.sharing && context?.user?.email && metadata.sharing.owner && context.user.email.toLowerCase() !== metadata.sharing.owner.toLowerCase()) {
1149
- throw new Error("Only the session owner can update sharing settings");
1150
- }
1151
- if (newSharing.enabled && !newSharing.owner && context?.user?.email) {
1152
- newSharing = { ...newSharing, owner: context.user.email };
1153
- }
1154
- metadata = { ...metadata, sharing: newSharing };
1155
- metadataVersion++;
1156
- notifyListeners({
1291
+ }
1292
+ if (listeners.indexOf(callback) < 0) {
1293
+ return { success: false, error: "Listener was removed during replay" };
1294
+ }
1295
+ try {
1296
+ const result = callback.onUpdate({
1157
1297
  type: "update-session",
1158
1298
  sessionId,
1159
- metadata: { value: metadata, version: metadataVersion }
1299
+ metadata: { value: metadata, version: metadataVersion },
1300
+ agentState: { value: agentState, version: agentStateVersion }
1160
1301
  });
1161
- callbacks.onSharingUpdate?.(newSharing);
1162
- return { success: true, sharing: newSharing };
1163
- },
1164
- /** Update security context and restart the agent process with new rules */
1165
- updateSecurityContext: async (newSecurityContext, context) => {
1166
- authorizeRequest(context, metadata.sharing, "admin");
1167
- if (metadata.sharing && context?.user?.email && metadata.sharing.owner && context.user.email.toLowerCase() !== metadata.sharing.owner.toLowerCase()) {
1168
- throw new Error("Only the session owner can update security context");
1169
- }
1170
- if (!callbacks.onUpdateSecurityContext) {
1171
- throw new Error("Security context updates are not supported for this session");
1302
+ if (result && typeof result.catch === "function") {
1303
+ result.catch(() => {
1304
+ });
1172
1305
  }
1173
- metadata = { ...metadata, securityContext: newSecurityContext };
1174
- metadataVersion++;
1175
- notifyListeners({
1176
- type: "update-session",
1306
+ } catch {
1307
+ }
1308
+ try {
1309
+ const result = callback.onUpdate({
1310
+ type: "activity",
1177
1311
  sessionId,
1178
- metadata: { value: metadata, version: metadataVersion }
1312
+ ...lastActivity
1179
1313
  });
1180
- return await callbacks.onUpdateSecurityContext(newSecurityContext);
1181
- },
1182
- /** Apply a new system prompt and restart the agent process */
1183
- applySystemPrompt: async (prompt, context) => {
1184
- authorizeRequest(context, metadata.sharing, "admin");
1185
- if (!callbacks.onApplySystemPrompt) {
1186
- throw new Error("System prompt updates are not supported for this session");
1187
- }
1188
- return await callbacks.onApplySystemPrompt(prompt);
1189
- },
1190
- // ── Listener Registration ──
1191
- registerListener: async (callback, context) => {
1192
- authorizeRequest(context, metadata.sharing, "view");
1193
- listeners.push(callback);
1194
- const replayMessages = messages.slice(-50);
1195
- const REPLAY_MESSAGE_TIMEOUT_MS = 1e4;
1196
- for (const msg of replayMessages) {
1197
- if (listeners.indexOf(callback) < 0) break;
1198
- try {
1199
- const result = callback.onUpdate({
1200
- type: "new-message",
1201
- sessionId,
1202
- message: msg
1203
- });
1204
- if (result && typeof result.catch === "function") {
1205
- try {
1206
- await Promise.race([
1207
- result,
1208
- new Promise(
1209
- (_, reject) => setTimeout(() => reject(new Error("Replay message timeout")), REPLAY_MESSAGE_TIMEOUT_MS)
1210
- )
1211
- ]);
1212
- } catch (err) {
1213
- console.error(`[HYPHA SESSION ${sessionId}] Replay listener error, removing:`, err?.message ?? err);
1214
- removeListener(callback, "replay error");
1215
- return { success: false, error: "Listener removed during replay" };
1216
- }
1217
- }
1218
- } catch (err) {
1219
- console.error(`[HYPHA SESSION ${sessionId}] Replay listener error, removing:`, err?.message ?? err);
1220
- removeListener(callback, "replay error");
1221
- return { success: false, error: "Listener removed during replay" };
1222
- }
1223
- }
1224
- if (listeners.indexOf(callback) < 0) {
1225
- return { success: false, error: "Listener was removed during replay" };
1226
- }
1227
- try {
1228
- const result = callback.onUpdate({
1229
- type: "update-session",
1230
- sessionId,
1231
- metadata: { value: metadata, version: metadataVersion },
1232
- agentState: { value: agentState, version: agentStateVersion }
1233
- });
1234
- if (result && typeof result.catch === "function") {
1235
- result.catch(() => {
1236
- });
1237
- }
1238
- } catch {
1239
- }
1240
- try {
1241
- const result = callback.onUpdate({
1242
- type: "activity",
1243
- sessionId,
1244
- ...lastActivity
1314
+ if (result && typeof result.catch === "function") {
1315
+ result.catch(() => {
1245
1316
  });
1246
- if (result && typeof result.catch === "function") {
1247
- result.catch(() => {
1248
- });
1249
- }
1250
- } catch {
1251
1317
  }
1252
- return { success: true, listenerId: listeners.length - 1 };
1318
+ } catch {
1253
1319
  }
1254
- },
1255
- { overwrite: true }
1256
- );
1257
- console.log(`[HYPHA SESSION] Session service registered: ${serviceInfo.id}`);
1258
- return {
1259
- serviceInfo,
1320
+ return { success: true, listenerId: listeners.length - 1 };
1321
+ }
1322
+ };
1323
+ const store = {
1324
+ serviceInfo: { id: `svamp-session-${sessionId}` },
1260
1325
  pushMessage,
1261
1326
  get _agentState() {
1262
1327
  return agentState;
@@ -1315,9 +1380,44 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1315
1380
  for (const listener of toRemove) {
1316
1381
  removeListener(listener, "disconnect");
1317
1382
  }
1318
- await server.unregisterService(serviceInfo.id);
1383
+ },
1384
+ reregister: async () => {
1319
1385
  }
1320
1386
  };
1387
+ return { store, rpcHandlers };
1388
+ }
1389
+ async function registerSessionService(server, sessionId, initialMetadata, initialAgentState, callbacks, options) {
1390
+ const { store, rpcHandlers } = createSessionStore(
1391
+ server,
1392
+ sessionId,
1393
+ initialMetadata,
1394
+ initialAgentState,
1395
+ callbacks,
1396
+ options
1397
+ );
1398
+ const serviceDefinition = {
1399
+ id: `svamp-session-${sessionId}`,
1400
+ name: `Svamp Session ${sessionId.slice(0, 8)}`,
1401
+ type: "svamp-session",
1402
+ config: { visibility: "unlisted", require_context: true },
1403
+ ...rpcHandlers
1404
+ };
1405
+ const serviceInfo = await server.registerService(serviceDefinition, { overwrite: true });
1406
+ console.log(`[HYPHA SESSION] Session service registered: ${serviceInfo.id}`);
1407
+ const originalDisconnect = store.disconnect;
1408
+ store.disconnect = async () => {
1409
+ await originalDisconnect();
1410
+ await server.unregisterService(serviceInfo.id);
1411
+ };
1412
+ store.reregister = async () => {
1413
+ try {
1414
+ await server.registerService(serviceDefinition, { overwrite: true });
1415
+ } catch (e) {
1416
+ if (!String(e?.message).includes("already exists")) throw e;
1417
+ }
1418
+ };
1419
+ store.serviceInfo = serviceInfo;
1420
+ return store;
1321
1421
  }
1322
1422
 
1323
1423
  async function registerDebugService(server, machineId, deps) {
@@ -1796,6 +1896,10 @@ function wrapWithNono(command, args, config) {
1796
1896
  if (existsSync(realLocalDir)) {
1797
1897
  nonoArgs.push("--read", realLocalDir);
1798
1898
  }
1899
+ const realKeychainDir = join$1(homedir(), "Library", "Keychains");
1900
+ if (existsSync(realKeychainDir)) {
1901
+ nonoArgs.push("--read", realKeychainDir);
1902
+ }
1799
1903
  }
1800
1904
  if (config.nonoConfig?.allowDirs) {
1801
1905
  for (const dir of config.nonoConfig.allowDirs) {
@@ -3886,9 +3990,8 @@ async function stageCredentialsForSharing(sessionId) {
3886
3990
  const realHome = homedir();
3887
3991
  const realClaudeDir = join$1(realHome, ".claude");
3888
3992
  await mkdir(STAGED_HOMES_DIR, { recursive: true });
3889
- const tmpHome = await mkdtemp(
3890
- join$1(STAGED_HOMES_DIR, `${sessionId.slice(0, 8)}-`)
3891
- );
3993
+ const tmpHome = join$1(STAGED_HOMES_DIR, sessionId);
3994
+ await mkdir(tmpHome, { recursive: true });
3892
3995
  const stagedClaudeDir = join$1(tmpHome, ".claude");
3893
3996
  await mkdir(stagedClaudeDir, { recursive: true });
3894
3997
  const credentialFiles = ["credentials.json", ".credentials.json"];
@@ -3909,10 +4012,12 @@ async function stageCredentialsForSharing(sessionId) {
3909
4012
  );
3910
4013
  } catch {
3911
4014
  }
3912
- const { writeFile } = await import('node:fs/promises');
3913
- try {
3914
- await writeFile(join$1(tmpHome, ".claude.json"), "{}");
3915
- } catch {
4015
+ const claudeJsonPath = join$1(tmpHome, ".claude.json");
4016
+ if (!existsSync(claudeJsonPath)) {
4017
+ try {
4018
+ await writeFile(claudeJsonPath, "{}");
4019
+ } catch {
4020
+ }
3916
4021
  }
3917
4022
  return {
3918
4023
  homePath: tmpHome,
@@ -4154,7 +4259,7 @@ class ProcessSupervisor {
4154
4259
  async persistSpec(spec) {
4155
4260
  const filePath = path.join(this.persistDir, `${spec.id}.json`);
4156
4261
  const tmpPath = filePath + ".tmp";
4157
- await writeFile(tmpPath, JSON.stringify(spec, null, 2), "utf-8");
4262
+ await writeFile$1(tmpPath, JSON.stringify(spec, null, 2), "utf-8");
4158
4263
  await rename(tmpPath, filePath);
4159
4264
  }
4160
4265
  async deleteSpec(id) {
@@ -5313,6 +5418,7 @@ async function startDaemon(options) {
5313
5418
  serverUrl: hyphaServerUrl,
5314
5419
  token: hyphaToken,
5315
5420
  name: `svamp-machine-${machineId}`,
5421
+ transport: "http",
5316
5422
  ...hyphaClientId ? { clientId: hyphaClientId } : {}
5317
5423
  });
5318
5424
  logger.log(`Connected to Hypha (workspace: ${server.config.workspace})`);
@@ -5320,6 +5426,7 @@ async function startDaemon(options) {
5320
5426
  logger.log(`Hypha connection permanently lost: ${reason}`);
5321
5427
  requestShutdown("hypha-disconnected", String(reason));
5322
5428
  });
5429
+ const pidToTrackedSession = /* @__PURE__ */ new Map();
5323
5430
  server.on("services_registered", () => {
5324
5431
  if (consecutiveHeartbeatFailures > 0) {
5325
5432
  logger.log(`Hypha reconnection successful \u2014 services re-registered (resetting ${consecutiveHeartbeatFailures} failures)`);
@@ -5327,7 +5434,6 @@ async function startDaemon(options) {
5327
5434
  lastReconnectAt = Date.now();
5328
5435
  }
5329
5436
  });
5330
- const pidToTrackedSession = /* @__PURE__ */ new Map();
5331
5437
  const getCurrentChildren = () => {
5332
5438
  return Array.from(pidToTrackedSession.values()).map((s) => ({
5333
5439
  sessionId: s.svampSessionId || `PID-${s.pid}`,
@@ -5337,6 +5443,7 @@ async function startDaemon(options) {
5337
5443
  active: !s.stopped && s.hyphaService != null
5338
5444
  }));
5339
5445
  };
5446
+ let machineServiceRef = null;
5340
5447
  const spawnSession = async (options2) => {
5341
5448
  logger.log("Spawning session:", JSON.stringify(options2));
5342
5449
  const { directory, approvedNewDirectoryCreation = true, resumeSessionId } = options2;
@@ -5479,6 +5586,9 @@ async function startDaemon(options) {
5479
5586
  let sessionWasProcessing = !!options2.wasProcessing;
5480
5587
  let lastAssistantText = "";
5481
5588
  let spawnHasReceivedInit = false;
5589
+ let startupFailureRetryPending = false;
5590
+ let startupRetryMessage;
5591
+ let startupNonJsonLines = [];
5482
5592
  const signalProcessing = (processing) => {
5483
5593
  sessionService.sendKeepAlive(processing);
5484
5594
  const newState = processing ? "running" : "idle";
@@ -5529,6 +5639,8 @@ async function startDaemon(options) {
5529
5639
  let isolationCleanupFiles = [];
5530
5640
  const spawnClaude = (initialMessage, meta) => {
5531
5641
  const effectiveMeta = { ...lastSpawnMeta, ...meta };
5642
+ startupNonJsonLines = [];
5643
+ startupRetryMessage = initialMessage;
5532
5644
  let rawPermissionMode = effectiveMeta.permissionMode || agentConfig.default_permission_mode || currentPermissionMode;
5533
5645
  if (options2.forceIsolation || sessionMetadata.sharing?.enabled) {
5534
5646
  rawPermissionMode = rawPermissionMode === "default" ? "auto-approve-all" : rawPermissionMode;
@@ -5724,23 +5836,36 @@ async function startDaemon(options) {
5724
5836
  if (msg.is_error) {
5725
5837
  const resultText = msg.result || "";
5726
5838
  logger.error(`[Session ${sessionId}] Claude error (is_error=true, api_ms=${msg.duration_api_ms}): "${resultText}"`);
5727
- const lower = resultText.toLowerCase();
5728
- const isLoginIssue = lower.includes("login") || lower.includes("logged in") || lower.includes("auth") || lower.includes("api key") || lower.includes("unauthorized");
5729
- const isResumeIssue = lower.includes("tool_use.name") || lower.includes("invalid_request") || lower.includes("messages.");
5730
- let hint = "";
5731
- if (isLoginIssue) {
5732
- hint = "\n\nRun `claude login` in your terminal on the machine running the daemon to re-authenticate.";
5733
- } else if (isResumeIssue) {
5734
- hint = "\n\nThe conversation history may be corrupted. Try starting a fresh session.";
5839
+ const isStartupFailure = msg.duration_api_ms === 0 && msg.num_turns === 0;
5840
+ if (isStartupFailure && !startupFailureRetryPending) {
5841
+ logger.log(`[Session ${sessionId}] Startup failure detected \u2014 scheduling silent retry without --resume`);
5842
+ startupFailureRetryPending = true;
5843
+ lastErrorMessagePushed = true;
5735
5844
  } else {
5736
- hint = "\n\nCheck that the Claude Code CLI is properly installed and configured.";
5845
+ const lower = resultText.toLowerCase();
5846
+ const isLoginIssue = lower.includes("login") || lower.includes("logged in") || lower.includes("auth") || lower.includes("api key") || lower.includes("unauthorized");
5847
+ const isResumeIssue = lower.includes("tool_use.name") || lower.includes("invalid_request") || lower.includes("messages.");
5848
+ const isBillingIssue = lower.includes("credit") || lower.includes("balance") || lower.includes("billing") || lower.includes("quota") || lower.includes("subscription") || lower.includes("payment");
5849
+ let hint = "";
5850
+ if (isBillingIssue) {
5851
+ hint = "\n\nCheck your Claude account credits or subscription at https://console.anthropic.com.";
5852
+ } else if (isLoginIssue) {
5853
+ hint = "\n\nRun `claude login` in your terminal on the machine running the daemon to re-authenticate.";
5854
+ } else if (isResumeIssue) {
5855
+ hint = "\n\nThe conversation history may be corrupted. Try starting a fresh session.";
5856
+ } else {
5857
+ hint = "\n\nCheck that the Claude Code CLI is properly installed and configured.";
5858
+ }
5859
+ const displayMsg = resultText || "Claude Code exited with an error.";
5860
+ let contextInfo = "";
5861
+ if (startupNonJsonLines.length > 0) {
5862
+ contextInfo = "\n\n**Startup output:**\n```\n" + startupNonJsonLines.slice(-10).join("\n") + "\n```";
5863
+ }
5864
+ const errorText = `${displayMsg}${hint}${contextInfo}`;
5865
+ logger.log(`[Session ${sessionId}] Pushing error to UI: "${displayMsg}"`);
5866
+ sessionService.pushMessage({ type: "message", message: errorText, level: "error" }, "event");
5867
+ lastErrorMessagePushed = true;
5737
5868
  }
5738
- const displayMsg = resultText || "Claude Code exited with an error.";
5739
- sessionService.pushMessage({
5740
- type: "assistant",
5741
- content: [{ type: "text", text: `**Error:** ${displayMsg}${hint}` }]
5742
- }, "agent");
5743
- lastErrorMessagePushed = true;
5744
5869
  }
5745
5870
  }
5746
5871
  if (msg.type === "result") {
@@ -5955,6 +6080,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5955
6080
  const isResumeFailure = !spawnHasReceivedInit && claudeResumeId && msg.session_id !== claudeResumeId;
5956
6081
  const isConversationClear = spawnHasReceivedInit && claudeResumeId && msg.session_id !== claudeResumeId;
5957
6082
  spawnHasReceivedInit = true;
6083
+ startupFailureRetryPending = false;
5958
6084
  claudeResumeId = msg.session_id;
5959
6085
  sessionMetadata = { ...sessionMetadata, claudeSessionId: msg.session_id };
5960
6086
  sessionService.updateMetadata(sessionMetadata);
@@ -5996,6 +6122,9 @@ The automated loop has finished. Review the progress above and let me know if yo
5996
6122
  }
5997
6123
  } catch {
5998
6124
  logger.log(`[Session ${sessionId}] Claude stdout (non-JSON): ${line}`);
6125
+ if (!spawnHasReceivedInit) {
6126
+ startupNonJsonLines.push(line.slice(0, 500));
6127
+ }
5999
6128
  }
6000
6129
  }
6001
6130
  });
@@ -6040,6 +6169,19 @@ The automated loop has finished. Review the progress above and let me know if yo
6040
6169
  sessionMetadata = { ...sessionMetadata, lifecycleState: claudeResumeId ? "idle" : "stopped" };
6041
6170
  sessionService.updateMetadata(sessionMetadata);
6042
6171
  sessionWasProcessing = false;
6172
+ if (startupFailureRetryPending && !trackedSession.stopped) {
6173
+ startupFailureRetryPending = false;
6174
+ const prevResumeId = claudeResumeId;
6175
+ claudeResumeId = void 0;
6176
+ logger.log(`[Session ${sessionId}] Startup failure \u2014 cleared stale resume ID (was: ${prevResumeId})`);
6177
+ if (startupRetryMessage !== void 0) {
6178
+ logger.log(`[Session ${sessionId}] Retrying startup without --resume`);
6179
+ sessionMetadata = { ...sessionMetadata, lifecycleState: "running" };
6180
+ sessionService.updateMetadata(sessionMetadata);
6181
+ spawnClaude(startupRetryMessage);
6182
+ return;
6183
+ }
6184
+ }
6043
6185
  const queueLen = sessionMetadata.messageQueue?.length ?? 0;
6044
6186
  if (queueLen > 0 && claudeResumeId && !trackedSession.stopped) {
6045
6187
  signalProcessing(false);
@@ -6115,7 +6257,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6115
6257
  }
6116
6258
  }
6117
6259
  let processMessageQueueRef;
6118
- const sessionService = await registerSessionService(
6260
+ const { store: sessionService, rpcHandlers: sessionRPCHandlers } = createSessionStore(
6119
6261
  server,
6120
6262
  sessionId,
6121
6263
  sessionMetadata,
@@ -6298,6 +6440,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6298
6440
  stopSession(sessionId);
6299
6441
  },
6300
6442
  onMetadataUpdate: (newMeta) => {
6443
+ const prevRalphLoop = sessionMetadata.ralphLoop;
6301
6444
  sessionMetadata = {
6302
6445
  ...newMeta,
6303
6446
  // Daemon drives lifecycleState — don't let frontend overwrite with stale value
@@ -6305,8 +6448,16 @@ The automated loop has finished. Review the progress above and let me know if yo
6305
6448
  // Preserve claudeSessionId set by 'system init' (frontend may not have it)
6306
6449
  ...sessionMetadata.claudeSessionId ? { claudeSessionId: sessionMetadata.claudeSessionId } : {},
6307
6450
  ...sessionMetadata.summary && !newMeta.summary ? { summary: sessionMetadata.summary } : {},
6308
- ...sessionMetadata.sessionLink && !newMeta.sessionLink ? { sessionLink: sessionMetadata.sessionLink } : {}
6451
+ ...sessionMetadata.sessionLink && !newMeta.sessionLink ? { sessionLink: sessionMetadata.sessionLink } : {},
6452
+ // Preserve parentSessionId — set at spawn time, frontend may not track it
6453
+ ...sessionMetadata.parentSessionId ? { parentSessionId: sessionMetadata.parentSessionId } : {},
6454
+ // Preserve daemon-owned ralphLoop — frontend may send stale snapshot without it,
6455
+ // which would wipe the active loop state and cause the bar to disappear mid-run.
6456
+ ...prevRalphLoop ? { ralphLoop: prevRalphLoop } : {}
6309
6457
  };
6458
+ if (prevRalphLoop && !newMeta.ralphLoop) {
6459
+ sessionService.updateMetadata(sessionMetadata);
6460
+ }
6310
6461
  const queue = newMeta.messageQueue;
6311
6462
  if (queue && queue.length > 0 && !sessionWasProcessing && !trackedSession.stopped) {
6312
6463
  setTimeout(() => {
@@ -6407,7 +6558,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6407
6558
  return { success: !!tree, tree };
6408
6559
  }
6409
6560
  },
6410
- { messagesDir: getSessionDir(directory, sessionId) }
6561
+ { messagesDir: getSessionDir(directory, sessionId), onSessionEvent: (update) => machineServiceRef?.notifySessionEvent(update) }
6411
6562
  );
6412
6563
  const svampConfig = createSvampConfigChecker(
6413
6564
  directory,
@@ -6494,6 +6645,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6494
6645
  pid: process.pid,
6495
6646
  svampSessionId: sessionId,
6496
6647
  hyphaService: sessionService,
6648
+ sessionRPCHandlers,
6497
6649
  checkSvampConfig,
6498
6650
  cleanupSvampConfig,
6499
6651
  directory,
@@ -6579,7 +6731,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6579
6731
  const allowedBashLiterals = /* @__PURE__ */ new Set();
6580
6732
  const allowedBashPrefixes = /* @__PURE__ */ new Set();
6581
6733
  const EDIT_TOOLS = /* @__PURE__ */ new Set(["Edit", "MultiEdit", "Write", "NotebookEdit"]);
6582
- const sessionService = await registerSessionService(
6734
+ const { store: sessionService, rpcHandlers: sessionRPCHandlersAcp } = createSessionStore(
6583
6735
  server,
6584
6736
  sessionId,
6585
6737
  sessionMetadata,
@@ -6691,13 +6843,22 @@ The automated loop has finished. Review the progress above and let me know if yo
6691
6843
  stopSession(sessionId);
6692
6844
  },
6693
6845
  onMetadataUpdate: (newMeta) => {
6846
+ const prevRalphLoop = sessionMetadata.ralphLoop;
6694
6847
  sessionMetadata = {
6695
6848
  ...newMeta,
6696
6849
  // Daemon drives lifecycleState — don't let frontend overwrite with stale value
6697
6850
  lifecycleState: sessionMetadata.lifecycleState,
6698
6851
  ...sessionMetadata.summary && !newMeta.summary ? { summary: sessionMetadata.summary } : {},
6699
- ...sessionMetadata.sessionLink && !newMeta.sessionLink ? { sessionLink: sessionMetadata.sessionLink } : {}
6852
+ ...sessionMetadata.sessionLink && !newMeta.sessionLink ? { sessionLink: sessionMetadata.sessionLink } : {},
6853
+ // Preserve parentSessionId — set at spawn time, frontend may not track it
6854
+ ...sessionMetadata.parentSessionId ? { parentSessionId: sessionMetadata.parentSessionId } : {},
6855
+ // Preserve daemon-owned ralphLoop — frontend may send stale snapshot without it,
6856
+ // which would wipe the active loop state and cause the bar to disappear mid-run.
6857
+ ...prevRalphLoop ? { ralphLoop: prevRalphLoop } : {}
6700
6858
  };
6859
+ if (prevRalphLoop && !newMeta.ralphLoop) {
6860
+ sessionService.updateMetadata(sessionMetadata);
6861
+ }
6701
6862
  if (acpStopped) return;
6702
6863
  const queue = newMeta.messageQueue;
6703
6864
  if (queue && queue.length > 0 && sessionMetadata.lifecycleState === "idle") {
@@ -6809,7 +6970,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6809
6970
  return { success: !!tree, tree };
6810
6971
  }
6811
6972
  },
6812
- { messagesDir: getSessionDir(directory, sessionId) }
6973
+ { messagesDir: getSessionDir(directory, sessionId), onSessionEvent: (update) => machineServiceRef?.notifySessionEvent(update) }
6813
6974
  );
6814
6975
  let insideOnTurnEnd = false;
6815
6976
  const svampConfigChecker = createSvampConfigChecker(
@@ -7031,6 +7192,7 @@ The automated loop has finished. Review the progress above and let me know if yo
7031
7192
  pid: process.pid,
7032
7193
  svampSessionId: sessionId,
7033
7194
  hyphaService: sessionService,
7195
+ sessionRPCHandlers: sessionRPCHandlersAcp,
7034
7196
  checkSvampConfig,
7035
7197
  cleanupSvampConfig: svampConfigChecker.cleanup,
7036
7198
  directory,
@@ -7156,10 +7318,28 @@ The automated loop has finished. Review the progress above and let me know if yo
7156
7318
  restartSession,
7157
7319
  requestShutdown: () => requestShutdown("hypha-app"),
7158
7320
  getTrackedSessions: getCurrentChildren,
7321
+ getSessionRPCHandlers: (sessionId) => {
7322
+ for (const [, session] of pidToTrackedSession) {
7323
+ if (session.svampSessionId === sessionId && session.sessionRPCHandlers) {
7324
+ return session.sessionRPCHandlers;
7325
+ }
7326
+ }
7327
+ return void 0;
7328
+ },
7329
+ getSessionIds: () => {
7330
+ const ids = [];
7331
+ for (const [, session] of pidToTrackedSession) {
7332
+ if (session.svampSessionId && !session.stopped && session.sessionRPCHandlers) {
7333
+ ids.push(session.svampSessionId);
7334
+ }
7335
+ }
7336
+ return ids;
7337
+ },
7159
7338
  supervisor
7160
7339
  }
7161
7340
  );
7162
7341
  logger.log(`Machine service registered: svamp-machine-${machineId}`);
7342
+ machineServiceRef = machineService;
7163
7343
  const artifactSync = new SessionArtifactSync(server, logger.log);
7164
7344
  const debugService = await registerDebugService(server, machineId, {
7165
7345
  machineId,
@@ -7347,7 +7527,7 @@ The automated loop has finished. Review the progress above and let me know if yo
7347
7527
  console.log(` Service: svamp-machine-${machineId}`);
7348
7528
  console.log(` Log file: ${logger.logFilePath}`);
7349
7529
  const HEARTBEAT_INTERVAL_MS = 1e4;
7350
- const PING_TIMEOUT_MS = 15e3;
7530
+ const PING_TIMEOUT_MS = 6e4;
7351
7531
  const MAX_FAILURES = 60;
7352
7532
  const POST_RECONNECT_GRACE_MS = 2e4;
7353
7533
  let heartbeatRunning = false;
@@ -7431,6 +7611,13 @@ The automated loop has finished. Review the progress above and let me know if yo
7431
7611
  } catch {
7432
7612
  }
7433
7613
  }
7614
+ if (conn?._reader) {
7615
+ logger.log("Aborting stale HTTP stream to trigger reconnection");
7616
+ try {
7617
+ conn._reader.cancel?.("Stale connection");
7618
+ } catch {
7619
+ }
7620
+ }
7434
7621
  }
7435
7622
  if (consecutiveHeartbeatFailures >= MAX_FAILURES) {
7436
7623
  logger.log(`Heartbeat failed ${MAX_FAILURES} times. Shutting down.`);