svamp-cli 0.1.62 → 0.1.64

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,6 +1,6 @@
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, unlink } from 'fs/promises';
3
- import { readFileSync as readFileSync$1, mkdirSync, writeFileSync, existsSync as existsSync$1, copyFileSync, unlinkSync, watch, rmdirSync } from 'fs';
2
+ import fs, { mkdir as mkdir$1, readdir, readFile, writeFile, rename, unlink } from 'fs/promises';
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';
6
6
  import { spawn as spawn$1 } from 'child_process';
@@ -70,7 +70,7 @@ const ISOLATION_PREFERENCE = ["nono", "docker", "podman"];
70
70
 
71
71
  function resolveRoleLevel(sharing, userEmail) {
72
72
  let level = -1;
73
- if (userEmail) {
73
+ if (userEmail && Array.isArray(sharing.allowedUsers)) {
74
74
  const sharedUser = sharing.allowedUsers.find(
75
75
  (u) => u.email.toLowerCase() === userEmail.toLowerCase()
76
76
  );
@@ -303,7 +303,10 @@ function loadPersistedMachineMetadata(svampHomeDir) {
303
303
  function savePersistedMachineMetadata(svampHomeDir, data) {
304
304
  try {
305
305
  mkdirSync(svampHomeDir, { recursive: true });
306
- writeFileSync(getMachineMetadataPath(svampHomeDir), JSON.stringify(data, null, 2));
306
+ const filePath = getMachineMetadataPath(svampHomeDir);
307
+ const tmpPath = filePath + ".tmp";
308
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2));
309
+ renameSync(tmpPath, filePath);
307
310
  } catch (err) {
308
311
  console.error("[HYPHA MACHINE] Failed to persist machine metadata:", err);
309
312
  }
@@ -313,9 +316,36 @@ async function registerMachineService(server, machineId, metadata, daemonState,
313
316
  let currentDaemonState = { ...daemonState };
314
317
  let metadataVersion = 1;
315
318
  let daemonStateVersion = 1;
316
- const subscribers = /* @__PURE__ */ new Set();
317
- const notifySubscribers = (update) => {
318
- for (const push of subscribers) push(update);
319
+ const listeners = [];
320
+ const removeListener = (listener, reason) => {
321
+ const idx = listeners.indexOf(listener);
322
+ if (idx >= 0) {
323
+ listeners.splice(idx, 1);
324
+ console.log(`[HYPHA MACHINE] Listener removed (${reason}), remaining: ${listeners.length}`);
325
+ const rintfId = listener._rintf_service_id;
326
+ if (rintfId) {
327
+ server.unregisterService(rintfId).catch(() => {
328
+ });
329
+ }
330
+ }
331
+ };
332
+ const notifyListeners = (update) => {
333
+ const snapshot = [...listeners];
334
+ for (let i = snapshot.length - 1; i >= 0; i--) {
335
+ const listener = snapshot[i];
336
+ try {
337
+ const result = listener.onUpdate(update);
338
+ if (result && typeof result.catch === "function") {
339
+ result.catch((err) => {
340
+ console.error(`[HYPHA MACHINE] Async listener error:`, err);
341
+ removeListener(listener, "async error");
342
+ });
343
+ }
344
+ } catch (err) {
345
+ console.error(`[HYPHA MACHINE] Listener error:`, err);
346
+ removeListener(listener, "sync error");
347
+ }
348
+ }
319
349
  };
320
350
  const serviceInfo = await server.registerService(
321
351
  {
@@ -335,11 +365,14 @@ async function registerMachineService(server, machineId, metadata, daemonState,
335
365
  };
336
366
  },
337
367
  // Heartbeat
338
- heartbeat: async (context) => ({
339
- time: Date.now(),
340
- status: currentDaemonState.status,
341
- machineId
342
- }),
368
+ heartbeat: async (context) => {
369
+ authorizeRequest(context, currentMetadata.sharing, "view");
370
+ return {
371
+ time: Date.now(),
372
+ status: currentDaemonState.status,
373
+ machineId
374
+ };
375
+ },
343
376
  // List active sessions on this machine
344
377
  listSessions: async (context) => {
345
378
  authorizeRequest(context, currentMetadata.sharing, "view");
@@ -401,7 +434,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
401
434
  machineId
402
435
  });
403
436
  if (result.type === "success" && result.sessionId) {
404
- notifySubscribers({
437
+ notifyListeners({
405
438
  type: "new-session",
406
439
  sessionId: result.sessionId,
407
440
  machineId
@@ -414,7 +447,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
414
447
  stopSession: async (sessionId, context) => {
415
448
  authorizeRequest(context, currentMetadata.sharing, "admin");
416
449
  const result = handlers.stopSession(sessionId);
417
- notifySubscribers({
450
+ notifyListeners({
418
451
  type: "session-stopped",
419
452
  sessionId,
420
453
  machineId
@@ -423,7 +456,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
423
456
  },
424
457
  // Restart agent process in a session (machine-level fallback)
425
458
  restartSession: async (sessionId, context) => {
426
- authorizeRequest(context, currentMetadata.sharing, "interact");
459
+ authorizeRequest(context, currentMetadata.sharing, "admin");
427
460
  return await handlers.restartSession(sessionId);
428
461
  },
429
462
  // Stop the daemon
@@ -455,7 +488,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
455
488
  sharing: currentMetadata.sharing,
456
489
  securityContextConfig: currentMetadata.securityContextConfig
457
490
  });
458
- notifySubscribers({
491
+ notifyListeners({
459
492
  type: "update-machine",
460
493
  machineId,
461
494
  metadata: { value: currentMetadata, version: metadataVersion }
@@ -485,7 +518,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
485
518
  }
486
519
  currentDaemonState = newState;
487
520
  daemonStateVersion++;
488
- notifySubscribers({
521
+ notifyListeners({
489
522
  type: "update-machine",
490
523
  machineId,
491
524
  daemonState: { value: currentDaemonState, version: daemonStateVersion }
@@ -515,7 +548,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
515
548
  sharing: currentMetadata.sharing,
516
549
  securityContextConfig: currentMetadata.securityContextConfig
517
550
  });
518
- notifySubscribers({
551
+ notifyListeners({
519
552
  type: "update-machine",
520
553
  machineId,
521
554
  metadata: { value: currentMetadata, version: metadataVersion }
@@ -536,48 +569,18 @@ async function registerMachineService(server, machineId, metadata, daemonState,
536
569
  sharing: currentMetadata.sharing,
537
570
  securityContextConfig: currentMetadata.securityContextConfig
538
571
  });
539
- notifySubscribers({
572
+ notifyListeners({
540
573
  type: "update-machine",
541
574
  machineId,
542
575
  metadata: { value: currentMetadata, version: metadataVersion }
543
576
  });
544
577
  return { success: true };
545
578
  },
546
- // Live update stream yields machine state changes as they happen.
547
- // Frontend iterates with `for await (const update of machine.subscribe())`.
548
- subscribe: async function* (context) {
579
+ // Register a listener for real-time updates (app calls this with _rintf callback)
580
+ registerListener: async (callback, context) => {
549
581
  authorizeRequest(context, currentMetadata.sharing, "view");
550
- const pending = [];
551
- let wake = null;
552
- const push = (update) => {
553
- pending.push(update);
554
- wake?.();
555
- };
556
- subscribers.add(push);
557
- console.log(`[HYPHA MACHINE] subscribe() started (total: ${subscribers.size})`);
558
- try {
559
- yield {
560
- type: "update-machine",
561
- machineId,
562
- metadata: { value: currentMetadata, version: metadataVersion },
563
- daemonState: { value: currentDaemonState, version: daemonStateVersion }
564
- };
565
- while (true) {
566
- while (pending.length === 0) {
567
- await new Promise((r) => {
568
- wake = r;
569
- });
570
- wake = null;
571
- }
572
- while (pending.length > 0) {
573
- yield pending.shift();
574
- }
575
- }
576
- } finally {
577
- subscribers.delete(push);
578
- wake?.();
579
- console.log(`[HYPHA MACHINE] subscribe() ended (remaining: ${subscribers.size})`);
580
- }
582
+ listeners.push(callback);
583
+ return { success: true, listenerId: listeners.length - 1 };
581
584
  },
582
585
  // Shell access
583
586
  bash: async (command, cwd, context) => {
@@ -603,7 +606,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
603
606
  const targetPath = resolve(path || homedir());
604
607
  const home = homedir();
605
608
  const isOwner = !currentMetadata.sharing?.enabled || context?.user?.email && currentMetadata.sharing.owner && context.user.email.toLowerCase() === currentMetadata.sharing.owner.toLowerCase();
606
- if (!isOwner && !targetPath.startsWith(home)) {
609
+ if (!isOwner && targetPath !== home && !targetPath.startsWith(home + "/")) {
607
610
  throw new Error(`Access denied: path must be within ${home}`);
608
611
  }
609
612
  const showHidden = options?.showHidden ?? false;
@@ -644,7 +647,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
644
647
  },
645
648
  /** Add and start a new supervised process. */
646
649
  processAdd: async (params, context) => {
647
- authorizeRequest(context, currentMetadata.sharing, "interact");
650
+ authorizeRequest(context, currentMetadata.sharing, "admin");
648
651
  if (!handlers.supervisor) throw new Error("Process supervisor not available");
649
652
  return handlers.supervisor.add(params.spec);
650
653
  },
@@ -653,7 +656,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
653
656
  * Returns { action: 'created'|'updated'|'no-change', info: ProcessInfo }
654
657
  */
655
658
  processApply: async (params, context) => {
656
- authorizeRequest(context, currentMetadata.sharing, "interact");
659
+ authorizeRequest(context, currentMetadata.sharing, "admin");
657
660
  if (!handlers.supervisor) throw new Error("Process supervisor not available");
658
661
  return handlers.supervisor.apply(params.spec);
659
662
  },
@@ -662,7 +665,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
662
665
  * Returns updated ProcessInfo.
663
666
  */
664
667
  processUpdate: async (params, context) => {
665
- authorizeRequest(context, currentMetadata.sharing, "interact");
668
+ authorizeRequest(context, currentMetadata.sharing, "admin");
666
669
  if (!handlers.supervisor) throw new Error("Process supervisor not available");
667
670
  return handlers.supervisor.update(params.idOrName, params.spec);
668
671
  },
@@ -707,7 +710,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
707
710
  serviceList: async (context) => {
708
711
  authorizeRequest(context, currentMetadata.sharing, "view");
709
712
  try {
710
- const { listServiceGroups } = await import('./api-Cegey1dh.mjs');
713
+ const { listServiceGroups } = await import('./api-BRbsyqJ4.mjs');
711
714
  return await listServiceGroups();
712
715
  } catch (err) {
713
716
  return [];
@@ -716,13 +719,13 @@ async function registerMachineService(server, machineId, metadata, daemonState,
716
719
  /** Get full details of a single service group (includes backends + health). */
717
720
  serviceGet: async (params, context) => {
718
721
  authorizeRequest(context, currentMetadata.sharing, "view");
719
- const { getServiceGroup } = await import('./api-Cegey1dh.mjs');
722
+ const { getServiceGroup } = await import('./api-BRbsyqJ4.mjs');
720
723
  return getServiceGroup(params.name);
721
724
  },
722
725
  /** Delete a service group. */
723
726
  serviceDelete: async (params, context) => {
724
727
  authorizeRequest(context, currentMetadata.sharing, "admin");
725
- const { deleteServiceGroup } = await import('./api-Cegey1dh.mjs');
728
+ const { deleteServiceGroup } = await import('./api-BRbsyqJ4.mjs');
726
729
  return deleteServiceGroup(params.name);
727
730
  },
728
731
  // WISE voice — create ephemeral token for OpenAI Realtime API
@@ -733,19 +736,27 @@ async function registerMachineService(server, machineId, metadata, daemonState,
733
736
  return { success: false, error: "No OpenAI API key found. Set OPENAI_API_KEY or pass apiKey." };
734
737
  }
735
738
  try {
736
- const response = await fetch("https://api.openai.com/v1/realtime/client_secrets", {
737
- method: "POST",
738
- headers: {
739
- "Authorization": `Bearer ${apiKey}`,
740
- "Content-Type": "application/json"
741
- },
742
- body: JSON.stringify({
743
- session: {
744
- type: "realtime",
745
- model: params.model || "gpt-realtime-mini"
746
- }
747
- })
748
- });
739
+ const wisCtrl = new AbortController();
740
+ const wisTimer = setTimeout(() => wisCtrl.abort(), 15e3);
741
+ let response;
742
+ try {
743
+ response = await fetch("https://api.openai.com/v1/realtime/client_secrets", {
744
+ method: "POST",
745
+ headers: {
746
+ "Authorization": `Bearer ${apiKey}`,
747
+ "Content-Type": "application/json"
748
+ },
749
+ body: JSON.stringify({
750
+ session: {
751
+ type: "realtime",
752
+ model: params.model || "gpt-realtime-mini"
753
+ }
754
+ }),
755
+ signal: wisCtrl.signal
756
+ });
757
+ } finally {
758
+ clearTimeout(wisTimer);
759
+ }
749
760
  if (!response.ok) {
750
761
  return { success: false, error: `OpenAI API error: ${response.status}` };
751
762
  }
@@ -764,7 +775,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
764
775
  updateMetadata: (newMetadata) => {
765
776
  currentMetadata = newMetadata;
766
777
  metadataVersion++;
767
- notifySubscribers({
778
+ notifyListeners({
768
779
  type: "update-machine",
769
780
  machineId,
770
781
  metadata: { value: currentMetadata, version: metadataVersion }
@@ -773,13 +784,17 @@ async function registerMachineService(server, machineId, metadata, daemonState,
773
784
  updateDaemonState: (newState) => {
774
785
  currentDaemonState = newState;
775
786
  daemonStateVersion++;
776
- notifySubscribers({
787
+ notifyListeners({
777
788
  type: "update-machine",
778
789
  machineId,
779
790
  daemonState: { value: currentDaemonState, version: daemonStateVersion }
780
791
  });
781
792
  },
782
793
  disconnect: async () => {
794
+ const toRemove = [...listeners];
795
+ for (const listener of toRemove) {
796
+ removeListener(listener, "disconnect");
797
+ }
783
798
  await server.unregisterService(serviceInfo.id);
784
799
  }
785
800
  };
@@ -797,17 +812,21 @@ function loadMessages(messagesDir, sessionId) {
797
812
  } catch {
798
813
  }
799
814
  }
800
- return messages.slice(-5e3);
815
+ return messages.slice(-1e3);
801
816
  } catch {
802
817
  return [];
803
818
  }
804
819
  }
805
820
  function appendMessage(messagesDir, sessionId, msg) {
806
- const filePath = join$1(messagesDir, "messages.jsonl");
807
- if (!existsSync(messagesDir)) {
808
- mkdirSync$1(messagesDir, { recursive: true });
821
+ try {
822
+ const filePath = join$1(messagesDir, "messages.jsonl");
823
+ if (!existsSync(messagesDir)) {
824
+ mkdirSync$1(messagesDir, { recursive: true });
825
+ }
826
+ appendFileSync(filePath, JSON.stringify(msg) + "\n");
827
+ } catch (err) {
828
+ console.error(`[HYPHA SESSION ${sessionId}] Failed to persist message: ${err?.message ?? err}`);
809
829
  }
810
- appendFileSync(filePath, JSON.stringify(msg) + "\n");
811
830
  }
812
831
  async function registerSessionService(server, sessionId, initialMetadata, initialAgentState, callbacks, options) {
813
832
  const messages = options?.messagesDir ? loadMessages(options.messagesDir) : [];
@@ -822,9 +841,36 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
822
841
  mode: "remote",
823
842
  time: Date.now()
824
843
  };
825
- const subscribers = /* @__PURE__ */ new Set();
826
- const notifySubscribers = (update) => {
827
- for (const push of subscribers) push(update);
844
+ const listeners = [];
845
+ const removeListener = (listener, reason) => {
846
+ const idx = listeners.indexOf(listener);
847
+ if (idx >= 0) {
848
+ listeners.splice(idx, 1);
849
+ console.log(`[HYPHA SESSION ${sessionId}] Listener removed (${reason}), remaining: ${listeners.length}`);
850
+ const rintfId = listener._rintf_service_id;
851
+ if (rintfId) {
852
+ server.unregisterService(rintfId).catch(() => {
853
+ });
854
+ }
855
+ }
856
+ };
857
+ const notifyListeners = (update) => {
858
+ const snapshot = [...listeners];
859
+ for (let i = snapshot.length - 1; i >= 0; i--) {
860
+ const listener = snapshot[i];
861
+ try {
862
+ const result = listener.onUpdate(update);
863
+ if (result && typeof result.catch === "function") {
864
+ result.catch((err) => {
865
+ console.error(`[HYPHA SESSION ${sessionId}] Async listener error:`, err);
866
+ removeListener(listener, "async error");
867
+ });
868
+ }
869
+ } catch (err) {
870
+ console.error(`[HYPHA SESSION ${sessionId}] Listener error:`, err);
871
+ removeListener(listener, "sync error");
872
+ }
873
+ }
828
874
  };
829
875
  const pushMessage = (content, role = "agent") => {
830
876
  let wrappedContent;
@@ -855,7 +901,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
855
901
  if (options?.messagesDir) {
856
902
  appendMessage(options.messagesDir, sessionId, msg);
857
903
  }
858
- notifySubscribers({
904
+ notifyListeners({
859
905
  type: "new-message",
860
906
  sessionId,
861
907
  message: msg
@@ -916,7 +962,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
916
962
  if (options?.messagesDir) {
917
963
  appendMessage(options.messagesDir, sessionId, msg);
918
964
  }
919
- notifySubscribers({
965
+ notifyListeners({
920
966
  type: "new-message",
921
967
  sessionId,
922
968
  message: msg
@@ -943,7 +989,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
943
989
  }
944
990
  metadata = newMetadata;
945
991
  metadataVersion++;
946
- notifySubscribers({
992
+ notifyListeners({
947
993
  type: "update-session",
948
994
  sessionId,
949
995
  metadata: { value: metadata, version: metadataVersion }
@@ -961,7 +1007,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
961
1007
  * Null values remove keys from the config.
962
1008
  */
963
1009
  updateConfig: async (patch, context) => {
964
- authorizeRequest(context, metadata.sharing, "interact");
1010
+ authorizeRequest(context, metadata.sharing, "admin");
965
1011
  callbacks.onUpdateConfig?.(patch);
966
1012
  return { success: true };
967
1013
  },
@@ -984,7 +1030,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
984
1030
  }
985
1031
  agentState = newState;
986
1032
  agentStateVersion++;
987
- notifySubscribers({
1033
+ notifyListeners({
988
1034
  type: "update-session",
989
1035
  sessionId,
990
1036
  agentState: { value: agentState, version: agentStateVersion }
@@ -1007,7 +1053,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1007
1053
  return { success: true };
1008
1054
  },
1009
1055
  switchMode: async (mode, context) => {
1010
- authorizeRequest(context, metadata.sharing, "interact");
1056
+ authorizeRequest(context, metadata.sharing, "admin");
1011
1057
  callbacks.onSwitchMode(mode);
1012
1058
  return { success: true };
1013
1059
  },
@@ -1022,16 +1068,18 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1022
1068
  },
1023
1069
  // ── Activity ──
1024
1070
  keepAlive: async (thinking, mode, context) => {
1071
+ authorizeRequest(context, metadata.sharing, "interact");
1025
1072
  lastActivity = { active: true, thinking: thinking || false, mode: mode || "remote", time: Date.now() };
1026
- notifySubscribers({
1073
+ notifyListeners({
1027
1074
  type: "activity",
1028
1075
  sessionId,
1029
1076
  ...lastActivity
1030
1077
  });
1031
1078
  },
1032
1079
  sessionEnd: async (context) => {
1080
+ authorizeRequest(context, metadata.sharing, "interact");
1033
1081
  lastActivity = { active: false, thinking: false, mode: "remote", time: Date.now() };
1034
- notifySubscribers({
1082
+ notifyListeners({
1035
1083
  type: "activity",
1036
1084
  sessionId,
1037
1085
  ...lastActivity
@@ -1092,6 +1140,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1092
1140
  },
1093
1141
  /** Returns the caller's effective role (null if no access). Does not throw. */
1094
1142
  getEffectiveRole: async (context) => {
1143
+ authorizeRequest(context, metadata.sharing, "view");
1095
1144
  const role = getEffectiveRole(context, metadata.sharing);
1096
1145
  return { role };
1097
1146
  },
@@ -1105,7 +1154,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1105
1154
  }
1106
1155
  metadata = { ...metadata, sharing: newSharing };
1107
1156
  metadataVersion++;
1108
- notifySubscribers({
1157
+ notifyListeners({
1109
1158
  type: "update-session",
1110
1159
  sessionId,
1111
1160
  metadata: { value: metadata, version: metadataVersion }
@@ -1123,7 +1172,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1123
1172
  }
1124
1173
  metadata = { ...metadata, securityContext: newSecurityContext };
1125
1174
  metadataVersion++;
1126
- notifySubscribers({
1175
+ notifyListeners({
1127
1176
  type: "update-session",
1128
1177
  sessionId,
1129
1178
  metadata: { value: metadata, version: metadataVersion }
@@ -1138,55 +1187,69 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1138
1187
  }
1139
1188
  return await callbacks.onApplySystemPrompt(prompt);
1140
1189
  },
1141
- // ── Live Update Stream ──
1142
- //
1143
- // Returns an async generator that yields real-time updates for this session.
1144
- // hypha-rpc proxies the generator across the RPC boundary — the frontend
1145
- // iterates with `for await (const update of service.subscribe())`.
1146
- //
1147
- // Initial state is replayed as the first batch of yields so the frontend
1148
- // can reconstruct full session state without a separate RPC call.
1149
- // Cleanup is automatic: when the frontend disconnects, hypha-rpc calls the
1150
- // generator's close method, triggering the finally block which removes the
1151
- // subscriber. No reverse `_rintf` service is registered.
1152
- subscribe: async function* (context) {
1190
+ // ── Listener Registration ──
1191
+ registerListener: async (callback, context) => {
1153
1192
  authorizeRequest(context, metadata.sharing, "view");
1154
- const pending = [];
1155
- let wake = null;
1156
- const push = (update) => {
1157
- pending.push(update);
1158
- wake?.();
1159
- };
1160
- subscribers.add(push);
1161
- console.log(`[HYPHA SESSION ${sessionId}] subscribe() started (total: ${subscribers.size})`);
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
+ }
1162
1227
  try {
1163
- yield {
1228
+ const result = callback.onUpdate({
1164
1229
  type: "update-session",
1165
1230
  sessionId,
1166
1231
  metadata: { value: metadata, version: metadataVersion },
1167
1232
  agentState: { value: agentState, version: agentStateVersion }
1168
- };
1169
- const MAX_REPLAY = 50;
1170
- for (const msg of messages.slice(-MAX_REPLAY)) {
1171
- yield { type: "new-message", sessionId, message: msg };
1233
+ });
1234
+ if (result && typeof result.catch === "function") {
1235
+ result.catch(() => {
1236
+ });
1172
1237
  }
1173
- yield { type: "activity", sessionId, ...lastActivity };
1174
- while (true) {
1175
- while (pending.length === 0) {
1176
- await new Promise((r) => {
1177
- wake = r;
1178
- });
1179
- wake = null;
1180
- }
1181
- while (pending.length > 0) {
1182
- yield pending.shift();
1183
- }
1238
+ } catch {
1239
+ }
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
+ });
1184
1249
  }
1185
- } finally {
1186
- subscribers.delete(push);
1187
- wake?.();
1188
- console.log(`[HYPHA SESSION ${sessionId}] subscribe() ended (remaining: ${subscribers.size})`);
1250
+ } catch {
1189
1251
  }
1252
+ return { success: true, listenerId: listeners.length - 1 };
1190
1253
  }
1191
1254
  },
1192
1255
  { overwrite: true }
@@ -1201,7 +1264,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1201
1264
  updateMetadata: (newMetadata) => {
1202
1265
  metadata = newMetadata;
1203
1266
  metadataVersion++;
1204
- notifySubscribers({
1267
+ notifyListeners({
1205
1268
  type: "update-session",
1206
1269
  sessionId,
1207
1270
  metadata: { value: metadata, version: metadataVersion }
@@ -1210,7 +1273,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1210
1273
  updateAgentState: (newAgentState) => {
1211
1274
  agentState = newAgentState;
1212
1275
  agentStateVersion++;
1213
- notifySubscribers({
1276
+ notifyListeners({
1214
1277
  type: "update-session",
1215
1278
  sessionId,
1216
1279
  agentState: { value: agentState, version: agentStateVersion }
@@ -1218,7 +1281,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1218
1281
  },
1219
1282
  sendKeepAlive: (thinking, mode) => {
1220
1283
  lastActivity = { active: true, thinking: thinking || false, mode: mode || "remote", time: Date.now() };
1221
- notifySubscribers({
1284
+ notifyListeners({
1222
1285
  type: "activity",
1223
1286
  sessionId,
1224
1287
  ...lastActivity
@@ -1226,7 +1289,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1226
1289
  },
1227
1290
  sendSessionEnd: () => {
1228
1291
  lastActivity = { active: false, thinking: false, mode: "remote", time: Date.now() };
1229
- notifySubscribers({
1292
+ notifyListeners({
1230
1293
  type: "activity",
1231
1294
  sessionId,
1232
1295
  ...lastActivity
@@ -1242,12 +1305,16 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1242
1305
  } catch {
1243
1306
  }
1244
1307
  }
1245
- notifySubscribers({
1308
+ notifyListeners({
1246
1309
  type: "clear-messages",
1247
1310
  sessionId
1248
1311
  });
1249
1312
  },
1250
1313
  disconnect: async () => {
1314
+ const toRemove = [...listeners];
1315
+ for (const listener of toRemove) {
1316
+ removeListener(listener, "disconnect");
1317
+ }
1251
1318
  await server.unregisterService(serviceInfo.id);
1252
1319
  }
1253
1320
  };
@@ -1382,6 +1449,19 @@ class SessionArtifactSync {
1382
1449
  this.log(`[ARTIFACT SYNC] Created new collection: ${this.collectionId}`);
1383
1450
  }
1384
1451
  }
1452
+ /**
1453
+ * fetch() with an AbortSignal-based timeout to prevent indefinite hangs
1454
+ * on slow/stalled presigned URL servers.
1455
+ */
1456
+ async fetchWithTimeout(url, options = {}, timeoutMs = 6e4) {
1457
+ const controller = new AbortController();
1458
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1459
+ try {
1460
+ return await fetch(url, { ...options, signal: controller.signal });
1461
+ } finally {
1462
+ clearTimeout(timer);
1463
+ }
1464
+ }
1385
1465
  /**
1386
1466
  * Upload a file to an artifact using the presigned URL pattern:
1387
1467
  * 1. put_file() returns a presigned upload URL
@@ -1396,11 +1476,11 @@ class SessionArtifactSync {
1396
1476
  if (!putUrl || typeof putUrl !== "string") {
1397
1477
  throw new Error(`put_file returned invalid URL for ${filePath}: ${putUrl}`);
1398
1478
  }
1399
- const resp = await fetch(putUrl, {
1479
+ const resp = await this.fetchWithTimeout(putUrl, {
1400
1480
  method: "PUT",
1401
1481
  body: content,
1402
1482
  headers: { "Content-Type": "application/octet-stream" }
1403
- });
1483
+ }, 12e4);
1404
1484
  if (!resp.ok) {
1405
1485
  throw new Error(`Upload failed for ${filePath}: ${resp.status} ${resp.statusText}`);
1406
1486
  }
@@ -1417,7 +1497,7 @@ class SessionArtifactSync {
1417
1497
  _rkwargs: true
1418
1498
  });
1419
1499
  if (!getUrl || typeof getUrl !== "string") return null;
1420
- const resp = await fetch(getUrl);
1500
+ const resp = await this.fetchWithTimeout(getUrl, {}, 6e4);
1421
1501
  if (!resp.ok) return null;
1422
1502
  return await resp.text();
1423
1503
  }
@@ -1435,16 +1515,27 @@ class SessionArtifactSync {
1435
1515
  const artifactAlias = `session-${sessionId}`;
1436
1516
  const sessionJsonPath = join$1(sessionsDir, "session.json");
1437
1517
  const messagesPath = join$1(sessionsDir, "messages.jsonl");
1438
- const sessionData = existsSync(sessionJsonPath) ? JSON.parse(readFileSync(sessionJsonPath, "utf-8")) : null;
1518
+ let sessionData = null;
1519
+ if (existsSync(sessionJsonPath)) {
1520
+ try {
1521
+ sessionData = JSON.parse(readFileSync(sessionJsonPath, "utf-8"));
1522
+ } catch {
1523
+ }
1524
+ }
1439
1525
  const messagesExist = existsSync(messagesPath);
1440
1526
  const messageCount = messagesExist ? readFileSync(messagesPath, "utf-8").split("\n").filter((l) => l.trim()).length : 0;
1441
1527
  let artifactId;
1528
+ let existingArtifactId = null;
1442
1529
  try {
1443
1530
  const existing = await this.artifactManager.read({
1444
1531
  artifact_id: artifactAlias,
1445
1532
  _rkwargs: true
1446
1533
  });
1447
- artifactId = existing.id;
1534
+ existingArtifactId = existing.id;
1535
+ } catch {
1536
+ }
1537
+ if (existingArtifactId) {
1538
+ artifactId = existingArtifactId;
1448
1539
  await this.artifactManager.edit({
1449
1540
  artifact_id: artifactId,
1450
1541
  manifest: {
@@ -1458,7 +1549,7 @@ class SessionArtifactSync {
1458
1549
  stage: true,
1459
1550
  _rkwargs: true
1460
1551
  });
1461
- } catch {
1552
+ } else {
1462
1553
  const artifact = await this.artifactManager.create({
1463
1554
  alias: artifactAlias,
1464
1555
  parent_id: this.collectionId,
@@ -1512,6 +1603,16 @@ class SessionArtifactSync {
1512
1603
  }, delayMs);
1513
1604
  this.syncTimers.set(sessionId, timer);
1514
1605
  }
1606
+ /**
1607
+ * Cancel any pending debounced sync for a session (e.g., when session is stopped).
1608
+ */
1609
+ cancelSync(sessionId) {
1610
+ const existing = this.syncTimers.get(sessionId);
1611
+ if (existing) {
1612
+ clearTimeout(existing);
1613
+ this.syncTimers.delete(sessionId);
1614
+ }
1615
+ }
1515
1616
  /**
1516
1617
  * Download a session from artifact store to local disk.
1517
1618
  */
@@ -1879,6 +1980,7 @@ function completeToolCall(toolCallId, toolKind, content, ctx) {
1879
1980
  const toolKindStr = typeof toolKind === "string" ? toolKind : "unknown";
1880
1981
  ctx.activeToolCalls.delete(toolCallId);
1881
1982
  ctx.toolCallStartTimes.delete(toolCallId);
1983
+ ctx.toolCallIdToNameMap.delete(toolCallId);
1882
1984
  const timeout = ctx.toolCallTimeouts.get(toolCallId);
1883
1985
  if (timeout) {
1884
1986
  clearTimeout(timeout);
@@ -1888,7 +1990,12 @@ function completeToolCall(toolCallId, toolKind, content, ctx) {
1888
1990
  ctx.emit({ type: "tool-result", toolName: toolKindStr, result: content, callId: toolCallId });
1889
1991
  if (ctx.activeToolCalls.size === 0) {
1890
1992
  ctx.clearIdleTimeout();
1891
- ctx.emitIdleStatus();
1993
+ const idleTimeoutMs = ctx.transport.getIdleTimeout?.() ?? DEFAULT_IDLE_TIMEOUT_MS;
1994
+ ctx.setIdleTimeout(() => {
1995
+ if (ctx.activeToolCalls.size === 0) {
1996
+ ctx.emitIdleStatus();
1997
+ }
1998
+ }, idleTimeoutMs);
1892
1999
  }
1893
2000
  }
1894
2001
  function failToolCall(toolCallId, status, toolKind, content, ctx) {
@@ -1897,6 +2004,7 @@ function failToolCall(toolCallId, status, toolKind, content, ctx) {
1897
2004
  const toolKindStr = typeof toolKind === "string" ? toolKind : "unknown";
1898
2005
  ctx.activeToolCalls.delete(toolCallId);
1899
2006
  ctx.toolCallStartTimes.delete(toolCallId);
2007
+ ctx.toolCallIdToNameMap.delete(toolCallId);
1900
2008
  const timeout = ctx.toolCallTimeouts.get(toolCallId);
1901
2009
  if (timeout) {
1902
2010
  clearTimeout(timeout);
@@ -1912,7 +2020,12 @@ function failToolCall(toolCallId, status, toolKind, content, ctx) {
1912
2020
  });
1913
2021
  if (ctx.activeToolCalls.size === 0) {
1914
2022
  ctx.clearIdleTimeout();
1915
- ctx.emitIdleStatus();
2023
+ const idleTimeoutMs = ctx.transport.getIdleTimeout?.() ?? DEFAULT_IDLE_TIMEOUT_MS;
2024
+ ctx.setIdleTimeout(() => {
2025
+ if (ctx.activeToolCalls.size === 0) {
2026
+ ctx.emitIdleStatus();
2027
+ }
2028
+ }, idleTimeoutMs);
1916
2029
  }
1917
2030
  }
1918
2031
  function handleToolCallUpdate(update, ctx) {
@@ -2161,10 +2274,22 @@ class AcpBackend {
2161
2274
  this.emit({ type: "status", status: "error", detail: err.message });
2162
2275
  });
2163
2276
  this.process.on("exit", (code, signal) => {
2164
- if (!this.disposed && code !== 0 && code !== null) {
2277
+ if (this.disposed) return;
2278
+ if (code !== 0 && code !== null) {
2165
2279
  signalStartupFailure(new Error(`Exit code: ${code}`));
2166
2280
  this.log(`[ACP] Process exited: code=${code}, signal=${signal}`);
2167
2281
  this.emit({ type: "status", status: "stopped", detail: `Exit code: ${code}` });
2282
+ } else if (code === null && this.waitingForResponse) {
2283
+ this.log(`[ACP] Process killed by signal: ${signal} (mid-turn)`);
2284
+ this.waitingForResponse = false;
2285
+ if (this.idleResolver) {
2286
+ this.idleResolver();
2287
+ this.idleResolver = null;
2288
+ }
2289
+ this.emit({ type: "status", status: "stopped", detail: `Process killed by signal: ${signal}` });
2290
+ } else if (code === 0 && this.waitingForResponse) {
2291
+ this.log(`[ACP] Process exited cleanly but response was pending \u2014 emitting idle`);
2292
+ this.emitIdleStatus();
2168
2293
  }
2169
2294
  });
2170
2295
  const streams = nodeToWebStreams(this.process.stdin, this.process.stdout);
@@ -2319,12 +2444,14 @@ class AcpBackend {
2319
2444
  const maybeErr = error;
2320
2445
  if (startupFailure && error === startupFailure) return true;
2321
2446
  if (maybeErr.code === "ENOENT" || maybeErr.code === "EACCES" || maybeErr.code === "EPIPE") return true;
2447
+ if (maybeErr.code === "DISPOSED") return true;
2322
2448
  const msg = error.message.toLowerCase();
2323
2449
  if (msg.includes("api key") || msg.includes("not configured") || msg.includes("401") || msg.includes("403")) return true;
2324
2450
  return false;
2325
2451
  };
2326
2452
  await withRetry(
2327
2453
  async () => {
2454
+ if (this.disposed) throw Object.assign(new Error("Backend disposed during startup retry"), { code: "DISPOSED" });
2328
2455
  let timeoutHandle = null;
2329
2456
  try {
2330
2457
  const result = await Promise.race([
@@ -2375,6 +2502,7 @@ class AcpBackend {
2375
2502
  this.log(`[ACP] Creating new session...`);
2376
2503
  const sessionResponse = await withRetry(
2377
2504
  async () => {
2505
+ if (this.disposed) throw Object.assign(new Error("Backend disposed during startup retry"), { code: "DISPOSED" });
2378
2506
  let timeoutHandle = null;
2379
2507
  try {
2380
2508
  const result = await Promise.race([
@@ -2524,9 +2652,16 @@ class AcpBackend {
2524
2652
  handleThinkingUpdate(update, ctx);
2525
2653
  }
2526
2654
  emitIdleStatus() {
2655
+ const resolver = this.idleResolver;
2656
+ this.idleResolver = null;
2657
+ this.waitingForResponse = false;
2527
2658
  this.emit({ type: "status", status: "idle" });
2528
- if (this.idleResolver) {
2529
- this.idleResolver();
2659
+ if (resolver) {
2660
+ const newPromptInFlight = this.waitingForResponse;
2661
+ resolver();
2662
+ if (newPromptInFlight) {
2663
+ this.waitingForResponse = true;
2664
+ }
2530
2665
  }
2531
2666
  }
2532
2667
  async sendPrompt(sessionId, prompt) {
@@ -2580,9 +2715,14 @@ class AcpBackend {
2580
2715
  }
2581
2716
  async cancel(sessionId) {
2582
2717
  if (!this.connection || !this.acpSessionId) return;
2718
+ this.waitingForResponse = false;
2719
+ if (this.idleResolver) {
2720
+ this.idleResolver();
2721
+ this.idleResolver = null;
2722
+ }
2583
2723
  try {
2584
2724
  await this.connection.cancel({ sessionId: this.acpSessionId });
2585
- this.emit({ type: "status", status: "stopped", detail: "Cancelled by user" });
2725
+ this.emit({ type: "status", status: "cancelled", detail: "Cancelled by user" });
2586
2726
  } catch (error) {
2587
2727
  this.log("[ACP] Error cancelling:", error);
2588
2728
  }
@@ -2605,16 +2745,24 @@ class AcpBackend {
2605
2745
  }
2606
2746
  }
2607
2747
  if (this.process) {
2608
- this.process.kill("SIGTERM");
2748
+ try {
2749
+ this.process.kill("SIGTERM");
2750
+ } catch {
2751
+ }
2609
2752
  await new Promise((resolve) => {
2610
2753
  const timeout = setTimeout(() => {
2611
- if (this.process) this.process.kill("SIGKILL");
2754
+ try {
2755
+ if (this.process) this.process.kill("SIGKILL");
2756
+ } catch {
2757
+ }
2612
2758
  resolve();
2613
2759
  }, 1e3);
2614
- this.process?.once("exit", () => {
2760
+ const done = () => {
2615
2761
  clearTimeout(timeout);
2616
2762
  resolve();
2617
- });
2763
+ };
2764
+ this.process?.once("exit", done);
2765
+ this.process?.once("close", done);
2618
2766
  });
2619
2767
  this.process = null;
2620
2768
  }
@@ -2629,6 +2777,7 @@ class AcpBackend {
2629
2777
  for (const timeout of this.toolCallTimeouts.values()) clearTimeout(timeout);
2630
2778
  this.toolCallTimeouts.clear();
2631
2779
  this.toolCallStartTimes.clear();
2780
+ this.toolCallIdToNameMap.clear();
2632
2781
  }
2633
2782
  }
2634
2783
 
@@ -2694,6 +2843,7 @@ function bridgeAcpToSession(backend, sessionService, getMetadata, setMetadata, l
2694
2843
  let pendingText = "";
2695
2844
  let turnText = "";
2696
2845
  let flushTimer = null;
2846
+ let bridgeStopped = false;
2697
2847
  function flushText() {
2698
2848
  if (pendingText) {
2699
2849
  sessionService.pushMessage({
@@ -2708,6 +2858,7 @@ function bridgeAcpToSession(backend, sessionService, getMetadata, setMetadata, l
2708
2858
  }
2709
2859
  }
2710
2860
  backend.onMessage((msg) => {
2861
+ if (bridgeStopped) return;
2711
2862
  switch (msg.type) {
2712
2863
  case "model-output": {
2713
2864
  if (msg.textDelta) {
@@ -2739,6 +2890,7 @@ function bridgeAcpToSession(backend, sessionService, getMetadata, setMetadata, l
2739
2890
  setMetadata((m) => ({ ...m, lifecycleState: "running" }));
2740
2891
  } else if (msg.status === "error") {
2741
2892
  flushText();
2893
+ turnText = "";
2742
2894
  sessionService.pushMessage(
2743
2895
  { type: "message", message: `Agent process exited unexpectedly: ${msg.detail || "Unknown error"}` },
2744
2896
  "event"
@@ -2747,8 +2899,12 @@ function bridgeAcpToSession(backend, sessionService, getMetadata, setMetadata, l
2747
2899
  setMetadata((m) => ({ ...m, lifecycleState: "error" }));
2748
2900
  } else if (msg.status === "stopped") {
2749
2901
  flushText();
2902
+ turnText = "";
2750
2903
  sessionService.sendSessionEnd();
2751
2904
  setMetadata((m) => ({ ...m, lifecycleState: "stopped" }));
2905
+ } else if (msg.status === "cancelled") {
2906
+ flushText();
2907
+ turnText = "";
2752
2908
  }
2753
2909
  break;
2754
2910
  }
@@ -2829,6 +2985,14 @@ function bridgeAcpToSession(backend, sessionService, getMetadata, setMetadata, l
2829
2985
  }
2830
2986
  }
2831
2987
  });
2988
+ return () => {
2989
+ bridgeStopped = true;
2990
+ if (flushTimer) {
2991
+ clearTimeout(flushTimer);
2992
+ flushTimer = null;
2993
+ }
2994
+ pendingText = "";
2995
+ };
2832
2996
  }
2833
2997
  class HyphaPermissionHandler {
2834
2998
  constructor(shouldAutoAllow, log) {
@@ -2894,6 +3058,10 @@ class CodexMcpBackend {
2894
3058
  client;
2895
3059
  transport = null;
2896
3060
  disposed = false;
3061
+ turnCancelled = false;
3062
+ // set by cancel() to suppress 'idle' in sendPrompt() finally
3063
+ turnId = 0;
3064
+ // monotonically increasing; each sendPrompt() captures its own ID
2897
3065
  codexSessionId = null;
2898
3066
  conversationId = null;
2899
3067
  svampSessionId = null;
@@ -2945,7 +3113,10 @@ class CodexMcpBackend {
2945
3113
  }
2946
3114
  async sendPrompt(sessionId, prompt) {
2947
3115
  if (!this.connected) throw new Error("Codex not connected");
3116
+ this.turnCancelled = false;
3117
+ const myTurnId = ++this.turnId;
2948
3118
  this.emit({ type: "status", status: "running" });
3119
+ let hadError = false;
2949
3120
  try {
2950
3121
  let response;
2951
3122
  if (this.codexSessionId) {
@@ -2966,16 +3137,20 @@ class CodexMcpBackend {
2966
3137
  }
2967
3138
  }
2968
3139
  } catch (err) {
3140
+ hadError = true;
2969
3141
  this.log(`[Codex] Error in sendPrompt: ${err.message}`);
2970
3142
  this.emit({ type: "status", status: "error", detail: err.message });
2971
3143
  throw err;
2972
3144
  } finally {
2973
- this.emit({ type: "status", status: "idle" });
3145
+ if (!this.turnCancelled && !hadError && this.turnId === myTurnId) {
3146
+ this.emit({ type: "status", status: "idle" });
3147
+ }
2974
3148
  }
2975
3149
  }
2976
3150
  async cancel(_sessionId) {
2977
3151
  this.log("[Codex] Cancel requested");
2978
- this.emit({ type: "status", status: "idle" });
3152
+ this.turnCancelled = true;
3153
+ this.emit({ type: "status", status: "cancelled" });
2979
3154
  }
2980
3155
  async respondToPermission(requestId, approved) {
2981
3156
  const pending = this.pendingApprovals.get(requestId);
@@ -3170,8 +3345,8 @@ class CodexMcpBackend {
3170
3345
  this.emit({ type: "status", status: "running" });
3171
3346
  break;
3172
3347
  case "task_complete":
3348
+ break;
3173
3349
  case "turn_aborted":
3174
- this.emit({ type: "status", status: "idle" });
3175
3350
  break;
3176
3351
  case "agent_message": {
3177
3352
  const content = event.content;
@@ -3803,7 +3978,10 @@ class ProcessSupervisor {
3803
3978
  /** Start a stopped/failed process by id or name. */
3804
3979
  async start(idOrName) {
3805
3980
  const entry = this.require(idOrName);
3806
- if (entry.child && !entry.stopping) throw new Error(`Process '${entry.spec.name}' is already running`);
3981
+ if (entry.child) {
3982
+ if (entry.stopping) throw new Error(`Process '${entry.spec.name}' is being stopped, try again shortly`);
3983
+ throw new Error(`Process '${entry.spec.name}' is already running`);
3984
+ }
3807
3985
  entry.stopping = false;
3808
3986
  await this.startEntry(entry, false);
3809
3987
  }
@@ -3823,15 +4001,21 @@ class ProcessSupervisor {
3823
4001
  /** Restart a process (stop if running, then start again). */
3824
4002
  async restart(idOrName) {
3825
4003
  const entry = this.require(idOrName);
3826
- if (entry.child) {
3827
- entry.stopping = true;
3828
- this.clearTimers(entry);
3829
- await this.killChild(entry.child);
3830
- entry.child = void 0;
4004
+ if (entry.restarting) return;
4005
+ entry.restarting = true;
4006
+ try {
4007
+ if (entry.child) {
4008
+ entry.stopping = true;
4009
+ this.clearTimers(entry);
4010
+ await this.killChild(entry.child);
4011
+ entry.child = void 0;
4012
+ }
4013
+ entry.stopping = false;
4014
+ entry.state.restartCount++;
4015
+ await this.startEntry(entry, false);
4016
+ } finally {
4017
+ entry.restarting = false;
3831
4018
  }
3832
- entry.stopping = false;
3833
- entry.state.restartCount++;
3834
- await this.startEntry(entry, false);
3835
4019
  }
3836
4020
  /** Stop the process and remove it from supervision (deletes persisted spec). */
3837
4021
  async remove(idOrName) {
@@ -3965,7 +4149,9 @@ class ProcessSupervisor {
3965
4149
  }
3966
4150
  async persistSpec(spec) {
3967
4151
  const filePath = path.join(this.persistDir, `${spec.id}.json`);
3968
- await writeFile(filePath, JSON.stringify(spec, null, 2), "utf-8");
4152
+ const tmpPath = filePath + ".tmp";
4153
+ await writeFile(tmpPath, JSON.stringify(spec, null, 2), "utf-8");
4154
+ await rename(tmpPath, filePath);
3969
4155
  }
3970
4156
  async deleteSpec(id) {
3971
4157
  try {
@@ -4039,7 +4225,13 @@ class ProcessSupervisor {
4039
4225
  };
4040
4226
  child.stdout?.on("data", appendLog);
4041
4227
  child.stderr?.on("data", appendLog);
4042
- child.on("exit", (code, signal) => this.onProcessExit(entry, code, signal));
4228
+ child.on("error", (err) => {
4229
+ console.error(`[SUPERVISOR] Process '${spec.name}' error: ${err.message}`);
4230
+ });
4231
+ child.on("close", (code, signal) => {
4232
+ if (entry.child !== child) return;
4233
+ this.onProcessExit(entry, code, signal);
4234
+ });
4043
4235
  if (spec.probe) this.setupProbe(entry);
4044
4236
  if (spec.ttl !== void 0) this.setupTTL(entry);
4045
4237
  console.log(`[SUPERVISOR] Started '${spec.name}' pid=${child.pid}`);
@@ -4127,6 +4319,8 @@ class ProcessSupervisor {
4127
4319
  }
4128
4320
  }
4129
4321
  async triggerProbeRestart(entry) {
4322
+ if (entry.restarting) return;
4323
+ if (entry.stopping) return;
4130
4324
  console.warn(`[SUPERVISOR] Restarting '${entry.spec.name}' due to probe failures`);
4131
4325
  entry.state.consecutiveProbeFailures = 0;
4132
4326
  this.clearTimers(entry);
@@ -4151,6 +4345,7 @@ class ProcessSupervisor {
4151
4345
  console.log(`[SUPERVISOR] Process '${entry.spec.name}' TTL expired`);
4152
4346
  entry.state.status = "expired";
4153
4347
  entry.stopping = true;
4348
+ this.clearTimers(entry);
4154
4349
  const cleanup = async () => {
4155
4350
  if (entry.child) await this.killChild(entry.child);
4156
4351
  this.entries.delete(entry.spec.id);
@@ -4162,13 +4357,36 @@ class ProcessSupervisor {
4162
4357
  // ── Process kill helper ───────────────────────────────────────────────────
4163
4358
  killChild(child) {
4164
4359
  return new Promise((resolve) => {
4165
- const done = () => resolve();
4360
+ let resolved = false;
4361
+ let forceKillTimer;
4362
+ let hardDeadlineTimer;
4363
+ const done = () => {
4364
+ if (!resolved) {
4365
+ resolved = true;
4366
+ if (forceKillTimer) clearTimeout(forceKillTimer);
4367
+ if (hardDeadlineTimer) clearTimeout(hardDeadlineTimer);
4368
+ resolve();
4369
+ }
4370
+ };
4166
4371
  child.once("exit", done);
4167
- child.kill("SIGTERM");
4168
- const forceKill = setTimeout(() => {
4169
- child.kill("SIGKILL");
4372
+ child.once("close", done);
4373
+ try {
4374
+ child.kill("SIGTERM");
4375
+ } catch {
4376
+ }
4377
+ forceKillTimer = setTimeout(() => {
4378
+ try {
4379
+ child.kill("SIGKILL");
4380
+ } catch {
4381
+ }
4382
+ hardDeadlineTimer = setTimeout(() => {
4383
+ if (!resolved) {
4384
+ resolved = true;
4385
+ console.warn(`[SUPERVISOR] Process pid=${child.pid} did not exit after SIGKILL \u2014 forcing resolution`);
4386
+ resolve();
4387
+ }
4388
+ }, 2e3);
4170
4389
  }, 5e3);
4171
- child.once("exit", () => clearTimeout(forceKill));
4172
4390
  });
4173
4391
  }
4174
4392
  // ── Timer cleanup ─────────────────────────────────────────────────────────
@@ -4266,7 +4484,9 @@ function readSvampConfig(configPath) {
4266
4484
  function writeSvampConfig(configPath, config) {
4267
4485
  mkdirSync(dirname(configPath), { recursive: true });
4268
4486
  const content = JSON.stringify(config, null, 2);
4269
- writeFileSync(configPath, content);
4487
+ const tmpPath = configPath + ".tmp";
4488
+ writeFileSync(tmpPath, content);
4489
+ renameSync(tmpPath, configPath);
4270
4490
  return content;
4271
4491
  }
4272
4492
  function getRalphStateFilePath(directory, sessionId) {
@@ -4320,7 +4540,9 @@ started_at: "${state.started_at}"${lastIterLine}${contextModeLine}${originalResu
4320
4540
 
4321
4541
  ${state.task}
4322
4542
  `;
4323
- writeFileSync(filePath, content);
4543
+ const tmpPath = `${filePath}.tmp`;
4544
+ writeFileSync(tmpPath, content);
4545
+ renameSync(tmpPath, filePath);
4324
4546
  }
4325
4547
  function removeRalphState(filePath) {
4326
4548
  try {
@@ -4409,20 +4631,17 @@ function createSvampConfigChecker(directory, sessionId, getMetadata, setMetadata
4409
4631
  ralphSystemPrompt: ralphSysPrompt
4410
4632
  }]
4411
4633
  }));
4412
- sessionService.updateMetadata(getMetadata());
4413
4634
  sessionService.pushMessage(
4414
- { type: "message", message: buildIterationStatus(1, state.max_iterations, state.completion_promise) },
4635
+ { type: "message", message: buildIterationStatus(state.iteration + 1, state.max_iterations, state.completion_promise) },
4415
4636
  "event"
4416
4637
  );
4417
- logger.log(`[svampConfig] Ralph loop started: "${state.task.slice(0, 50)}..."`);
4638
+ logger.log(`[svampConfig] Ralph loop started/resumed at iteration ${state.iteration + 1}: "${state.task.slice(0, 50)}..."`);
4418
4639
  onRalphLoopActivated?.();
4419
4640
  } else if (prevRalph.currentIteration !== ralphLoop.currentIteration || prevRalph.task !== ralphLoop.task) {
4420
4641
  setMetadata((m) => ({ ...m, ralphLoop }));
4421
- sessionService.updateMetadata(getMetadata());
4422
4642
  }
4423
4643
  } else if (prevRalph?.active) {
4424
4644
  setMetadata((m) => ({ ...m, ralphLoop: { active: false } }));
4425
- sessionService.updateMetadata(getMetadata());
4426
4645
  sessionService.pushMessage(
4427
4646
  { type: "message", message: `Ralph loop cancelled at iteration ${prevRalph.currentIteration}.` },
4428
4647
  "event"
@@ -4655,18 +4874,19 @@ function loadSessionIndex() {
4655
4874
  }
4656
4875
  }
4657
4876
  function saveSessionIndex(index) {
4658
- writeFileSync(SESSION_INDEX_FILE, JSON.stringify(index, null, 2), "utf-8");
4877
+ const tmp = SESSION_INDEX_FILE + ".tmp";
4878
+ writeFileSync(tmp, JSON.stringify(index, null, 2), "utf-8");
4879
+ renameSync(tmp, SESSION_INDEX_FILE);
4659
4880
  }
4660
4881
  function saveSession(session) {
4661
4882
  const sessionDir = getSessionDir(session.directory, session.sessionId);
4662
4883
  if (!existsSync$1(sessionDir)) {
4663
4884
  mkdirSync(sessionDir, { recursive: true });
4664
4885
  }
4665
- writeFileSync(
4666
- getSessionFilePath(session.directory, session.sessionId),
4667
- JSON.stringify(session, null, 2),
4668
- "utf-8"
4669
- );
4886
+ const filePath = getSessionFilePath(session.directory, session.sessionId);
4887
+ const tmpPath = filePath + ".tmp";
4888
+ writeFileSync(tmpPath, JSON.stringify(session, null, 2), "utf-8");
4889
+ renameSync(tmpPath, filePath);
4670
4890
  const index = loadSessionIndex();
4671
4891
  index[session.sessionId] = { directory: session.directory, createdAt: session.createdAt };
4672
4892
  saveSessionIndex(index);
@@ -4690,6 +4910,16 @@ function deletePersistedSession(sessionId) {
4690
4910
  if (existsSync$1(configFile)) unlinkSync(configFile);
4691
4911
  } catch {
4692
4912
  }
4913
+ const ralphStateFile = getRalphStateFilePath(entry.directory, sessionId);
4914
+ try {
4915
+ if (existsSync$1(ralphStateFile)) unlinkSync(ralphStateFile);
4916
+ } catch {
4917
+ }
4918
+ const ralphProgressFile = getRalphProgressFilePath(entry.directory, sessionId);
4919
+ try {
4920
+ if (existsSync$1(ralphProgressFile)) unlinkSync(ralphProgressFile);
4921
+ } catch {
4922
+ }
4693
4923
  const sessionDir = getSessionDir(entry.directory, sessionId);
4694
4924
  try {
4695
4925
  rmdirSync(sessionDir);
@@ -4755,7 +4985,9 @@ function createLogger() {
4755
4985
  }
4756
4986
  function writeDaemonStateFile(state) {
4757
4987
  ensureHomeDir();
4758
- writeFileSync(DAEMON_STATE_FILE, JSON.stringify(state, null, 2), "utf-8");
4988
+ const tmpPath = DAEMON_STATE_FILE + ".tmp";
4989
+ writeFileSync(tmpPath, JSON.stringify(state, null, 2), "utf-8");
4990
+ renameSync(tmpPath, DAEMON_STATE_FILE);
4759
4991
  }
4760
4992
  function readDaemonStateFile() {
4761
4993
  try {
@@ -4998,6 +5230,7 @@ async function startDaemon(options) {
4998
5230
  if (agentName !== "claude" && (KNOWN_ACP_AGENTS[agentName] || KNOWN_MCP_AGENTS[agentName])) {
4999
5231
  return await spawnAgentSession(sessionId, directory, agentName, options2, resumeSessionId);
5000
5232
  }
5233
+ let stagedCredentials = null;
5001
5234
  try {
5002
5235
  let parseBashPermission2 = function(permission) {
5003
5236
  if (permission === "Bash") return;
@@ -5031,17 +5264,23 @@ async function startDaemon(options) {
5031
5264
  resolve2();
5032
5265
  return;
5033
5266
  }
5267
+ let settled = false;
5268
+ const done = () => {
5269
+ if (settled) return;
5270
+ settled = true;
5271
+ clearTimeout(timeout);
5272
+ proc.off("exit", exitHandler);
5273
+ resolve2();
5274
+ };
5034
5275
  const timeout = setTimeout(() => {
5035
5276
  try {
5036
5277
  proc.kill("SIGKILL");
5037
5278
  } catch {
5038
5279
  }
5039
- resolve2();
5280
+ done();
5040
5281
  }, timeoutMs);
5041
- proc.on("exit", () => {
5042
- clearTimeout(timeout);
5043
- resolve2();
5044
- });
5282
+ const exitHandler = () => done();
5283
+ proc.on("exit", exitHandler);
5045
5284
  if (!proc.killed) {
5046
5285
  proc.kill(signal);
5047
5286
  }
@@ -5135,6 +5374,8 @@ async function startDaemon(options) {
5135
5374
  let userMessagePending = false;
5136
5375
  let turnInitiatedByUser = true;
5137
5376
  let isKillingClaude = false;
5377
+ let isRestartingClaude = false;
5378
+ let isSwitchingMode = false;
5138
5379
  let checkSvampConfig;
5139
5380
  let cleanupSvampConfig;
5140
5381
  const CLAUDE_PERMISSION_MODE_MAP = {
@@ -5142,7 +5383,6 @@ async function startDaemon(options) {
5142
5383
  };
5143
5384
  const toClaudePermissionMode = (mode) => CLAUDE_PERMISSION_MODE_MAP[mode] || mode;
5144
5385
  let isolationCleanupFiles = [];
5145
- let stagedCredentials = null;
5146
5386
  const spawnClaude = (initialMessage, meta) => {
5147
5387
  const effectiveMeta = { ...lastSpawnMeta, ...meta };
5148
5388
  let rawPermissionMode = effectiveMeta.permissionMode || agentConfig.default_permission_mode || currentPermissionMode;
@@ -5210,12 +5450,17 @@ async function startDaemon(options) {
5210
5450
  });
5211
5451
  claudeProcess = child;
5212
5452
  logger.log(`[Session ${sessionId}] Claude PID: ${child.pid}, stdin: ${!!child.stdin}, stdout: ${!!child.stdout}, stderr: ${!!child.stderr}`);
5453
+ child.stdin?.on("error", (err) => {
5454
+ logger.log(`[Session ${sessionId}] Claude stdin error: ${err.message}`);
5455
+ });
5213
5456
  child.on("error", (err) => {
5214
5457
  logger.log(`[Session ${sessionId}] Claude process error: ${err.message}`);
5215
5458
  sessionService.pushMessage(
5216
5459
  { type: "message", message: `Agent process exited unexpectedly: ${err.message}. Please ensure Claude Code CLI is installed.` },
5217
5460
  "event"
5218
5461
  );
5462
+ sessionWasProcessing = false;
5463
+ claudeProcess = null;
5219
5464
  signalProcessing(false);
5220
5465
  sessionService.sendSessionEnd();
5221
5466
  });
@@ -5327,7 +5572,7 @@ async function startDaemon(options) {
5327
5572
  }
5328
5573
  const textBlocks = assistantContent.filter((b) => b.type === "text").map((b) => b.text);
5329
5574
  if (textBlocks.length > 0) {
5330
- lastAssistantText = textBlocks.join("\n");
5575
+ lastAssistantText += textBlocks.join("\n");
5331
5576
  }
5332
5577
  }
5333
5578
  if (msg.type === "result") {
@@ -5365,9 +5610,12 @@ async function startDaemon(options) {
5365
5610
  turnInitiatedByUser = true;
5366
5611
  continue;
5367
5612
  }
5613
+ if (msg.session_id) {
5614
+ claudeResumeId = msg.session_id;
5615
+ }
5368
5616
  signalProcessing(false);
5369
5617
  sessionWasProcessing = false;
5370
- if (claudeResumeId) {
5618
+ if (claudeResumeId && !trackedSession.stopped) {
5371
5619
  saveSession({
5372
5620
  sessionId,
5373
5621
  directory,
@@ -5387,7 +5635,7 @@ async function startDaemon(options) {
5387
5635
  sessionService.pushMessage({ type: "session_event", message: taskInfo }, "session");
5388
5636
  }
5389
5637
  const queueLen = sessionMetadata.messageQueue?.length ?? 0;
5390
- if (queueLen > 0 && claudeResumeId) {
5638
+ if (queueLen > 0 && claudeResumeId && !trackedSession.stopped) {
5391
5639
  setTimeout(() => processMessageQueueRef?.(), 200);
5392
5640
  } else if (claudeResumeId) {
5393
5641
  const rlState = readRalphState(getRalphStateFilePath(directory, sessionId));
@@ -5411,6 +5659,7 @@ async function startDaemon(options) {
5411
5659
  logger.log(`[Session ${sessionId}] ${reason}`);
5412
5660
  sessionService.pushMessage({ type: "message", message: reason }, "event");
5413
5661
  if (isFreshMode && rlState.original_resume_id) {
5662
+ claudeResumeId = rlState.original_resume_id;
5414
5663
  (async () => {
5415
5664
  try {
5416
5665
  if (claudeProcess && claudeProcess.exitCode === null) {
@@ -5418,7 +5667,8 @@ async function startDaemon(options) {
5418
5667
  await killAndWaitForExit2(claudeProcess);
5419
5668
  isKillingClaude = false;
5420
5669
  }
5421
- claudeResumeId = rlState.original_resume_id;
5670
+ if (trackedSession.stopped) return;
5671
+ if (isRestartingClaude || isSwitchingMode) return;
5422
5672
  const progressPath = getRalphProgressFilePath(directory, sessionId);
5423
5673
  let resumeMessage;
5424
5674
  try {
@@ -5458,7 +5708,16 @@ The automated loop has finished. Review the progress above and let me know if yo
5458
5708
  if (isFreshMode && !rlState.original_resume_id && claudeResumeId) {
5459
5709
  updatedRlState.original_resume_id = claudeResumeId;
5460
5710
  }
5461
- writeRalphState(getRalphStateFilePath(directory, sessionId), updatedRlState);
5711
+ try {
5712
+ writeRalphState(getRalphStateFilePath(directory, sessionId), updatedRlState);
5713
+ } catch (writeErr) {
5714
+ logger.log(`[Session ${sessionId}] Ralph: failed to persist state (iter ${nextIteration}): ${writeErr.message} \u2014 stopping loop`);
5715
+ sessionService.pushMessage({ type: "message", message: `Ralph loop error: failed to persist iteration state \u2014 loop stopped. (${writeErr.message})` }, "event");
5716
+ removeRalphState(getRalphStateFilePath(directory, sessionId));
5717
+ sessionMetadata = { ...sessionMetadata, ralphLoop: { active: false }, lifecycleState: "idle" };
5718
+ sessionService.updateMetadata(sessionMetadata);
5719
+ break;
5720
+ }
5462
5721
  const ralphLoop = {
5463
5722
  active: true,
5464
5723
  task: rlState.task,
@@ -5478,13 +5737,15 @@ The automated loop has finished. Review the progress above and let me know if yo
5478
5737
  const ralphSysPrompt = buildRalphSystemPrompt(updatedRlState, progressRelPath);
5479
5738
  const cooldownMs = Math.max(200, rlState.cooldown_seconds * 1e3);
5480
5739
  if (isFreshMode) {
5740
+ isKillingClaude = true;
5481
5741
  setTimeout(async () => {
5482
5742
  try {
5483
5743
  if (claudeProcess && claudeProcess.exitCode === null) {
5484
- isKillingClaude = true;
5485
5744
  await killAndWaitForExit2(claudeProcess);
5486
- isKillingClaude = false;
5487
5745
  }
5746
+ isKillingClaude = false;
5747
+ if (trackedSession.stopped) return;
5748
+ if (isRestartingClaude || isSwitchingMode) return;
5488
5749
  claudeResumeId = void 0;
5489
5750
  userMessagePending = true;
5490
5751
  turnInitiatedByUser = true;
@@ -5496,25 +5757,34 @@ The automated loop has finished. Review the progress above and let me know if yo
5496
5757
  } catch (err) {
5497
5758
  logger.log(`[Session ${sessionId}] Error in fresh Ralph iteration: ${err.message}`);
5498
5759
  isKillingClaude = false;
5760
+ sessionWasProcessing = false;
5499
5761
  signalProcessing(false);
5500
5762
  }
5501
5763
  }, cooldownMs);
5502
5764
  } else {
5503
5765
  setTimeout(() => {
5504
- userMessagePending = true;
5505
- turnInitiatedByUser = true;
5506
- sessionWasProcessing = true;
5507
- signalProcessing(true);
5508
- sessionService.pushMessage({ type: "message", message: buildIterationStatus(nextIteration, rlState.max_iterations, rlState.completion_promise) }, "event");
5509
- sessionService.pushMessage(rlState.task, "user");
5510
- if (claudeProcess && claudeProcess.exitCode === null) {
5511
- const stdinMsg = JSON.stringify({
5512
- type: "user",
5513
- message: { role: "user", content: prompt }
5514
- });
5515
- claudeProcess.stdin?.write(stdinMsg + "\n");
5516
- } else {
5517
- spawnClaude(prompt, { appendSystemPrompt: ralphSysPrompt });
5766
+ if (trackedSession.stopped) return;
5767
+ if (isRestartingClaude || isSwitchingMode) return;
5768
+ try {
5769
+ userMessagePending = true;
5770
+ turnInitiatedByUser = true;
5771
+ sessionWasProcessing = true;
5772
+ signalProcessing(true);
5773
+ sessionService.pushMessage({ type: "message", message: buildIterationStatus(nextIteration, rlState.max_iterations, rlState.completion_promise) }, "event");
5774
+ sessionService.pushMessage(rlState.task, "user");
5775
+ if (claudeProcess && claudeProcess.exitCode === null) {
5776
+ const stdinMsg = JSON.stringify({
5777
+ type: "user",
5778
+ message: { role: "user", content: prompt }
5779
+ });
5780
+ claudeProcess.stdin?.write(stdinMsg + "\n");
5781
+ } else {
5782
+ spawnClaude(prompt, { appendSystemPrompt: ralphSysPrompt });
5783
+ }
5784
+ } catch (err) {
5785
+ logger.log(`[Session ${sessionId}] Error in continue Ralph iteration: ${err.message}`);
5786
+ sessionWasProcessing = false;
5787
+ signalProcessing(false);
5518
5788
  }
5519
5789
  }, cooldownMs);
5520
5790
  }
@@ -5529,10 +5799,8 @@ The automated loop has finished. Review the progress above and let me know if yo
5529
5799
  }
5530
5800
  }
5531
5801
  sessionService.pushMessage(msg, "agent");
5532
- if (msg.session_id) {
5533
- claudeResumeId = msg.session_id;
5534
- }
5535
5802
  } else if (msg.type === "system" && msg.subtype === "init") {
5803
+ lastAssistantText = "";
5536
5804
  if (!userMessagePending) {
5537
5805
  turnInitiatedByUser = false;
5538
5806
  logger.log(`[Session ${sessionId}] SDK-initiated turn (likely stale task_notification)`);
@@ -5543,18 +5811,20 @@ The automated loop has finished. Review the progress above and let me know if yo
5543
5811
  claudeResumeId = msg.session_id;
5544
5812
  sessionMetadata = { ...sessionMetadata, claudeSessionId: msg.session_id };
5545
5813
  sessionService.updateMetadata(sessionMetadata);
5546
- saveSession({
5547
- sessionId,
5548
- directory,
5549
- claudeResumeId,
5550
- permissionMode: currentPermissionMode,
5551
- spawnMeta: lastSpawnMeta,
5552
- metadata: sessionMetadata,
5553
- createdAt: Date.now(),
5554
- machineId,
5555
- wasProcessing: sessionWasProcessing
5556
- });
5557
- artifactSync.scheduleDebouncedSync(sessionId, getSessionDir(directory, sessionId), sessionMetadata, machineId);
5814
+ if (!trackedSession.stopped) {
5815
+ saveSession({
5816
+ sessionId,
5817
+ directory,
5818
+ claudeResumeId,
5819
+ permissionMode: currentPermissionMode,
5820
+ spawnMeta: lastSpawnMeta,
5821
+ metadata: sessionMetadata,
5822
+ createdAt: Date.now(),
5823
+ machineId,
5824
+ wasProcessing: sessionWasProcessing
5825
+ });
5826
+ artifactSync.scheduleDebouncedSync(sessionId, getSessionDir(directory, sessionId), sessionMetadata, machineId);
5827
+ }
5558
5828
  if (isConversationClear) {
5559
5829
  logger.log(`[Session ${sessionId}] Conversation cleared (/clear) \u2014 new Claude session: ${msg.session_id}`);
5560
5830
  sessionService.clearMessages();
@@ -5580,6 +5850,19 @@ The automated loop has finished. Review the progress above and let me know if yo
5580
5850
  }
5581
5851
  }
5582
5852
  });
5853
+ child.stdout?.on("close", () => {
5854
+ const remaining = stdoutBuffer.trim();
5855
+ if (remaining) {
5856
+ logger.log(`[Session ${sessionId}] stdout close with remaining buffer (${remaining.length} chars): ${remaining.slice(0, 200)}`);
5857
+ try {
5858
+ const msg = JSON.parse(remaining);
5859
+ sessionService.pushMessage(msg, "agent");
5860
+ } catch {
5861
+ logger.log(`[Session ${sessionId}] Discarding non-JSON stdout remainder on close`);
5862
+ }
5863
+ stdoutBuffer = "";
5864
+ }
5865
+ });
5583
5866
  let stderrBuffer = "";
5584
5867
  child.stderr?.on("data", (chunk) => {
5585
5868
  const text = chunk.toString();
@@ -5609,7 +5892,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5609
5892
  sessionService.updateMetadata(sessionMetadata);
5610
5893
  sessionWasProcessing = false;
5611
5894
  const queueLen = sessionMetadata.messageQueue?.length ?? 0;
5612
- if (queueLen > 0 && claudeResumeId) {
5895
+ if (queueLen > 0 && claudeResumeId && !trackedSession.stopped) {
5613
5896
  signalProcessing(false);
5614
5897
  setTimeout(() => processMessageQueueRef?.(), 200);
5615
5898
  } else {
@@ -5643,6 +5926,10 @@ The automated loop has finished. Review the progress above and let me know if yo
5643
5926
  };
5644
5927
  const restartClaudeHandler = async () => {
5645
5928
  logger.log(`[Session ${sessionId}] Restart Claude requested`);
5929
+ if (isRestartingClaude || isSwitchingMode) {
5930
+ return { success: false, message: "Restart already in progress." };
5931
+ }
5932
+ isRestartingClaude = true;
5646
5933
  try {
5647
5934
  if (claudeProcess && claudeProcess.exitCode === null) {
5648
5935
  isKillingClaude = true;
@@ -5651,6 +5938,9 @@ The automated loop has finished. Review the progress above and let me know if yo
5651
5938
  await killAndWaitForExit2(claudeProcess);
5652
5939
  isKillingClaude = false;
5653
5940
  }
5941
+ if (trackedSession?.stopped) {
5942
+ return { success: false, message: "Session was stopped during restart." };
5943
+ }
5654
5944
  if (claudeResumeId) {
5655
5945
  spawnClaude(void 0, { permissionMode: currentPermissionMode });
5656
5946
  logger.log(`[Session ${sessionId}] Claude respawned with --resume ${claudeResumeId}`);
@@ -5663,6 +5953,8 @@ The automated loop has finished. Review the progress above and let me know if yo
5663
5953
  isKillingClaude = false;
5664
5954
  logger.log(`[Session ${sessionId}] Restart failed: ${err.message}`);
5665
5955
  return { success: false, message: `Restart failed: ${err.message}` };
5956
+ } finally {
5957
+ isRestartingClaude = false;
5666
5958
  }
5667
5959
  };
5668
5960
  if (sessionMetadata.sharing?.enabled && isolationCapabilities.preferred) {
@@ -5681,6 +5973,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5681
5973
  { controlledByUser: false },
5682
5974
  {
5683
5975
  onUserMessage: (content, meta) => {
5976
+ if (trackedSession?.stopped) return;
5684
5977
  logger.log(`[Session ${sessionId}] User message received`);
5685
5978
  userMessagePending = true;
5686
5979
  turnInitiatedByUser = true;
@@ -5707,7 +6000,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5707
6000
  if (msgMeta) {
5708
6001
  lastSpawnMeta = { ...lastSpawnMeta, ...msgMeta };
5709
6002
  }
5710
- if (isKillingClaude) {
6003
+ if (isKillingClaude || isRestartingClaude || isSwitchingMode) {
5711
6004
  logger.log(`[Session ${sessionId}] Message received while restarting Claude, queuing to prevent loss`);
5712
6005
  const existingQueue = sessionMetadata.messageQueue || [];
5713
6006
  sessionMetadata = {
@@ -5761,6 +6054,19 @@ The automated loop has finished. Review the progress above and let me know if yo
5761
6054
  if (params.mode) {
5762
6055
  currentPermissionMode = toClaudePermissionMode(params.mode);
5763
6056
  logger.log(`[Session ${sessionId}] Permission mode changed to: ${currentPermissionMode}`);
6057
+ if (claudeResumeId && !trackedSession.stopped) {
6058
+ saveSession({
6059
+ sessionId,
6060
+ directory,
6061
+ claudeResumeId,
6062
+ permissionMode: currentPermissionMode,
6063
+ spawnMeta: lastSpawnMeta,
6064
+ metadata: sessionMetadata,
6065
+ createdAt: Date.now(),
6066
+ machineId,
6067
+ wasProcessing: sessionWasProcessing
6068
+ });
6069
+ }
5764
6070
  }
5765
6071
  if (params.allowTools && Array.isArray(params.allowTools)) {
5766
6072
  for (const tool of params.allowTools) {
@@ -5791,12 +6097,23 @@ The automated loop has finished. Review the progress above and let me know if yo
5791
6097
  },
5792
6098
  onSwitchMode: async (mode) => {
5793
6099
  logger.log(`[Session ${sessionId}] Switch mode: ${mode}`);
6100
+ if (isRestartingClaude || isSwitchingMode) {
6101
+ logger.log(`[Session ${sessionId}] Switch mode deferred \u2014 restart/switch already in progress`);
6102
+ return;
6103
+ }
5794
6104
  currentPermissionMode = mode;
5795
6105
  if (claudeProcess && claudeProcess.exitCode === null) {
6106
+ isSwitchingMode = true;
5796
6107
  isKillingClaude = true;
5797
- await killAndWaitForExit2(claudeProcess);
5798
- isKillingClaude = false;
5799
- spawnClaude(void 0, { permissionMode: mode });
6108
+ try {
6109
+ await killAndWaitForExit2(claudeProcess);
6110
+ isKillingClaude = false;
6111
+ if (trackedSession?.stopped) return;
6112
+ spawnClaude(void 0, { permissionMode: mode });
6113
+ } finally {
6114
+ isKillingClaude = false;
6115
+ isSwitchingMode = false;
6116
+ }
5800
6117
  }
5801
6118
  },
5802
6119
  onRestartClaude: restartClaudeHandler,
@@ -5817,11 +6134,15 @@ The automated loop has finished. Review the progress above and let me know if yo
5817
6134
  onMetadataUpdate: (newMeta) => {
5818
6135
  sessionMetadata = {
5819
6136
  ...newMeta,
6137
+ // Daemon drives lifecycleState — don't let frontend overwrite with stale value
6138
+ lifecycleState: sessionMetadata.lifecycleState,
6139
+ // Preserve claudeSessionId set by 'system init' (frontend may not have it)
6140
+ ...sessionMetadata.claudeSessionId ? { claudeSessionId: sessionMetadata.claudeSessionId } : {},
5820
6141
  ...sessionMetadata.summary && !newMeta.summary ? { summary: sessionMetadata.summary } : {},
5821
6142
  ...sessionMetadata.sessionLink && !newMeta.sessionLink ? { sessionLink: sessionMetadata.sessionLink } : {}
5822
6143
  };
5823
6144
  const queue = newMeta.messageQueue;
5824
- if (queue && queue.length > 0 && !sessionWasProcessing) {
6145
+ if (queue && queue.length > 0 && !sessionWasProcessing && !trackedSession.stopped) {
5825
6146
  setTimeout(() => {
5826
6147
  processMessageQueueRef?.();
5827
6148
  }, 200);
@@ -5858,7 +6179,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5858
6179
  },
5859
6180
  onReadFile: async (path) => {
5860
6181
  const resolvedPath = resolve(directory, path);
5861
- if (!resolvedPath.startsWith(resolve(directory))) {
6182
+ if (resolvedPath !== resolve(directory) && !resolvedPath.startsWith(resolve(directory) + "/")) {
5862
6183
  throw new Error("Path outside working directory");
5863
6184
  }
5864
6185
  const buffer = await fs.readFile(resolvedPath);
@@ -5866,7 +6187,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5866
6187
  },
5867
6188
  onWriteFile: async (path, content) => {
5868
6189
  const resolvedPath = resolve(directory, path);
5869
- if (!resolvedPath.startsWith(resolve(directory))) {
6190
+ if (resolvedPath !== resolve(directory) && !resolvedPath.startsWith(resolve(directory) + "/")) {
5870
6191
  throw new Error("Path outside working directory");
5871
6192
  }
5872
6193
  await fs.mkdir(dirname(resolvedPath), { recursive: true });
@@ -5874,7 +6195,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5874
6195
  },
5875
6196
  onListDirectory: async (path) => {
5876
6197
  const resolvedDir = resolve(directory, path || ".");
5877
- if (!resolvedDir.startsWith(resolve(directory))) {
6198
+ if (resolvedDir !== resolve(directory) && !resolvedDir.startsWith(resolve(directory) + "/")) {
5878
6199
  throw new Error("Path outside working directory");
5879
6200
  }
5880
6201
  const entries = await fs.readdir(resolvedDir, { withFileTypes: true });
@@ -5913,6 +6234,9 @@ The automated loop has finished. Review the progress above and let me know if yo
5913
6234
  }
5914
6235
  }
5915
6236
  const resolvedPath = resolve(directory, treePath);
6237
+ if (resolvedPath !== resolve(directory) && !resolvedPath.startsWith(resolve(directory) + "/")) {
6238
+ throw new Error("Path outside working directory");
6239
+ }
5916
6240
  const tree = await buildTree(resolvedPath, basename(resolvedPath), 0);
5917
6241
  return { success: !!tree, tree };
5918
6242
  }
@@ -5929,13 +6253,18 @@ The automated loop has finished. Review the progress above and let me know if yo
5929
6253
  },
5930
6254
  sessionService,
5931
6255
  logger,
5932
- () => setTimeout(() => processMessageQueueRef?.(), 200)
6256
+ () => {
6257
+ if (!trackedSession?.stopped) setTimeout(() => processMessageQueueRef?.(), 200);
6258
+ }
5933
6259
  );
5934
6260
  checkSvampConfig = svampConfig.check;
5935
6261
  cleanupSvampConfig = svampConfig.cleanup;
5936
6262
  const writeSvampConfigPatch = svampConfig.writeConfig;
5937
6263
  processMessageQueueRef = () => {
5938
6264
  if (sessionWasProcessing) return;
6265
+ if (trackedSession?.stopped) return;
6266
+ if (isKillingClaude) return;
6267
+ if (isRestartingClaude || isSwitchingMode) return;
5939
6268
  const queue = sessionMetadata.messageQueue;
5940
6269
  if (queue && queue.length > 0) {
5941
6270
  const next = queue[0];
@@ -5964,22 +6293,33 @@ The automated loop has finished. Review the progress above and let me know if yo
5964
6293
  isKillingClaude = true;
5965
6294
  await killAndWaitForExit2(claudeProcess);
5966
6295
  isKillingClaude = false;
6296
+ if (trackedSession?.stopped) return;
6297
+ if (isRestartingClaude || isSwitchingMode) return;
5967
6298
  claudeResumeId = void 0;
5968
6299
  spawnClaude(next.text, queueMeta);
5969
6300
  } catch (err) {
5970
6301
  logger.log(`[Session ${sessionId}] Error in fresh Ralph queue processing: ${err.message}`);
5971
6302
  isKillingClaude = false;
6303
+ sessionWasProcessing = false;
5972
6304
  signalProcessing(false);
5973
6305
  }
5974
6306
  })();
5975
- } else if (!claudeProcess || claudeProcess.exitCode !== null) {
5976
- spawnClaude(next.text, queueMeta);
5977
6307
  } else {
5978
- const stdinMsg = JSON.stringify({
5979
- type: "user",
5980
- message: { role: "user", content: next.text }
5981
- });
5982
- claudeProcess.stdin?.write(stdinMsg + "\n");
6308
+ try {
6309
+ if (!claudeProcess || claudeProcess.exitCode !== null) {
6310
+ spawnClaude(next.text, queueMeta);
6311
+ } else {
6312
+ const stdinMsg = JSON.stringify({
6313
+ type: "user",
6314
+ message: { role: "user", content: next.text }
6315
+ });
6316
+ claudeProcess.stdin?.write(stdinMsg + "\n");
6317
+ }
6318
+ } catch (err) {
6319
+ logger.log(`[Session ${sessionId}] Error in processMessageQueue spawn: ${err.message}`);
6320
+ sessionWasProcessing = false;
6321
+ signalProcessing(false);
6322
+ }
5983
6323
  }
5984
6324
  }
5985
6325
  };
@@ -5998,7 +6338,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5998
6338
  return claudeProcess || void 0;
5999
6339
  }
6000
6340
  };
6001
- pidToTrackedSession.set(process.pid + Math.floor(Math.random() * 1e5), trackedSession);
6341
+ pidToTrackedSession.set(randomUUID$1(), trackedSession);
6002
6342
  sessionMetadata = { ...sessionMetadata, lifecycleState: "idle" };
6003
6343
  sessionService.updateMetadata(sessionMetadata);
6004
6344
  logger.log(`Session ${sessionId} registered on Hypha, waiting for first message to spawn Claude`);
@@ -6009,6 +6349,10 @@ The automated loop has finished. Review the progress above and let me know if yo
6009
6349
  };
6010
6350
  } catch (err) {
6011
6351
  logger.error(`Failed to spawn session: ${err?.message || err}`, err?.stack);
6352
+ if (stagedCredentials) {
6353
+ stagedCredentials.cleanup().catch(() => {
6354
+ });
6355
+ }
6012
6356
  return {
6013
6357
  type: "error",
6014
6358
  errorMessage: `Failed to register session service: ${err?.message || String(err)}`
@@ -6076,6 +6420,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6076
6420
  { controlledByUser: false },
6077
6421
  {
6078
6422
  onUserMessage: (content, meta) => {
6423
+ if (acpStopped) return;
6079
6424
  logger.log(`[${agentName} Session ${sessionId}] User message received`);
6080
6425
  let text;
6081
6426
  let msgMeta = meta;
@@ -6096,8 +6441,35 @@ The automated loop has finished. Review the progress above and let me know if yo
6096
6441
  if (msgMeta?.permissionMode) {
6097
6442
  currentPermissionMode = msgMeta.permissionMode;
6098
6443
  }
6444
+ if (!acpBackendReady) {
6445
+ logger.log(`[${agentName} Session ${sessionId}] Backend not ready \u2014 queuing message`);
6446
+ const existingQueue = sessionMetadata.messageQueue || [];
6447
+ sessionMetadata = {
6448
+ ...sessionMetadata,
6449
+ messageQueue: [...existingQueue, { id: randomUUID$1(), text, createdAt: Date.now() }]
6450
+ };
6451
+ sessionService.updateMetadata(sessionMetadata);
6452
+ return;
6453
+ }
6454
+ if (sessionMetadata.lifecycleState === "running") {
6455
+ logger.log(`[${agentName} Session ${sessionId}] Agent busy \u2014 queuing message`);
6456
+ const existingQueue = sessionMetadata.messageQueue || [];
6457
+ sessionMetadata = {
6458
+ ...sessionMetadata,
6459
+ messageQueue: [...existingQueue, { id: randomUUID$1(), text, createdAt: Date.now() }]
6460
+ };
6461
+ sessionService.updateMetadata(sessionMetadata);
6462
+ return;
6463
+ }
6464
+ sessionMetadata = { ...sessionMetadata, lifecycleState: "running" };
6465
+ sessionService.updateMetadata(sessionMetadata);
6099
6466
  agentBackend.sendPrompt(sessionId, text).catch((err) => {
6100
6467
  logger.error(`[${agentName} Session ${sessionId}] Error sending prompt:`, err);
6468
+ if (!acpStopped) {
6469
+ sessionMetadata = { ...sessionMetadata, lifecycleState: "idle" };
6470
+ sessionService.updateMetadata(sessionMetadata);
6471
+ sessionService.sendSessionEnd();
6472
+ }
6101
6473
  });
6102
6474
  },
6103
6475
  onAbort: () => {
@@ -6151,18 +6523,27 @@ The automated loop has finished. Review the progress above and let me know if yo
6151
6523
  onMetadataUpdate: (newMeta) => {
6152
6524
  sessionMetadata = {
6153
6525
  ...newMeta,
6526
+ // Daemon drives lifecycleState — don't let frontend overwrite with stale value
6527
+ lifecycleState: sessionMetadata.lifecycleState,
6154
6528
  ...sessionMetadata.summary && !newMeta.summary ? { summary: sessionMetadata.summary } : {},
6155
6529
  ...sessionMetadata.sessionLink && !newMeta.sessionLink ? { sessionLink: sessionMetadata.sessionLink } : {}
6156
6530
  };
6531
+ if (acpStopped) return;
6157
6532
  const queue = newMeta.messageQueue;
6158
6533
  if (queue && queue.length > 0 && sessionMetadata.lifecycleState === "idle") {
6159
6534
  const next = queue[0];
6160
6535
  const remaining = queue.slice(1);
6161
- sessionMetadata = { ...sessionMetadata, messageQueue: remaining.length > 0 ? remaining : void 0 };
6536
+ sessionMetadata = { ...sessionMetadata, messageQueue: remaining.length > 0 ? remaining : void 0, lifecycleState: "running" };
6162
6537
  sessionService.updateMetadata(sessionMetadata);
6163
6538
  logger.log(`[Session ${sessionId}] Processing queued message from metadata update: "${next.text.slice(0, 50)}..."`);
6539
+ sessionService.sendKeepAlive(true);
6164
6540
  agentBackend.sendPrompt(sessionId, next.text).catch((err) => {
6165
6541
  logger.error(`[Session ${sessionId}] Error processing queued message: ${err.message}`);
6542
+ if (!acpStopped) {
6543
+ sessionMetadata = { ...sessionMetadata, lifecycleState: "idle" };
6544
+ sessionService.updateMetadata(sessionMetadata);
6545
+ sessionService.sendSessionEnd();
6546
+ }
6166
6547
  });
6167
6548
  }
6168
6549
  },
@@ -6196,7 +6577,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6196
6577
  },
6197
6578
  onReadFile: async (path) => {
6198
6579
  const resolvedPath = resolve(directory, path);
6199
- if (!resolvedPath.startsWith(resolve(directory))) {
6580
+ if (resolvedPath !== resolve(directory) && !resolvedPath.startsWith(resolve(directory) + "/")) {
6200
6581
  throw new Error("Path outside working directory");
6201
6582
  }
6202
6583
  const buffer = await fs.readFile(resolvedPath);
@@ -6204,7 +6585,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6204
6585
  },
6205
6586
  onWriteFile: async (path, content) => {
6206
6587
  const resolvedPath = resolve(directory, path);
6207
- if (!resolvedPath.startsWith(resolve(directory))) {
6588
+ if (resolvedPath !== resolve(directory) && !resolvedPath.startsWith(resolve(directory) + "/")) {
6208
6589
  throw new Error("Path outside working directory");
6209
6590
  }
6210
6591
  await fs.mkdir(dirname(resolvedPath), { recursive: true });
@@ -6212,7 +6593,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6212
6593
  },
6213
6594
  onListDirectory: async (path) => {
6214
6595
  const resolvedDir = resolve(directory, path || ".");
6215
- if (!resolvedDir.startsWith(resolve(directory))) {
6596
+ if (resolvedDir !== resolve(directory) && !resolvedDir.startsWith(resolve(directory) + "/")) {
6216
6597
  throw new Error("Path outside working directory");
6217
6598
  }
6218
6599
  const entries = await fs.readdir(resolvedDir, { withFileTypes: true });
@@ -6251,12 +6632,16 @@ The automated loop has finished. Review the progress above and let me know if yo
6251
6632
  }
6252
6633
  }
6253
6634
  const resolvedPath = resolve(directory, treePath);
6635
+ if (resolvedPath !== resolve(directory) && !resolvedPath.startsWith(resolve(directory) + "/")) {
6636
+ throw new Error("Path outside working directory");
6637
+ }
6254
6638
  const tree = await buildTree(resolvedPath, basename(resolvedPath), 0);
6255
6639
  return { success: !!tree, tree };
6256
6640
  }
6257
6641
  },
6258
6642
  { messagesDir: getSessionDir(directory, sessionId) }
6259
6643
  );
6644
+ let insideOnTurnEnd = false;
6260
6645
  const svampConfigChecker = createSvampConfigChecker(
6261
6646
  directory,
6262
6647
  sessionId,
@@ -6268,6 +6653,8 @@ The automated loop has finished. Review the progress above and let me know if yo
6268
6653
  sessionService,
6269
6654
  logger,
6270
6655
  () => {
6656
+ if (acpStopped) return;
6657
+ if (insideOnTurnEnd) return;
6271
6658
  const queue = sessionMetadata.messageQueue;
6272
6659
  if (queue && queue.length > 0 && sessionMetadata.lifecycleState === "idle") {
6273
6660
  const next = queue[0];
@@ -6278,6 +6665,11 @@ The automated loop has finished. Review the progress above and let me know if yo
6278
6665
  sessionService.sendKeepAlive(true);
6279
6666
  agentBackend.sendPrompt(sessionId, next.text).catch((err) => {
6280
6667
  logger.error(`[Session ${sessionId}] Error processing queued message: ${err.message}`);
6668
+ if (!acpStopped) {
6669
+ sessionMetadata = { ...sessionMetadata, lifecycleState: "idle" };
6670
+ sessionService.updateMetadata(sessionMetadata);
6671
+ sessionService.sendSessionEnd();
6672
+ }
6281
6673
  });
6282
6674
  }
6283
6675
  }
@@ -6331,71 +6723,129 @@ The automated loop has finished. Review the progress above and let me know if yo
6331
6723
  isolationConfig: agentIsoConfig
6332
6724
  });
6333
6725
  }
6726
+ let acpStopped = false;
6727
+ let acpBackendReady = false;
6334
6728
  const onTurnEnd = (lastAssistantText) => {
6335
- checkSvampConfig?.();
6336
- const rlState = readRalphState(getRalphStateFilePath(directory, sessionId));
6337
- if (rlState) {
6338
- let promiseFulfilled = false;
6339
- if (rlState.completion_promise) {
6340
- const promiseMatch = lastAssistantText.match(/<promise>([\s\S]*?)<\/promise>/);
6341
- promiseFulfilled = !!(promiseMatch && promiseMatch[1].trim().replace(/\s+/g, " ") === rlState.completion_promise);
6729
+ if (acpStopped) return;
6730
+ insideOnTurnEnd = true;
6731
+ try {
6732
+ checkSvampConfig?.();
6733
+ const rlState = readRalphState(getRalphStateFilePath(directory, sessionId));
6734
+ if (rlState) {
6735
+ let promiseFulfilled = false;
6736
+ if (rlState.completion_promise) {
6737
+ const promiseMatch = lastAssistantText.match(/<promise>([\s\S]*?)<\/promise>/);
6738
+ promiseFulfilled = !!(promiseMatch && promiseMatch[1].trim().replace(/\s+/g, " ") === rlState.completion_promise);
6739
+ }
6740
+ const maxReached = rlState.max_iterations > 0 && rlState.iteration >= rlState.max_iterations;
6741
+ if (promiseFulfilled || maxReached) {
6742
+ removeRalphState(getRalphStateFilePath(directory, sessionId));
6743
+ sessionMetadata = { ...sessionMetadata, ralphLoop: { active: false }, lifecycleState: "idle" };
6744
+ sessionService.updateMetadata(sessionMetadata);
6745
+ const reason = promiseFulfilled ? `Ralph loop completed at iteration ${rlState.iteration} \u2014 promise "${rlState.completion_promise}" fulfilled.` : `Ralph loop stopped \u2014 max iterations (${rlState.max_iterations}) reached.`;
6746
+ logger.log(`[${agentName} Session ${sessionId}] ${reason}`);
6747
+ sessionService.pushMessage({ type: "message", message: reason }, "event");
6748
+ } else {
6749
+ const pendingQueue = sessionMetadata.messageQueue;
6750
+ if (pendingQueue && pendingQueue.length > 0) {
6751
+ const next = pendingQueue[0];
6752
+ const remaining = pendingQueue.slice(1);
6753
+ sessionMetadata = { ...sessionMetadata, messageQueue: remaining.length > 0 ? remaining : void 0, lifecycleState: "running" };
6754
+ sessionService.updateMetadata(sessionMetadata);
6755
+ sessionService.sendKeepAlive(true);
6756
+ sessionService.pushMessage(next.displayText || next.text, "user");
6757
+ logger.log(`[${agentName} Session ${sessionId}] Processing queued message (priority over Ralph advance): "${next.text.slice(0, 50)}..."`);
6758
+ agentBackend.sendPrompt(sessionId, next.text).catch((err) => {
6759
+ logger.error(`[${agentName} Session ${sessionId}] Error processing queued message (Ralph): ${err.message}`);
6760
+ if (!acpStopped) {
6761
+ sessionMetadata = { ...sessionMetadata, lifecycleState: "idle" };
6762
+ sessionService.updateMetadata(sessionMetadata);
6763
+ sessionService.sendSessionEnd();
6764
+ }
6765
+ });
6766
+ return;
6767
+ }
6768
+ const nextIteration = rlState.iteration + 1;
6769
+ const iterationTimestamp = (/* @__PURE__ */ new Date()).toISOString();
6770
+ try {
6771
+ writeRalphState(getRalphStateFilePath(directory, sessionId), { ...rlState, iteration: nextIteration, last_iteration_at: iterationTimestamp });
6772
+ } catch (writeErr) {
6773
+ logger.log(`[${agentName} Session ${sessionId}] Ralph: failed to persist state (iter ${nextIteration}): ${writeErr.message} \u2014 stopping loop`);
6774
+ sessionService.pushMessage({ type: "message", message: `Ralph loop error: failed to persist iteration state \u2014 loop stopped. (${writeErr.message})` }, "event");
6775
+ removeRalphState(getRalphStateFilePath(directory, sessionId));
6776
+ sessionMetadata = { ...sessionMetadata, ralphLoop: { active: false }, lifecycleState: "idle" };
6777
+ sessionService.updateMetadata(sessionMetadata);
6778
+ return;
6779
+ }
6780
+ const ralphLoop = {
6781
+ active: true,
6782
+ task: rlState.task,
6783
+ completionPromise: rlState.completion_promise ?? "none",
6784
+ maxIterations: rlState.max_iterations,
6785
+ currentIteration: nextIteration,
6786
+ startedAt: rlState.started_at,
6787
+ cooldownSeconds: rlState.cooldown_seconds,
6788
+ contextMode: rlState.context_mode || "fresh",
6789
+ lastIterationStartedAt: (/* @__PURE__ */ new Date()).toISOString()
6790
+ };
6791
+ sessionMetadata = { ...sessionMetadata, ralphLoop, lifecycleState: "running" };
6792
+ sessionService.updateMetadata(sessionMetadata);
6793
+ logger.log(`[${agentName} Session ${sessionId}] Ralph loop iteration ${nextIteration}${rlState.max_iterations > 0 ? `/${rlState.max_iterations}` : ""}: spawning`);
6794
+ const updatedState = { ...rlState, iteration: nextIteration, context_mode: "continue" };
6795
+ const prompt = buildRalphPrompt(rlState.task, updatedState);
6796
+ const cooldownMs = Math.max(200, rlState.cooldown_seconds * 1e3);
6797
+ setTimeout(() => {
6798
+ if (acpStopped) return;
6799
+ const liveRlState = readRalphState(getRalphStateFilePath(directory, sessionId));
6800
+ if (!liveRlState) {
6801
+ sessionMetadata = { ...sessionMetadata, lifecycleState: "idle" };
6802
+ sessionService.updateMetadata(sessionMetadata);
6803
+ sessionService.sendKeepAlive(false);
6804
+ sessionService.sendSessionEnd();
6805
+ return;
6806
+ }
6807
+ sessionService.sendKeepAlive(true);
6808
+ sessionMetadata = { ...sessionMetadata, lifecycleState: "running" };
6809
+ sessionService.updateMetadata(sessionMetadata);
6810
+ sessionService.pushMessage({ type: "message", message: buildIterationStatus(nextIteration, rlState.max_iterations, rlState.completion_promise) }, "event");
6811
+ sessionService.pushMessage(rlState.task, "user");
6812
+ agentBackend.sendPrompt(sessionId, prompt).catch((err) => {
6813
+ logger.error(`[${agentName} Session ${sessionId}] Error in Ralph loop: ${err.message}`);
6814
+ if (!acpStopped) {
6815
+ removeRalphState(getRalphStateFilePath(directory, sessionId));
6816
+ sessionMetadata = { ...sessionMetadata, ralphLoop: { active: false }, lifecycleState: "idle" };
6817
+ sessionService.updateMetadata(sessionMetadata);
6818
+ sessionService.sendSessionEnd();
6819
+ sessionService.pushMessage({ type: "message", message: `Ralph loop error: agent failed to start turn \u2014 loop stopped. (${err.message})` }, "event");
6820
+ }
6821
+ });
6822
+ }, cooldownMs);
6823
+ return;
6824
+ }
6342
6825
  }
6343
- const maxReached = rlState.max_iterations > 0 && rlState.iteration >= rlState.max_iterations;
6344
- if (promiseFulfilled || maxReached) {
6345
- removeRalphState(getRalphStateFilePath(directory, sessionId));
6346
- sessionMetadata = { ...sessionMetadata, ralphLoop: { active: false }, lifecycleState: "idle" };
6347
- sessionService.updateMetadata(sessionMetadata);
6348
- const reason = promiseFulfilled ? `Ralph loop completed at iteration ${rlState.iteration} \u2014 promise "${rlState.completion_promise}" fulfilled.` : `Ralph loop stopped \u2014 max iterations (${rlState.max_iterations}) reached.`;
6349
- logger.log(`[${agentName} Session ${sessionId}] ${reason}`);
6350
- sessionService.pushMessage({ type: "message", message: reason }, "event");
6351
- } else {
6352
- const nextIteration = rlState.iteration + 1;
6353
- const iterationTimestamp = (/* @__PURE__ */ new Date()).toISOString();
6354
- writeRalphState(getRalphStateFilePath(directory, sessionId), { ...rlState, iteration: nextIteration, last_iteration_at: iterationTimestamp });
6355
- const ralphLoop = {
6356
- active: true,
6357
- task: rlState.task,
6358
- completionPromise: rlState.completion_promise ?? "none",
6359
- maxIterations: rlState.max_iterations,
6360
- currentIteration: nextIteration,
6361
- startedAt: rlState.started_at,
6362
- cooldownSeconds: rlState.cooldown_seconds,
6363
- contextMode: rlState.context_mode || "fresh",
6364
- lastIterationStartedAt: (/* @__PURE__ */ new Date()).toISOString()
6365
- };
6366
- sessionMetadata = { ...sessionMetadata, ralphLoop };
6826
+ const queue = sessionMetadata.messageQueue;
6827
+ if (queue && queue.length > 0) {
6828
+ const next = queue[0];
6829
+ const remaining = queue.slice(1);
6830
+ sessionMetadata = { ...sessionMetadata, messageQueue: remaining.length > 0 ? remaining : void 0, lifecycleState: "running" };
6367
6831
  sessionService.updateMetadata(sessionMetadata);
6368
- logger.log(`[${agentName} Session ${sessionId}] Ralph loop iteration ${nextIteration}${rlState.max_iterations > 0 ? `/${rlState.max_iterations}` : ""}: spawning`);
6369
- const updatedState = { ...rlState, iteration: nextIteration, context_mode: "continue" };
6370
- const prompt = buildRalphPrompt(rlState.task, updatedState);
6371
- const cooldownMs = Math.max(200, rlState.cooldown_seconds * 1e3);
6372
- setTimeout(() => {
6373
- sessionService.sendKeepAlive(true);
6374
- sessionMetadata = { ...sessionMetadata, lifecycleState: "running" };
6375
- sessionService.updateMetadata(sessionMetadata);
6376
- sessionService.pushMessage({ type: "message", message: buildIterationStatus(nextIteration, rlState.max_iterations, rlState.completion_promise) }, "event");
6377
- sessionService.pushMessage(rlState.task, "user");
6378
- agentBackend.sendPrompt(sessionId, prompt).catch((err) => {
6379
- logger.error(`[${agentName} Session ${sessionId}] Error in Ralph loop: ${err.message}`);
6380
- });
6381
- }, cooldownMs);
6382
- return;
6832
+ sessionService.sendKeepAlive(true);
6833
+ logger.log(`[Session ${sessionId}] Processing queued message: "${next.text.slice(0, 50)}..."`);
6834
+ sessionService.pushMessage(next.displayText || next.text, "user");
6835
+ agentBackend.sendPrompt(sessionId, next.text).catch((err) => {
6836
+ logger.error(`[Session ${sessionId}] Error processing queued message: ${err.message}`);
6837
+ if (!acpStopped) {
6838
+ sessionMetadata = { ...sessionMetadata, lifecycleState: "idle" };
6839
+ sessionService.updateMetadata(sessionMetadata);
6840
+ sessionService.sendSessionEnd();
6841
+ }
6842
+ });
6383
6843
  }
6384
- }
6385
- const queue = sessionMetadata.messageQueue;
6386
- if (queue && queue.length > 0) {
6387
- const next = queue[0];
6388
- const remaining = queue.slice(1);
6389
- sessionMetadata = { ...sessionMetadata, messageQueue: remaining.length > 0 ? remaining : void 0, lifecycleState: "running" };
6390
- sessionService.updateMetadata(sessionMetadata);
6391
- logger.log(`[Session ${sessionId}] Processing queued message: "${next.text.slice(0, 50)}..."`);
6392
- sessionService.pushMessage(next.displayText || next.text, "user");
6393
- agentBackend.sendPrompt(sessionId, next.text).catch((err) => {
6394
- logger.error(`[Session ${sessionId}] Error processing queued message: ${err.message}`);
6395
- });
6844
+ } finally {
6845
+ insideOnTurnEnd = false;
6396
6846
  }
6397
6847
  };
6398
- bridgeAcpToSession(
6848
+ const cleanupBridge = bridgeAcpToSession(
6399
6849
  agentBackend,
6400
6850
  sessionService,
6401
6851
  () => sessionMetadata,
@@ -6417,11 +6867,19 @@ The automated loop has finished. Review the progress above and let me know if yo
6417
6867
  resumeSessionId,
6418
6868
  get childProcess() {
6419
6869
  return agentBackend.getProcess?.() || void 0;
6870
+ },
6871
+ onStop: () => {
6872
+ acpStopped = true;
6873
+ cleanupBridge();
6874
+ permissionHandler.rejectAll("session stopped");
6875
+ agentBackend.dispose().catch(() => {
6876
+ });
6420
6877
  }
6421
6878
  };
6422
- pidToTrackedSession.set(process.pid + Math.floor(Math.random() * 1e5), trackedSession);
6879
+ pidToTrackedSession.set(randomUUID$1(), trackedSession);
6423
6880
  logger.log(`[Agent Session ${sessionId}] Starting ${agentName} backend...`);
6424
6881
  agentBackend.startSession().then(() => {
6882
+ acpBackendReady = true;
6425
6883
  logger.log(`[Agent Session ${sessionId}] ${agentName} backend started, waiting for first message`);
6426
6884
  }).catch((err) => {
6427
6885
  logger.error(`[Agent Session ${sessionId}] Failed to start ${agentName}:`, err);
@@ -6430,6 +6888,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6430
6888
  "event"
6431
6889
  );
6432
6890
  sessionService.sendSessionEnd();
6891
+ stopSession(sessionId);
6433
6892
  });
6434
6893
  return {
6435
6894
  type: "success",
@@ -6449,6 +6908,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6449
6908
  for (const [pid, session] of pidToTrackedSession) {
6450
6909
  if (session.svampSessionId === sessionId) {
6451
6910
  session.stopped = true;
6911
+ session.onStop?.();
6452
6912
  session.hyphaService?.disconnect().catch(() => {
6453
6913
  });
6454
6914
  if (session.childProcess) {
@@ -6460,12 +6920,14 @@ The automated loop has finished. Review the progress above and let me know if yo
6460
6920
  session.cleanupCredentials?.().catch(() => {
6461
6921
  });
6462
6922
  session.cleanupSvampConfig?.();
6923
+ artifactSync.cancelSync(sessionId);
6463
6924
  pidToTrackedSession.delete(pid);
6464
6925
  deletePersistedSession(sessionId);
6465
6926
  logger.log(`Session ${sessionId} stopped`);
6466
6927
  return true;
6467
6928
  }
6468
6929
  }
6930
+ artifactSync.cancelSync(sessionId);
6469
6931
  deletePersistedSession(sessionId);
6470
6932
  logger.log(`Session ${sessionId} not found in memory, cleaned up persisted state`);
6471
6933
  return false;
@@ -6602,14 +7064,20 @@ The automated loop has finished. Review the progress above and let me know if yo
6602
7064
  for (const sessionId of sessionsToAutoContinue) {
6603
7065
  setTimeout(async () => {
6604
7066
  try {
6605
- const svc = await server.getService(`svamp-session-${sessionId}`);
6606
- await svc.sendMessage(
6607
- JSON.stringify({
6608
- role: "user",
6609
- content: { type: "text", text: "The session was interrupted. Please continue." },
6610
- meta: { sentFrom: "svamp-daemon-auto-continue" }
6611
- })
6612
- );
7067
+ const svc = await Promise.race([
7068
+ server.getService(`svamp-session-${sessionId}`),
7069
+ new Promise((_, reject) => setTimeout(() => reject(new Error("getService timeout")), 1e4))
7070
+ ]);
7071
+ await Promise.race([
7072
+ svc.sendMessage(
7073
+ JSON.stringify({
7074
+ role: "user",
7075
+ content: { type: "text", text: 'The svamp daemon was restarted, which interrupted this session. Please continue where you left off. IMPORTANT: Do not run any command that would stop or restart the svamp daemon (e.g. "svamp daemon stop") \u2014 that would interrupt the session again.' },
7076
+ meta: { sentFrom: "svamp-daemon-auto-continue" }
7077
+ })
7078
+ ),
7079
+ new Promise((_, reject) => setTimeout(() => reject(new Error("sendMessage timeout")), 3e4))
7080
+ ]);
6613
7081
  logger.log(`Auto-continued session ${sessionId}`);
6614
7082
  } catch (err) {
6615
7083
  logger.log(`Failed to auto-continue session ${sessionId}: ${err.message}`);
@@ -6623,7 +7091,10 @@ The automated loop has finished. Review the progress above and let me know if yo
6623
7091
  logger.log(`Resuming Ralph loop for ${sessionsToRalphResume.length} session(s)...`);
6624
7092
  for (const { sessionId, directory: sessDir } of sessionsToRalphResume) {
6625
7093
  try {
6626
- const svc = await server.getService(`svamp-session-${sessionId}`);
7094
+ const svc = await Promise.race([
7095
+ server.getService(`svamp-session-${sessionId}`),
7096
+ new Promise((_, reject) => setTimeout(() => reject(new Error("getService timeout")), 1e4))
7097
+ ]);
6627
7098
  const rlState = readRalphState(getRalphStateFilePath(sessDir, sessionId));
6628
7099
  if (!rlState) continue;
6629
7100
  const initDelayMs = 2e3;
@@ -6644,17 +7115,20 @@ The automated loop has finished. Review the progress above and let me know if yo
6644
7115
  const progressRelPath = `.svamp/${sessionId}/ralph-progress.md`;
6645
7116
  const prompt = buildRalphPrompt(currentState.task, currentState);
6646
7117
  const ralphSysPrompt = buildRalphSystemPrompt(currentState, progressRelPath);
6647
- await svc.sendMessage(
6648
- JSON.stringify({
6649
- role: "user",
6650
- content: { type: "text", text: prompt },
6651
- meta: {
6652
- sentFrom: "svamp-daemon-ralph-resume",
6653
- appendSystemPrompt: ralphSysPrompt,
6654
- ...isFreshMode ? { ralphFreshContext: true } : {}
6655
- }
6656
- })
6657
- );
7118
+ await Promise.race([
7119
+ svc.sendMessage(
7120
+ JSON.stringify({
7121
+ role: "user",
7122
+ content: { type: "text", text: prompt },
7123
+ meta: {
7124
+ sentFrom: "svamp-daemon-ralph-resume",
7125
+ appendSystemPrompt: ralphSysPrompt,
7126
+ ...isFreshMode ? { ralphFreshContext: true } : {}
7127
+ }
7128
+ })
7129
+ ),
7130
+ new Promise((_, reject) => setTimeout(() => reject(new Error("sendMessage timeout")), 3e4))
7131
+ ]);
6658
7132
  logger.log(`Resumed Ralph loop for session ${sessionId} at iteration ${currentState.iteration} (${isFreshMode ? "fresh" : "continue"})`);
6659
7133
  } catch (err) {
6660
7134
  logger.log(`Failed to resume Ralph loop for session ${sessionId}: ${err.message}`);
@@ -6740,9 +7214,16 @@ The automated loop has finished. Review the progress above and let me know if yo
6740
7214
  process.kill(child.pid, 0);
6741
7215
  } catch {
6742
7216
  logger.log(`Removing stale session (child PID ${child.pid} dead): ${session.svampSessionId}`);
7217
+ session.stopped = true;
7218
+ session.onStop?.();
6743
7219
  session.hyphaService?.disconnect().catch(() => {
6744
7220
  });
7221
+ session.cleanupCredentials?.().catch(() => {
7222
+ });
7223
+ session.cleanupSvampConfig?.();
7224
+ if (session.svampSessionId) artifactSync.cancelSync(session.svampSessionId);
6745
7225
  pidToTrackedSession.delete(key);
7226
+ if (session.svampSessionId) deletePersistedSession(session.svampSessionId);
6746
7227
  }
6747
7228
  }
6748
7229
  }
@@ -6810,6 +7291,10 @@ The automated loop has finished. Review the progress above and let me know if yo
6810
7291
  clearInterval(heartbeatInterval);
6811
7292
  if (proxyTokenRefreshInterval) clearInterval(proxyTokenRefreshInterval);
6812
7293
  if (unhandledRejectionResetTimer) clearTimeout(unhandledRejectionResetTimer);
7294
+ for (const [, session] of pidToTrackedSession) {
7295
+ session.stopped = true;
7296
+ session.onStop?.();
7297
+ }
6813
7298
  machineService.updateDaemonState({
6814
7299
  ...initialDaemonState,
6815
7300
  status: "shutting-down",
@@ -6817,7 +7302,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6817
7302
  shutdownSource: source
6818
7303
  });
6819
7304
  await new Promise((r) => setTimeout(r, 200));
6820
- for (const [pid, session] of pidToTrackedSession) {
7305
+ for (const [, session] of pidToTrackedSession) {
6821
7306
  session.hyphaService?.disconnect().catch(() => {
6822
7307
  });
6823
7308
  if (session.childProcess) {
@@ -6834,14 +7319,21 @@ The automated loop has finished. Review the progress above and let me know if yo
6834
7319
  if (shouldMarkStopped) {
6835
7320
  try {
6836
7321
  const index = loadSessionIndex();
7322
+ let markedCount = 0;
6837
7323
  for (const [sessionId, entry] of Object.entries(index)) {
6838
- const filePath = getSessionFilePath(entry.directory, sessionId);
6839
- if (existsSync$1(filePath)) {
6840
- const data = JSON.parse(readFileSync$1(filePath, "utf-8"));
6841
- writeFileSync(filePath, JSON.stringify({ ...data, stopped: true }, null, 2), "utf-8");
7324
+ try {
7325
+ const filePath = getSessionFilePath(entry.directory, sessionId);
7326
+ if (existsSync$1(filePath)) {
7327
+ const data = JSON.parse(readFileSync$1(filePath, "utf-8"));
7328
+ const tmpPath = filePath + ".tmp";
7329
+ writeFileSync(tmpPath, JSON.stringify({ ...data, stopped: true }, null, 2), "utf-8");
7330
+ renameSync(tmpPath, filePath);
7331
+ markedCount++;
7332
+ }
7333
+ } catch {
6842
7334
  }
6843
7335
  }
6844
- logger.log("Marked all sessions as stopped (--cleanup mode)");
7336
+ logger.log(`Marked ${markedCount} session(s) as stopped (--cleanup mode)`);
6845
7337
  } catch {
6846
7338
  }
6847
7339
  } else {