svamp-cli 0.1.68 → 0.1.70

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;
@@ -907,353 +907,351 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
907
907
  });
908
908
  return msg;
909
909
  };
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
- }
910
+ const serviceDefinition = {
911
+ id: `svamp-session-${sessionId}`,
912
+ name: `Svamp Session ${sessionId.slice(0, 8)}`,
913
+ type: "svamp-session",
914
+ config: { visibility: "unlisted", require_context: true },
915
+ // ── Messages ──
916
+ getMessages: async (afterSeq, limit, context) => {
917
+ authorizeRequest(context, metadata.sharing, "view");
918
+ const after = afterSeq ?? 0;
919
+ const lim = Math.min(limit ?? 100, 500);
920
+ const filtered = messages.filter((m) => m.seq > after);
921
+ const page = filtered.slice(0, lim);
922
+ return {
923
+ messages: page,
924
+ hasMore: filtered.length > lim
925
+ };
926
+ },
927
+ sendMessage: async (content, localId, meta, context) => {
928
+ authorizeRequest(context, metadata.sharing, "interact");
929
+ if (localId) {
930
+ const existing = messages.find((m) => m.localId === localId);
931
+ if (existing) {
932
+ return { id: existing.id, seq: existing.seq, localId: existing.localId };
949
933
  }
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);
934
+ }
935
+ let parsed = content;
936
+ if (typeof parsed === "string") {
937
+ try {
938
+ parsed = JSON.parse(parsed);
939
+ } catch {
963
940
  }
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
- };
941
+ }
942
+ if (parsed && typeof parsed.content === "string" && !parsed.role) {
943
+ try {
944
+ const inner = JSON.parse(parsed.content);
945
+ if (inner && typeof inner === "object") parsed = inner;
946
+ } catch {
988
947
  }
989
- metadata = newMetadata;
990
- metadataVersion++;
991
- notifyListeners({
992
- type: "update-session",
993
- sessionId,
994
- metadata: { value: metadata, version: metadataVersion }
995
- });
996
- callbacks.onMetadataUpdate?.(metadata);
948
+ }
949
+ const wrappedContent = parsed && parsed.role === "user" ? { role: "user", content: parsed.content } : { role: "user", content: { type: "text", text: typeof parsed === "string" ? parsed : JSON.stringify(parsed) } };
950
+ const msg = {
951
+ id: randomUUID(),
952
+ seq: nextSeq++,
953
+ content: wrappedContent,
954
+ localId: localId || randomUUID(),
955
+ createdAt: Date.now(),
956
+ updatedAt: Date.now()
957
+ };
958
+ messages.push(msg);
959
+ if (messages.length > 1e3) messages.splice(0, messages.length - 1e3);
960
+ if (options?.messagesDir) {
961
+ appendMessage(options.messagesDir, sessionId, msg);
962
+ }
963
+ notifyListeners({
964
+ type: "new-message",
965
+ sessionId,
966
+ message: msg
967
+ });
968
+ callbacks.onUserMessage(content, meta);
969
+ return { id: msg.id, seq: msg.seq, localId: msg.localId };
970
+ },
971
+ // ── Metadata ──
972
+ getMetadata: async (context) => {
973
+ authorizeRequest(context, metadata.sharing, "view");
974
+ return {
975
+ metadata,
976
+ version: metadataVersion
977
+ };
978
+ },
979
+ updateMetadata: async (newMetadata, expectedVersion, context) => {
980
+ authorizeRequest(context, metadata.sharing, "admin");
981
+ if (expectedVersion !== void 0 && expectedVersion !== metadataVersion) {
997
982
  return {
998
- result: "success",
983
+ result: "version-mismatch",
999
984
  version: metadataVersion,
1000
985
  metadata
1001
986
  };
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
- });
987
+ }
988
+ metadata = newMetadata;
989
+ metadataVersion++;
990
+ notifyListeners({
991
+ type: "update-session",
992
+ sessionId,
993
+ metadata: { value: metadata, version: metadataVersion }
994
+ });
995
+ callbacks.onMetadataUpdate?.(metadata);
996
+ return {
997
+ result: "success",
998
+ version: metadataVersion,
999
+ metadata
1000
+ };
1001
+ },
1002
+ /**
1003
+ * Patch the session config file (.svamp/{sessionId}/config.json).
1004
+ * Used by the frontend to set title, session_link, ralph_loop, etc.
1005
+ * Null values remove keys from the config.
1006
+ */
1007
+ updateConfig: async (patch, context) => {
1008
+ authorizeRequest(context, metadata.sharing, "admin");
1009
+ callbacks.onUpdateConfig?.(patch);
1010
+ return { success: true };
1011
+ },
1012
+ // ── Agent State ──
1013
+ getAgentState: async (context) => {
1014
+ authorizeRequest(context, metadata.sharing, "view");
1015
+ return {
1016
+ agentState,
1017
+ version: agentStateVersion
1018
+ };
1019
+ },
1020
+ updateAgentState: async (newState, expectedVersion, context) => {
1021
+ authorizeRequest(context, metadata.sharing, "admin");
1022
+ if (expectedVersion !== void 0 && expectedVersion !== agentStateVersion) {
1037
1023
  return {
1038
- result: "success",
1024
+ result: "version-mismatch",
1039
1025
  version: agentStateVersion,
1040
1026
  agentState
1041
1027
  };
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");
1028
+ }
1029
+ agentState = newState;
1030
+ agentStateVersion++;
1031
+ notifyListeners({
1032
+ type: "update-session",
1033
+ sessionId,
1034
+ agentState: { value: agentState, version: agentStateVersion }
1035
+ });
1036
+ return {
1037
+ result: "success",
1038
+ version: agentStateVersion,
1039
+ agentState
1040
+ };
1041
+ },
1042
+ // ── Session Control RPCs ──
1043
+ abort: async (context) => {
1044
+ authorizeRequest(context, metadata.sharing, "interact");
1045
+ callbacks.onAbort();
1046
+ return { success: true };
1047
+ },
1048
+ permissionResponse: async (params, context) => {
1049
+ authorizeRequest(context, metadata.sharing, "interact");
1050
+ callbacks.onPermissionResponse(params);
1051
+ return { success: true };
1052
+ },
1053
+ switchMode: async (mode, context) => {
1054
+ authorizeRequest(context, metadata.sharing, "admin");
1055
+ callbacks.onSwitchMode(mode);
1056
+ return { success: true };
1057
+ },
1058
+ restartClaude: async (context) => {
1059
+ authorizeRequest(context, metadata.sharing, "admin");
1060
+ return await callbacks.onRestartClaude();
1061
+ },
1062
+ killSession: async (context) => {
1063
+ authorizeRequest(context, metadata.sharing, "admin");
1064
+ callbacks.onKillSession();
1065
+ return { success: true };
1066
+ },
1067
+ // ── Activity ──
1068
+ keepAlive: async (thinking, mode, context) => {
1069
+ authorizeRequest(context, metadata.sharing, "interact");
1070
+ lastActivity = { active: true, thinking: thinking || false, mode: mode || "remote", time: Date.now() };
1071
+ notifyListeners({
1072
+ type: "activity",
1073
+ sessionId,
1074
+ ...lastActivity
1075
+ });
1076
+ },
1077
+ sessionEnd: async (context) => {
1078
+ authorizeRequest(context, metadata.sharing, "interact");
1079
+ lastActivity = { active: false, thinking: false, mode: "remote", time: Date.now() };
1080
+ notifyListeners({
1081
+ type: "activity",
1082
+ sessionId,
1083
+ ...lastActivity
1084
+ });
1085
+ },
1086
+ // ── Activity State Query ──
1087
+ getActivityState: async (context) => {
1088
+ authorizeRequest(context, metadata.sharing, "view");
1089
+ const pendingPermissions = agentState?.requests ? Object.entries(agentState.requests).filter(([, req]) => req.status === "pending" || !req.status).map(([id, req]) => ({
1090
+ id,
1091
+ tool: req.tool,
1092
+ arguments: req.arguments,
1093
+ createdAt: req.createdAt
1094
+ })) : [];
1095
+ return { ...lastActivity, sessionId, pendingPermissions };
1096
+ },
1097
+ // ── File Operations (optional, admin-only) ──
1098
+ readFile: async (path, context) => {
1099
+ authorizeRequest(context, metadata.sharing, "admin");
1100
+ if (!callbacks.onReadFile) throw new Error("readFile not supported");
1101
+ return await callbacks.onReadFile(path);
1102
+ },
1103
+ writeFile: async (path, content, context) => {
1104
+ authorizeRequest(context, metadata.sharing, "admin");
1105
+ if (!callbacks.onWriteFile) throw new Error("writeFile not supported");
1106
+ await callbacks.onWriteFile(path, content);
1107
+ return { success: true };
1108
+ },
1109
+ listDirectory: async (path, context) => {
1110
+ authorizeRequest(context, metadata.sharing, "admin");
1111
+ if (!callbacks.onListDirectory) throw new Error("listDirectory not supported");
1112
+ return await callbacks.onListDirectory(path);
1113
+ },
1114
+ bash: async (command, cwd, context) => {
1115
+ authorizeRequest(context, metadata.sharing, "admin");
1116
+ if (!callbacks.onBash) throw new Error("bash not supported");
1117
+ return await callbacks.onBash(command, cwd);
1118
+ },
1119
+ ripgrep: async (args, cwd, context) => {
1120
+ authorizeRequest(context, metadata.sharing, "admin");
1121
+ if (!callbacks.onRipgrep) throw new Error("ripgrep not supported");
1122
+ try {
1123
+ const stdout = await callbacks.onRipgrep(args, cwd);
1124
+ return { success: true, stdout, stderr: "", exitCode: 0 };
1125
+ } catch (err) {
1126
+ return { success: false, stdout: "", stderr: err.message || "", exitCode: 1, error: err.message };
1127
+ }
1128
+ },
1129
+ getDirectoryTree: async (path, maxDepth, context) => {
1130
+ authorizeRequest(context, metadata.sharing, "admin");
1131
+ if (!callbacks.onGetDirectoryTree) throw new Error("getDirectoryTree not supported");
1132
+ return await callbacks.onGetDirectoryTree(path, maxDepth ?? 3);
1133
+ },
1134
+ // ── Sharing Management ──
1135
+ getSharing: async (context) => {
1136
+ authorizeRequest(context, metadata.sharing, "view");
1137
+ return { sharing: metadata.sharing || null };
1138
+ },
1139
+ /** Returns the caller's effective role (null if no access). Does not throw. */
1140
+ getEffectiveRole: async (context) => {
1141
+ authorizeRequest(context, metadata.sharing, "view");
1142
+ const role = getEffectiveRole(context, metadata.sharing);
1143
+ return { role };
1144
+ },
1145
+ updateSharing: async (newSharing, context) => {
1146
+ authorizeRequest(context, metadata.sharing, "admin");
1147
+ if (metadata.sharing && context?.user?.email && metadata.sharing.owner && context.user.email.toLowerCase() !== metadata.sharing.owner.toLowerCase()) {
1148
+ throw new Error("Only the session owner can update sharing settings");
1149
+ }
1150
+ if (newSharing.enabled && !newSharing.owner && context?.user?.email) {
1151
+ newSharing = { ...newSharing, owner: context.user.email };
1152
+ }
1153
+ metadata = { ...metadata, sharing: newSharing };
1154
+ metadataVersion++;
1155
+ notifyListeners({
1156
+ type: "update-session",
1157
+ sessionId,
1158
+ metadata: { value: metadata, version: metadataVersion }
1159
+ });
1160
+ callbacks.onSharingUpdate?.(newSharing);
1161
+ return { success: true, sharing: newSharing };
1162
+ },
1163
+ /** Update security context and restart the agent process with new rules */
1164
+ updateSecurityContext: async (newSecurityContext, context) => {
1165
+ authorizeRequest(context, metadata.sharing, "admin");
1166
+ if (metadata.sharing && context?.user?.email && metadata.sharing.owner && context.user.email.toLowerCase() !== metadata.sharing.owner.toLowerCase()) {
1167
+ throw new Error("Only the session owner can update security context");
1168
+ }
1169
+ if (!callbacks.onUpdateSecurityContext) {
1170
+ throw new Error("Security context updates are not supported for this session");
1171
+ }
1172
+ metadata = { ...metadata, securityContext: newSecurityContext };
1173
+ metadataVersion++;
1174
+ notifyListeners({
1175
+ type: "update-session",
1176
+ sessionId,
1177
+ metadata: { value: metadata, version: metadataVersion }
1178
+ });
1179
+ return await callbacks.onUpdateSecurityContext(newSecurityContext);
1180
+ },
1181
+ /** Apply a new system prompt and restart the agent process */
1182
+ applySystemPrompt: async (prompt, context) => {
1183
+ authorizeRequest(context, metadata.sharing, "admin");
1184
+ if (!callbacks.onApplySystemPrompt) {
1185
+ throw new Error("System prompt updates are not supported for this session");
1186
+ }
1187
+ return await callbacks.onApplySystemPrompt(prompt);
1188
+ },
1189
+ // ── Listener Registration ──
1190
+ registerListener: async (callback, context) => {
1191
+ authorizeRequest(context, metadata.sharing, "view");
1192
+ listeners.push(callback);
1193
+ const replayMessages = messages.slice(-50);
1194
+ const REPLAY_MESSAGE_TIMEOUT_MS = 1e4;
1195
+ for (const msg of replayMessages) {
1196
+ if (listeners.indexOf(callback) < 0) break;
1123
1197
  try {
1124
- const stdout = await callbacks.onRipgrep(args, cwd);
1125
- return { success: true, stdout, stderr: "", exitCode: 0 };
1198
+ const result = callback.onUpdate({
1199
+ type: "new-message",
1200
+ sessionId,
1201
+ message: msg
1202
+ });
1203
+ if (result && typeof result.catch === "function") {
1204
+ try {
1205
+ await Promise.race([
1206
+ result,
1207
+ new Promise(
1208
+ (_, reject) => setTimeout(() => reject(new Error("Replay message timeout")), REPLAY_MESSAGE_TIMEOUT_MS)
1209
+ )
1210
+ ]);
1211
+ } catch (err) {
1212
+ console.error(`[HYPHA SESSION ${sessionId}] Replay listener error, removing:`, err?.message ?? err);
1213
+ removeListener(callback, "replay error");
1214
+ return { success: false, error: "Listener removed during replay" };
1215
+ }
1216
+ }
1126
1217
  } catch (err) {
1127
- return { success: false, stdout: "", stderr: err.message || "", exitCode: 1, error: err.message };
1218
+ console.error(`[HYPHA SESSION ${sessionId}] Replay listener error, removing:`, err?.message ?? err);
1219
+ removeListener(callback, "replay error");
1220
+ return { success: false, error: "Listener removed during replay" };
1128
1221
  }
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({
1222
+ }
1223
+ if (listeners.indexOf(callback) < 0) {
1224
+ return { success: false, error: "Listener was removed during replay" };
1225
+ }
1226
+ try {
1227
+ const result = callback.onUpdate({
1157
1228
  type: "update-session",
1158
1229
  sessionId,
1159
- metadata: { value: metadata, version: metadataVersion }
1230
+ metadata: { value: metadata, version: metadataVersion },
1231
+ agentState: { value: agentState, version: agentStateVersion }
1160
1232
  });
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");
1233
+ if (result && typeof result.catch === "function") {
1234
+ result.catch(() => {
1235
+ });
1172
1236
  }
1173
- metadata = { ...metadata, securityContext: newSecurityContext };
1174
- metadataVersion++;
1175
- notifyListeners({
1176
- type: "update-session",
1237
+ } catch {
1238
+ }
1239
+ try {
1240
+ const result = callback.onUpdate({
1241
+ type: "activity",
1177
1242
  sessionId,
1178
- metadata: { value: metadata, version: metadataVersion }
1243
+ ...lastActivity
1179
1244
  });
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 }
1245
+ if (result && typeof result.catch === "function") {
1246
+ result.catch(() => {
1233
1247
  });
1234
- if (result && typeof result.catch === "function") {
1235
- result.catch(() => {
1236
- });
1237
- }
1238
- } catch {
1239
1248
  }
1240
- try {
1241
- const result = callback.onUpdate({
1242
- type: "activity",
1243
- sessionId,
1244
- ...lastActivity
1245
- });
1246
- if (result && typeof result.catch === "function") {
1247
- result.catch(() => {
1248
- });
1249
- }
1250
- } catch {
1251
- }
1252
- return { success: true, listenerId: listeners.length - 1 };
1249
+ } catch {
1253
1250
  }
1254
- },
1255
- { overwrite: true }
1256
- );
1251
+ return { success: true, listenerId: listeners.length - 1 };
1252
+ }
1253
+ };
1254
+ const serviceInfo = await server.registerService(serviceDefinition, { overwrite: true });
1257
1255
  console.log(`[HYPHA SESSION] Session service registered: ${serviceInfo.id}`);
1258
1256
  return {
1259
1257
  serviceInfo,
@@ -1316,6 +1314,13 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1316
1314
  removeListener(listener, "disconnect");
1317
1315
  }
1318
1316
  await server.unregisterService(serviceInfo.id);
1317
+ },
1318
+ reregister: async () => {
1319
+ try {
1320
+ await server.registerService(serviceDefinition, { overwrite: true });
1321
+ } catch (e) {
1322
+ if (!String(e?.message).includes("already exists")) throw e;
1323
+ }
1319
1324
  }
1320
1325
  };
1321
1326
  }
@@ -1796,6 +1801,10 @@ function wrapWithNono(command, args, config) {
1796
1801
  if (existsSync(realLocalDir)) {
1797
1802
  nonoArgs.push("--read", realLocalDir);
1798
1803
  }
1804
+ const realKeychainDir = join$1(homedir(), "Library", "Keychains");
1805
+ if (existsSync(realKeychainDir)) {
1806
+ nonoArgs.push("--read", realKeychainDir);
1807
+ }
1799
1808
  }
1800
1809
  if (config.nonoConfig?.allowDirs) {
1801
1810
  for (const dir of config.nonoConfig.allowDirs) {
@@ -3886,9 +3895,8 @@ async function stageCredentialsForSharing(sessionId) {
3886
3895
  const realHome = homedir();
3887
3896
  const realClaudeDir = join$1(realHome, ".claude");
3888
3897
  await mkdir(STAGED_HOMES_DIR, { recursive: true });
3889
- const tmpHome = await mkdtemp(
3890
- join$1(STAGED_HOMES_DIR, `${sessionId.slice(0, 8)}-`)
3891
- );
3898
+ const tmpHome = join$1(STAGED_HOMES_DIR, sessionId);
3899
+ await mkdir(tmpHome, { recursive: true });
3892
3900
  const stagedClaudeDir = join$1(tmpHome, ".claude");
3893
3901
  await mkdir(stagedClaudeDir, { recursive: true });
3894
3902
  const credentialFiles = ["credentials.json", ".credentials.json"];
@@ -3909,10 +3917,12 @@ async function stageCredentialsForSharing(sessionId) {
3909
3917
  );
3910
3918
  } catch {
3911
3919
  }
3912
- const { writeFile } = await import('node:fs/promises');
3913
- try {
3914
- await writeFile(join$1(tmpHome, ".claude.json"), "{}");
3915
- } catch {
3920
+ const claudeJsonPath = join$1(tmpHome, ".claude.json");
3921
+ if (!existsSync(claudeJsonPath)) {
3922
+ try {
3923
+ await writeFile(claudeJsonPath, "{}");
3924
+ } catch {
3925
+ }
3916
3926
  }
3917
3927
  return {
3918
3928
  homePath: tmpHome,
@@ -4154,7 +4164,7 @@ class ProcessSupervisor {
4154
4164
  async persistSpec(spec) {
4155
4165
  const filePath = path.join(this.persistDir, `${spec.id}.json`);
4156
4166
  const tmpPath = filePath + ".tmp";
4157
- await writeFile(tmpPath, JSON.stringify(spec, null, 2), "utf-8");
4167
+ await writeFile$1(tmpPath, JSON.stringify(spec, null, 2), "utf-8");
4158
4168
  await rename(tmpPath, filePath);
4159
4169
  }
4160
4170
  async deleteSpec(id) {
@@ -4467,6 +4477,53 @@ async function installSkillFromMarketplace(name) {
4467
4477
  writeFileSync(localPath, content, "utf-8");
4468
4478
  }
4469
4479
  }
4480
+ function preventMachineSleep(logger) {
4481
+ if (process.platform === "darwin") {
4482
+ const caff = spawn$1("caffeinate", ["-s", "-i", "-m"], { stdio: "ignore", detached: false });
4483
+ caff.on("error", (err) => {
4484
+ logger.log(`Warning: could not start caffeinate to prevent sleep: ${err.message}`);
4485
+ });
4486
+ caff.on("exit", (code) => {
4487
+ if (code !== null && code !== 0) {
4488
+ logger.log(`Warning: caffeinate exited unexpectedly (code ${code})`);
4489
+ }
4490
+ });
4491
+ process.on("exit", () => {
4492
+ try {
4493
+ caff.kill();
4494
+ } catch {
4495
+ }
4496
+ });
4497
+ logger.log("caffeinate started \u2014 machine will not sleep while daemon is running");
4498
+ return;
4499
+ }
4500
+ if (process.platform === "linux") {
4501
+ const inh = spawn$1(
4502
+ "systemd-inhibit",
4503
+ ["--what=idle:sleep:handle-lid-switch", "--who=svamp", "--why=Svamp daemon running", "--mode=block", "sleep", "infinity"],
4504
+ { stdio: "ignore", detached: false }
4505
+ );
4506
+ inh.on("error", () => {
4507
+ });
4508
+ inh.on("exit", (code) => {
4509
+ if (code !== null && code !== 0 && code !== 130) {
4510
+ logger.log(`Warning: systemd-inhibit exited with code ${code} \u2014 sleep prevention may be inactive`);
4511
+ }
4512
+ });
4513
+ process.on("exit", () => {
4514
+ try {
4515
+ inh.kill();
4516
+ } catch {
4517
+ }
4518
+ });
4519
+ setImmediate(() => {
4520
+ if (!inh.killed && inh.exitCode === null) {
4521
+ logger.log("systemd-inhibit started \u2014 machine will not idle-sleep while daemon is running");
4522
+ }
4523
+ });
4524
+ return;
4525
+ }
4526
+ }
4470
4527
  async function ensureAutoInstalledSkills(logger) {
4471
4528
  const tasks = [
4472
4529
  {
@@ -5159,8 +5216,12 @@ async function startDaemon(options) {
5159
5216
  // hypha-rpc connection closed message
5160
5217
  "Client disconnected",
5161
5218
  // Hypha client disconnect events
5162
- "fetch failed"
5219
+ "fetch failed",
5163
5220
  // Fetch API errors during reconnect
5221
+ "already exists in the cache store",
5222
+ // hypha-rpc: stale in-flight RPC calls after reconnect try to resolve with duplicate message keys
5223
+ "Failed to send the request when calling method"
5224
+ // hypha-rpc: RPC call failed after reconnect (often wraps the cache store error)
5164
5225
  ];
5165
5226
  let unhandledRejectionCount = 0;
5166
5227
  let unhandledRejectionResetTimer = null;
@@ -5255,6 +5316,7 @@ async function startDaemon(options) {
5255
5316
  await supervisor.init();
5256
5317
  ensureAutoInstalledSkills(logger).catch(() => {
5257
5318
  });
5319
+ preventMachineSleep(logger);
5258
5320
  try {
5259
5321
  logger.log("Connecting to Hypha server...");
5260
5322
  server = await connectToHypha({
@@ -5268,14 +5330,22 @@ async function startDaemon(options) {
5268
5330
  logger.log(`Hypha connection permanently lost: ${reason}`);
5269
5331
  requestShutdown("hypha-disconnected", String(reason));
5270
5332
  });
5333
+ const pidToTrackedSession = /* @__PURE__ */ new Map();
5271
5334
  server.on("services_registered", () => {
5272
5335
  if (consecutiveHeartbeatFailures > 0) {
5273
5336
  logger.log(`Hypha reconnection successful \u2014 services re-registered (resetting ${consecutiveHeartbeatFailures} failures)`);
5274
5337
  consecutiveHeartbeatFailures = 0;
5275
5338
  lastReconnectAt = Date.now();
5276
5339
  }
5340
+ const activeSessions = Array.from(pidToTrackedSession.values()).filter((s) => !s.stopped && s.hyphaService);
5341
+ if (activeSessions.length > 0) {
5342
+ logger.log(`Re-registering ${activeSessions.length} session services after reconnect`);
5343
+ Promise.allSettled(activeSessions.map((s) => s.hyphaService.reregister())).then((results) => {
5344
+ const failed = results.filter((r) => r.status === "rejected").length;
5345
+ if (failed > 0) logger.log(`Warning: ${failed} session service re-registrations failed`);
5346
+ });
5347
+ }
5277
5348
  });
5278
- const pidToTrackedSession = /* @__PURE__ */ new Map();
5279
5349
  const getCurrentChildren = () => {
5280
5350
  return Array.from(pidToTrackedSession.values()).map((s) => ({
5281
5351
  sessionId: s.svampSessionId || `PID-${s.pid}`,
@@ -5412,6 +5482,10 @@ async function startDaemon(options) {
5412
5482
  securityContext: options2.securityContext,
5413
5483
  tags: options2.tags,
5414
5484
  parentSessionId: options2.parentSessionId,
5485
+ ...options2.parentSessionId && (() => {
5486
+ const parentTracked = Array.from(pidToTrackedSession.values()).find((t) => t.svampSessionId === options2.parentSessionId);
5487
+ return parentTracked?.directory ? { parentSessionPath: parentTracked.directory } : {};
5488
+ })(),
5415
5489
  ...options2.injectPlatformGuidance !== void 0 && { injectPlatformGuidance: options2.injectPlatformGuidance }
5416
5490
  };
5417
5491
  let claudeProcess = null;
@@ -5423,6 +5497,9 @@ async function startDaemon(options) {
5423
5497
  let sessionWasProcessing = !!options2.wasProcessing;
5424
5498
  let lastAssistantText = "";
5425
5499
  let spawnHasReceivedInit = false;
5500
+ let startupFailureRetryPending = false;
5501
+ let startupRetryMessage;
5502
+ let startupNonJsonLines = [];
5426
5503
  const signalProcessing = (processing) => {
5427
5504
  sessionService.sendKeepAlive(processing);
5428
5505
  const newState = processing ? "running" : "idle";
@@ -5473,6 +5550,8 @@ async function startDaemon(options) {
5473
5550
  let isolationCleanupFiles = [];
5474
5551
  const spawnClaude = (initialMessage, meta) => {
5475
5552
  const effectiveMeta = { ...lastSpawnMeta, ...meta };
5553
+ startupNonJsonLines = [];
5554
+ startupRetryMessage = initialMessage;
5476
5555
  let rawPermissionMode = effectiveMeta.permissionMode || agentConfig.default_permission_mode || currentPermissionMode;
5477
5556
  if (options2.forceIsolation || sessionMetadata.sharing?.enabled) {
5478
5557
  rawPermissionMode = rawPermissionMode === "default" ? "auto-approve-all" : rawPermissionMode;
@@ -5668,23 +5747,34 @@ async function startDaemon(options) {
5668
5747
  if (msg.is_error) {
5669
5748
  const resultText = msg.result || "";
5670
5749
  logger.error(`[Session ${sessionId}] Claude error (is_error=true, api_ms=${msg.duration_api_ms}): "${resultText}"`);
5671
- const lower = resultText.toLowerCase();
5672
- const isLoginIssue = lower.includes("login") || lower.includes("logged in") || lower.includes("auth") || lower.includes("api key") || lower.includes("unauthorized");
5673
- const isResumeIssue = lower.includes("tool_use.name") || lower.includes("invalid_request") || lower.includes("messages.");
5674
- let hint = "";
5675
- if (isLoginIssue) {
5676
- hint = "\n\nRun `claude login` in your terminal on the machine running the daemon to re-authenticate.";
5677
- } else if (isResumeIssue) {
5678
- hint = "\n\nThe conversation history may be corrupted. Try starting a fresh session.";
5750
+ const isStartupFailure = msg.duration_api_ms === 0 && msg.num_turns === 0;
5751
+ if (isStartupFailure && !startupFailureRetryPending) {
5752
+ logger.log(`[Session ${sessionId}] Startup failure detected \u2014 scheduling silent retry without --resume`);
5753
+ startupFailureRetryPending = true;
5754
+ lastErrorMessagePushed = true;
5679
5755
  } else {
5680
- hint = "\n\nCheck that the Claude Code CLI is properly installed and configured.";
5756
+ const lower = resultText.toLowerCase();
5757
+ const isLoginIssue = lower.includes("login") || lower.includes("logged in") || lower.includes("auth") || lower.includes("api key") || lower.includes("unauthorized");
5758
+ const isResumeIssue = lower.includes("tool_use.name") || lower.includes("invalid_request") || lower.includes("messages.");
5759
+ let hint = "";
5760
+ if (isLoginIssue) {
5761
+ hint = "\n\nRun `claude login` in your terminal on the machine running the daemon to re-authenticate.";
5762
+ } else if (isResumeIssue) {
5763
+ hint = "\n\nThe conversation history may be corrupted. Try starting a fresh session.";
5764
+ } else {
5765
+ hint = "\n\nCheck that the Claude Code CLI is properly installed and configured.";
5766
+ }
5767
+ const displayMsg = resultText || "Claude Code exited with an error.";
5768
+ let contextInfo = "";
5769
+ if (startupNonJsonLines.length > 0) {
5770
+ contextInfo = "\n\n**Startup output:**\n```\n" + startupNonJsonLines.slice(-10).join("\n") + "\n```";
5771
+ }
5772
+ sessionService.pushMessage({
5773
+ type: "assistant",
5774
+ content: [{ type: "text", text: `**Error:** ${displayMsg}${hint}${contextInfo}` }]
5775
+ }, "agent");
5776
+ lastErrorMessagePushed = true;
5681
5777
  }
5682
- const displayMsg = resultText || "Claude Code exited with an error.";
5683
- sessionService.pushMessage({
5684
- type: "assistant",
5685
- content: [{ type: "text", text: `**Error:** ${displayMsg}${hint}` }]
5686
- }, "agent");
5687
- lastErrorMessagePushed = true;
5688
5778
  }
5689
5779
  }
5690
5780
  if (msg.type === "result") {
@@ -5899,6 +5989,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5899
5989
  const isResumeFailure = !spawnHasReceivedInit && claudeResumeId && msg.session_id !== claudeResumeId;
5900
5990
  const isConversationClear = spawnHasReceivedInit && claudeResumeId && msg.session_id !== claudeResumeId;
5901
5991
  spawnHasReceivedInit = true;
5992
+ startupFailureRetryPending = false;
5902
5993
  claudeResumeId = msg.session_id;
5903
5994
  sessionMetadata = { ...sessionMetadata, claudeSessionId: msg.session_id };
5904
5995
  sessionService.updateMetadata(sessionMetadata);
@@ -5940,6 +6031,9 @@ The automated loop has finished. Review the progress above and let me know if yo
5940
6031
  }
5941
6032
  } catch {
5942
6033
  logger.log(`[Session ${sessionId}] Claude stdout (non-JSON): ${line}`);
6034
+ if (!spawnHasReceivedInit) {
6035
+ startupNonJsonLines.push(line.slice(0, 500));
6036
+ }
5943
6037
  }
5944
6038
  }
5945
6039
  });
@@ -5984,6 +6078,19 @@ The automated loop has finished. Review the progress above and let me know if yo
5984
6078
  sessionMetadata = { ...sessionMetadata, lifecycleState: claudeResumeId ? "idle" : "stopped" };
5985
6079
  sessionService.updateMetadata(sessionMetadata);
5986
6080
  sessionWasProcessing = false;
6081
+ if (startupFailureRetryPending && !trackedSession.stopped) {
6082
+ startupFailureRetryPending = false;
6083
+ const prevResumeId = claudeResumeId;
6084
+ claudeResumeId = void 0;
6085
+ logger.log(`[Session ${sessionId}] Startup failure \u2014 cleared stale resume ID (was: ${prevResumeId})`);
6086
+ if (startupRetryMessage !== void 0) {
6087
+ logger.log(`[Session ${sessionId}] Retrying startup without --resume`);
6088
+ sessionMetadata = { ...sessionMetadata, lifecycleState: "running" };
6089
+ sessionService.updateMetadata(sessionMetadata);
6090
+ spawnClaude(startupRetryMessage);
6091
+ return;
6092
+ }
6093
+ }
5987
6094
  const queueLen = sessionMetadata.messageQueue?.length ?? 0;
5988
6095
  if (queueLen > 0 && claudeResumeId && !trackedSession.stopped) {
5989
6096
  signalProcessing(false);
@@ -6242,6 +6349,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6242
6349
  stopSession(sessionId);
6243
6350
  },
6244
6351
  onMetadataUpdate: (newMeta) => {
6352
+ const prevRalphLoop = sessionMetadata.ralphLoop;
6245
6353
  sessionMetadata = {
6246
6354
  ...newMeta,
6247
6355
  // Daemon drives lifecycleState — don't let frontend overwrite with stale value
@@ -6249,8 +6357,16 @@ The automated loop has finished. Review the progress above and let me know if yo
6249
6357
  // Preserve claudeSessionId set by 'system init' (frontend may not have it)
6250
6358
  ...sessionMetadata.claudeSessionId ? { claudeSessionId: sessionMetadata.claudeSessionId } : {},
6251
6359
  ...sessionMetadata.summary && !newMeta.summary ? { summary: sessionMetadata.summary } : {},
6252
- ...sessionMetadata.sessionLink && !newMeta.sessionLink ? { sessionLink: sessionMetadata.sessionLink } : {}
6360
+ ...sessionMetadata.sessionLink && !newMeta.sessionLink ? { sessionLink: sessionMetadata.sessionLink } : {},
6361
+ // Preserve parentSessionId — set at spawn time, frontend may not track it
6362
+ ...sessionMetadata.parentSessionId ? { parentSessionId: sessionMetadata.parentSessionId } : {},
6363
+ // Preserve daemon-owned ralphLoop — frontend may send stale snapshot without it,
6364
+ // which would wipe the active loop state and cause the bar to disappear mid-run.
6365
+ ...prevRalphLoop ? { ralphLoop: prevRalphLoop } : {}
6253
6366
  };
6367
+ if (prevRalphLoop && !newMeta.ralphLoop) {
6368
+ sessionService.updateMetadata(sessionMetadata);
6369
+ }
6254
6370
  const queue = newMeta.messageQueue;
6255
6371
  if (queue && queue.length > 0 && !sessionWasProcessing && !trackedSession.stopped) {
6256
6372
  setTimeout(() => {
@@ -6635,13 +6751,22 @@ The automated loop has finished. Review the progress above and let me know if yo
6635
6751
  stopSession(sessionId);
6636
6752
  },
6637
6753
  onMetadataUpdate: (newMeta) => {
6754
+ const prevRalphLoop = sessionMetadata.ralphLoop;
6638
6755
  sessionMetadata = {
6639
6756
  ...newMeta,
6640
6757
  // Daemon drives lifecycleState — don't let frontend overwrite with stale value
6641
6758
  lifecycleState: sessionMetadata.lifecycleState,
6642
6759
  ...sessionMetadata.summary && !newMeta.summary ? { summary: sessionMetadata.summary } : {},
6643
- ...sessionMetadata.sessionLink && !newMeta.sessionLink ? { sessionLink: sessionMetadata.sessionLink } : {}
6760
+ ...sessionMetadata.sessionLink && !newMeta.sessionLink ? { sessionLink: sessionMetadata.sessionLink } : {},
6761
+ // Preserve parentSessionId — set at spawn time, frontend may not track it
6762
+ ...sessionMetadata.parentSessionId ? { parentSessionId: sessionMetadata.parentSessionId } : {},
6763
+ // Preserve daemon-owned ralphLoop — frontend may send stale snapshot without it,
6764
+ // which would wipe the active loop state and cause the bar to disappear mid-run.
6765
+ ...prevRalphLoop ? { ralphLoop: prevRalphLoop } : {}
6644
6766
  };
6767
+ if (prevRalphLoop && !newMeta.ralphLoop) {
6768
+ sessionService.updateMetadata(sessionMetadata);
6769
+ }
6645
6770
  if (acpStopped) return;
6646
6771
  const queue = newMeta.messageQueue;
6647
6772
  if (queue && queue.length > 0 && sessionMetadata.lifecycleState === "idle") {
@@ -7291,7 +7416,7 @@ The automated loop has finished. Review the progress above and let me know if yo
7291
7416
  console.log(` Service: svamp-machine-${machineId}`);
7292
7417
  console.log(` Log file: ${logger.logFilePath}`);
7293
7418
  const HEARTBEAT_INTERVAL_MS = 1e4;
7294
- const PING_TIMEOUT_MS = 15e3;
7419
+ const PING_TIMEOUT_MS = 6e4;
7295
7420
  const MAX_FAILURES = 60;
7296
7421
  const POST_RECONNECT_GRACE_MS = 2e4;
7297
7422
  let heartbeatRunning = false;