svamp-cli 0.1.63 → 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,60 +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
- let callerDisconnected = false;
553
- const callerClientId = context?.from;
554
- const push = (update) => {
555
- pending.push(update);
556
- wake?.();
557
- };
558
- const onDisconnect = (event) => {
559
- if (callerClientId && event.client_id === callerClientId) {
560
- callerDisconnected = true;
561
- const w = wake;
562
- wake = null;
563
- w?.();
564
- }
565
- };
566
- server.on("remote_client_disconnected", onDisconnect);
567
- subscribers.add(push);
568
- console.log(`[HYPHA MACHINE] subscribe() started (total: ${subscribers.size})`);
569
- try {
570
- yield {
571
- type: "update-machine",
572
- machineId,
573
- metadata: { value: currentMetadata, version: metadataVersion },
574
- daemonState: { value: currentDaemonState, version: daemonStateVersion }
575
- };
576
- while (!callerDisconnected) {
577
- while (pending.length === 0 && !callerDisconnected) {
578
- await new Promise((r) => {
579
- wake = r;
580
- });
581
- wake = null;
582
- }
583
- while (pending.length > 0) {
584
- yield pending.shift();
585
- }
586
- }
587
- } finally {
588
- server.off("remote_client_disconnected", onDisconnect);
589
- subscribers.delete(push);
590
- wake?.();
591
- console.log(`[HYPHA MACHINE] subscribe() ended (remaining: ${subscribers.size})`);
592
- }
582
+ listeners.push(callback);
583
+ return { success: true, listenerId: listeners.length - 1 };
593
584
  },
594
585
  // Shell access
595
586
  bash: async (command, cwd, context) => {
@@ -615,7 +606,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
615
606
  const targetPath = resolve(path || homedir());
616
607
  const home = homedir();
617
608
  const isOwner = !currentMetadata.sharing?.enabled || context?.user?.email && currentMetadata.sharing.owner && context.user.email.toLowerCase() === currentMetadata.sharing.owner.toLowerCase();
618
- if (!isOwner && !targetPath.startsWith(home)) {
609
+ if (!isOwner && targetPath !== home && !targetPath.startsWith(home + "/")) {
619
610
  throw new Error(`Access denied: path must be within ${home}`);
620
611
  }
621
612
  const showHidden = options?.showHidden ?? false;
@@ -656,7 +647,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
656
647
  },
657
648
  /** Add and start a new supervised process. */
658
649
  processAdd: async (params, context) => {
659
- authorizeRequest(context, currentMetadata.sharing, "interact");
650
+ authorizeRequest(context, currentMetadata.sharing, "admin");
660
651
  if (!handlers.supervisor) throw new Error("Process supervisor not available");
661
652
  return handlers.supervisor.add(params.spec);
662
653
  },
@@ -665,7 +656,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
665
656
  * Returns { action: 'created'|'updated'|'no-change', info: ProcessInfo }
666
657
  */
667
658
  processApply: async (params, context) => {
668
- authorizeRequest(context, currentMetadata.sharing, "interact");
659
+ authorizeRequest(context, currentMetadata.sharing, "admin");
669
660
  if (!handlers.supervisor) throw new Error("Process supervisor not available");
670
661
  return handlers.supervisor.apply(params.spec);
671
662
  },
@@ -674,7 +665,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
674
665
  * Returns updated ProcessInfo.
675
666
  */
676
667
  processUpdate: async (params, context) => {
677
- authorizeRequest(context, currentMetadata.sharing, "interact");
668
+ authorizeRequest(context, currentMetadata.sharing, "admin");
678
669
  if (!handlers.supervisor) throw new Error("Process supervisor not available");
679
670
  return handlers.supervisor.update(params.idOrName, params.spec);
680
671
  },
@@ -719,7 +710,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
719
710
  serviceList: async (context) => {
720
711
  authorizeRequest(context, currentMetadata.sharing, "view");
721
712
  try {
722
- const { listServiceGroups } = await import('./api-Cegey1dh.mjs');
713
+ const { listServiceGroups } = await import('./api-BRbsyqJ4.mjs');
723
714
  return await listServiceGroups();
724
715
  } catch (err) {
725
716
  return [];
@@ -728,13 +719,13 @@ async function registerMachineService(server, machineId, metadata, daemonState,
728
719
  /** Get full details of a single service group (includes backends + health). */
729
720
  serviceGet: async (params, context) => {
730
721
  authorizeRequest(context, currentMetadata.sharing, "view");
731
- const { getServiceGroup } = await import('./api-Cegey1dh.mjs');
722
+ const { getServiceGroup } = await import('./api-BRbsyqJ4.mjs');
732
723
  return getServiceGroup(params.name);
733
724
  },
734
725
  /** Delete a service group. */
735
726
  serviceDelete: async (params, context) => {
736
727
  authorizeRequest(context, currentMetadata.sharing, "admin");
737
- const { deleteServiceGroup } = await import('./api-Cegey1dh.mjs');
728
+ const { deleteServiceGroup } = await import('./api-BRbsyqJ4.mjs');
738
729
  return deleteServiceGroup(params.name);
739
730
  },
740
731
  // WISE voice — create ephemeral token for OpenAI Realtime API
@@ -745,19 +736,27 @@ async function registerMachineService(server, machineId, metadata, daemonState,
745
736
  return { success: false, error: "No OpenAI API key found. Set OPENAI_API_KEY or pass apiKey." };
746
737
  }
747
738
  try {
748
- const response = await fetch("https://api.openai.com/v1/realtime/client_secrets", {
749
- method: "POST",
750
- headers: {
751
- "Authorization": `Bearer ${apiKey}`,
752
- "Content-Type": "application/json"
753
- },
754
- body: JSON.stringify({
755
- session: {
756
- type: "realtime",
757
- model: params.model || "gpt-realtime-mini"
758
- }
759
- })
760
- });
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
+ }
761
760
  if (!response.ok) {
762
761
  return { success: false, error: `OpenAI API error: ${response.status}` };
763
762
  }
@@ -776,7 +775,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
776
775
  updateMetadata: (newMetadata) => {
777
776
  currentMetadata = newMetadata;
778
777
  metadataVersion++;
779
- notifySubscribers({
778
+ notifyListeners({
780
779
  type: "update-machine",
781
780
  machineId,
782
781
  metadata: { value: currentMetadata, version: metadataVersion }
@@ -785,13 +784,17 @@ async function registerMachineService(server, machineId, metadata, daemonState,
785
784
  updateDaemonState: (newState) => {
786
785
  currentDaemonState = newState;
787
786
  daemonStateVersion++;
788
- notifySubscribers({
787
+ notifyListeners({
789
788
  type: "update-machine",
790
789
  machineId,
791
790
  daemonState: { value: currentDaemonState, version: daemonStateVersion }
792
791
  });
793
792
  },
794
793
  disconnect: async () => {
794
+ const toRemove = [...listeners];
795
+ for (const listener of toRemove) {
796
+ removeListener(listener, "disconnect");
797
+ }
795
798
  await server.unregisterService(serviceInfo.id);
796
799
  }
797
800
  };
@@ -809,17 +812,21 @@ function loadMessages(messagesDir, sessionId) {
809
812
  } catch {
810
813
  }
811
814
  }
812
- return messages.slice(-5e3);
815
+ return messages.slice(-1e3);
813
816
  } catch {
814
817
  return [];
815
818
  }
816
819
  }
817
820
  function appendMessage(messagesDir, sessionId, msg) {
818
- const filePath = join$1(messagesDir, "messages.jsonl");
819
- if (!existsSync(messagesDir)) {
820
- 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}`);
821
829
  }
822
- appendFileSync(filePath, JSON.stringify(msg) + "\n");
823
830
  }
824
831
  async function registerSessionService(server, sessionId, initialMetadata, initialAgentState, callbacks, options) {
825
832
  const messages = options?.messagesDir ? loadMessages(options.messagesDir) : [];
@@ -834,9 +841,36 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
834
841
  mode: "remote",
835
842
  time: Date.now()
836
843
  };
837
- const subscribers = /* @__PURE__ */ new Set();
838
- const notifySubscribers = (update) => {
839
- 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
+ }
840
874
  };
841
875
  const pushMessage = (content, role = "agent") => {
842
876
  let wrappedContent;
@@ -867,7 +901,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
867
901
  if (options?.messagesDir) {
868
902
  appendMessage(options.messagesDir, sessionId, msg);
869
903
  }
870
- notifySubscribers({
904
+ notifyListeners({
871
905
  type: "new-message",
872
906
  sessionId,
873
907
  message: msg
@@ -928,7 +962,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
928
962
  if (options?.messagesDir) {
929
963
  appendMessage(options.messagesDir, sessionId, msg);
930
964
  }
931
- notifySubscribers({
965
+ notifyListeners({
932
966
  type: "new-message",
933
967
  sessionId,
934
968
  message: msg
@@ -955,7 +989,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
955
989
  }
956
990
  metadata = newMetadata;
957
991
  metadataVersion++;
958
- notifySubscribers({
992
+ notifyListeners({
959
993
  type: "update-session",
960
994
  sessionId,
961
995
  metadata: { value: metadata, version: metadataVersion }
@@ -973,7 +1007,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
973
1007
  * Null values remove keys from the config.
974
1008
  */
975
1009
  updateConfig: async (patch, context) => {
976
- authorizeRequest(context, metadata.sharing, "interact");
1010
+ authorizeRequest(context, metadata.sharing, "admin");
977
1011
  callbacks.onUpdateConfig?.(patch);
978
1012
  return { success: true };
979
1013
  },
@@ -996,7 +1030,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
996
1030
  }
997
1031
  agentState = newState;
998
1032
  agentStateVersion++;
999
- notifySubscribers({
1033
+ notifyListeners({
1000
1034
  type: "update-session",
1001
1035
  sessionId,
1002
1036
  agentState: { value: agentState, version: agentStateVersion }
@@ -1019,7 +1053,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1019
1053
  return { success: true };
1020
1054
  },
1021
1055
  switchMode: async (mode, context) => {
1022
- authorizeRequest(context, metadata.sharing, "interact");
1056
+ authorizeRequest(context, metadata.sharing, "admin");
1023
1057
  callbacks.onSwitchMode(mode);
1024
1058
  return { success: true };
1025
1059
  },
@@ -1034,16 +1068,18 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1034
1068
  },
1035
1069
  // ── Activity ──
1036
1070
  keepAlive: async (thinking, mode, context) => {
1071
+ authorizeRequest(context, metadata.sharing, "interact");
1037
1072
  lastActivity = { active: true, thinking: thinking || false, mode: mode || "remote", time: Date.now() };
1038
- notifySubscribers({
1073
+ notifyListeners({
1039
1074
  type: "activity",
1040
1075
  sessionId,
1041
1076
  ...lastActivity
1042
1077
  });
1043
1078
  },
1044
1079
  sessionEnd: async (context) => {
1080
+ authorizeRequest(context, metadata.sharing, "interact");
1045
1081
  lastActivity = { active: false, thinking: false, mode: "remote", time: Date.now() };
1046
- notifySubscribers({
1082
+ notifyListeners({
1047
1083
  type: "activity",
1048
1084
  sessionId,
1049
1085
  ...lastActivity
@@ -1104,6 +1140,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1104
1140
  },
1105
1141
  /** Returns the caller's effective role (null if no access). Does not throw. */
1106
1142
  getEffectiveRole: async (context) => {
1143
+ authorizeRequest(context, metadata.sharing, "view");
1107
1144
  const role = getEffectiveRole(context, metadata.sharing);
1108
1145
  return { role };
1109
1146
  },
@@ -1117,7 +1154,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1117
1154
  }
1118
1155
  metadata = { ...metadata, sharing: newSharing };
1119
1156
  metadataVersion++;
1120
- notifySubscribers({
1157
+ notifyListeners({
1121
1158
  type: "update-session",
1122
1159
  sessionId,
1123
1160
  metadata: { value: metadata, version: metadataVersion }
@@ -1135,7 +1172,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1135
1172
  }
1136
1173
  metadata = { ...metadata, securityContext: newSecurityContext };
1137
1174
  metadataVersion++;
1138
- notifySubscribers({
1175
+ notifyListeners({
1139
1176
  type: "update-session",
1140
1177
  sessionId,
1141
1178
  metadata: { value: metadata, version: metadataVersion }
@@ -1150,67 +1187,69 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1150
1187
  }
1151
1188
  return await callbacks.onApplySystemPrompt(prompt);
1152
1189
  },
1153
- // ── Live Update Stream ──
1154
- //
1155
- // Returns an async generator that yields real-time updates for this session.
1156
- // hypha-rpc proxies the generator across the RPC boundary — the frontend
1157
- // iterates with `for await (const update of service.subscribe())`.
1158
- //
1159
- // Initial state is replayed as the first batch of yields so the frontend
1160
- // can reconstruct full session state without a separate RPC call.
1161
- // Cleanup is automatic: when the frontend disconnects, hypha-rpc calls the
1162
- // generator's close method, triggering the finally block which removes the
1163
- // subscriber. No reverse `_rintf` service is registered.
1164
- subscribe: async function* (context) {
1190
+ // ── Listener Registration ──
1191
+ registerListener: async (callback, context) => {
1165
1192
  authorizeRequest(context, metadata.sharing, "view");
1166
- const pending = [];
1167
- let wake = null;
1168
- let callerDisconnected = false;
1169
- const callerClientId = context?.from;
1170
- const push = (update) => {
1171
- pending.push(update);
1172
- wake?.();
1173
- };
1174
- const onDisconnect = (event) => {
1175
- if (callerClientId && event.client_id === callerClientId) {
1176
- callerDisconnected = true;
1177
- const w = wake;
1178
- wake = null;
1179
- w?.();
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" };
1180
1222
  }
1181
- };
1182
- server.on("remote_client_disconnected", onDisconnect);
1183
- subscribers.add(push);
1184
- console.log(`[HYPHA SESSION ${sessionId}] subscribe() started (total: ${subscribers.size})`);
1223
+ }
1224
+ if (listeners.indexOf(callback) < 0) {
1225
+ return { success: false, error: "Listener was removed during replay" };
1226
+ }
1185
1227
  try {
1186
- yield {
1228
+ const result = callback.onUpdate({
1187
1229
  type: "update-session",
1188
1230
  sessionId,
1189
1231
  metadata: { value: metadata, version: metadataVersion },
1190
1232
  agentState: { value: agentState, version: agentStateVersion }
1191
- };
1192
- const MAX_REPLAY = 50;
1193
- for (const msg of messages.slice(-MAX_REPLAY)) {
1194
- yield { type: "new-message", sessionId, message: msg };
1233
+ });
1234
+ if (result && typeof result.catch === "function") {
1235
+ result.catch(() => {
1236
+ });
1195
1237
  }
1196
- yield { type: "activity", sessionId, ...lastActivity };
1197
- while (!callerDisconnected) {
1198
- while (pending.length === 0 && !callerDisconnected) {
1199
- await new Promise((r) => {
1200
- wake = r;
1201
- });
1202
- wake = null;
1203
- }
1204
- while (pending.length > 0) {
1205
- yield pending.shift();
1206
- }
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
+ });
1207
1249
  }
1208
- } finally {
1209
- server.off("remote_client_disconnected", onDisconnect);
1210
- subscribers.delete(push);
1211
- wake?.();
1212
- console.log(`[HYPHA SESSION ${sessionId}] subscribe() ended (remaining: ${subscribers.size})`);
1250
+ } catch {
1213
1251
  }
1252
+ return { success: true, listenerId: listeners.length - 1 };
1214
1253
  }
1215
1254
  },
1216
1255
  { overwrite: true }
@@ -1225,7 +1264,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1225
1264
  updateMetadata: (newMetadata) => {
1226
1265
  metadata = newMetadata;
1227
1266
  metadataVersion++;
1228
- notifySubscribers({
1267
+ notifyListeners({
1229
1268
  type: "update-session",
1230
1269
  sessionId,
1231
1270
  metadata: { value: metadata, version: metadataVersion }
@@ -1234,7 +1273,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1234
1273
  updateAgentState: (newAgentState) => {
1235
1274
  agentState = newAgentState;
1236
1275
  agentStateVersion++;
1237
- notifySubscribers({
1276
+ notifyListeners({
1238
1277
  type: "update-session",
1239
1278
  sessionId,
1240
1279
  agentState: { value: agentState, version: agentStateVersion }
@@ -1242,7 +1281,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1242
1281
  },
1243
1282
  sendKeepAlive: (thinking, mode) => {
1244
1283
  lastActivity = { active: true, thinking: thinking || false, mode: mode || "remote", time: Date.now() };
1245
- notifySubscribers({
1284
+ notifyListeners({
1246
1285
  type: "activity",
1247
1286
  sessionId,
1248
1287
  ...lastActivity
@@ -1250,7 +1289,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1250
1289
  },
1251
1290
  sendSessionEnd: () => {
1252
1291
  lastActivity = { active: false, thinking: false, mode: "remote", time: Date.now() };
1253
- notifySubscribers({
1292
+ notifyListeners({
1254
1293
  type: "activity",
1255
1294
  sessionId,
1256
1295
  ...lastActivity
@@ -1266,12 +1305,16 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1266
1305
  } catch {
1267
1306
  }
1268
1307
  }
1269
- notifySubscribers({
1308
+ notifyListeners({
1270
1309
  type: "clear-messages",
1271
1310
  sessionId
1272
1311
  });
1273
1312
  },
1274
1313
  disconnect: async () => {
1314
+ const toRemove = [...listeners];
1315
+ for (const listener of toRemove) {
1316
+ removeListener(listener, "disconnect");
1317
+ }
1275
1318
  await server.unregisterService(serviceInfo.id);
1276
1319
  }
1277
1320
  };
@@ -1406,6 +1449,19 @@ class SessionArtifactSync {
1406
1449
  this.log(`[ARTIFACT SYNC] Created new collection: ${this.collectionId}`);
1407
1450
  }
1408
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
+ }
1409
1465
  /**
1410
1466
  * Upload a file to an artifact using the presigned URL pattern:
1411
1467
  * 1. put_file() returns a presigned upload URL
@@ -1420,11 +1476,11 @@ class SessionArtifactSync {
1420
1476
  if (!putUrl || typeof putUrl !== "string") {
1421
1477
  throw new Error(`put_file returned invalid URL for ${filePath}: ${putUrl}`);
1422
1478
  }
1423
- const resp = await fetch(putUrl, {
1479
+ const resp = await this.fetchWithTimeout(putUrl, {
1424
1480
  method: "PUT",
1425
1481
  body: content,
1426
1482
  headers: { "Content-Type": "application/octet-stream" }
1427
- });
1483
+ }, 12e4);
1428
1484
  if (!resp.ok) {
1429
1485
  throw new Error(`Upload failed for ${filePath}: ${resp.status} ${resp.statusText}`);
1430
1486
  }
@@ -1441,7 +1497,7 @@ class SessionArtifactSync {
1441
1497
  _rkwargs: true
1442
1498
  });
1443
1499
  if (!getUrl || typeof getUrl !== "string") return null;
1444
- const resp = await fetch(getUrl);
1500
+ const resp = await this.fetchWithTimeout(getUrl, {}, 6e4);
1445
1501
  if (!resp.ok) return null;
1446
1502
  return await resp.text();
1447
1503
  }
@@ -1459,16 +1515,27 @@ class SessionArtifactSync {
1459
1515
  const artifactAlias = `session-${sessionId}`;
1460
1516
  const sessionJsonPath = join$1(sessionsDir, "session.json");
1461
1517
  const messagesPath = join$1(sessionsDir, "messages.jsonl");
1462
- 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
+ }
1463
1525
  const messagesExist = existsSync(messagesPath);
1464
1526
  const messageCount = messagesExist ? readFileSync(messagesPath, "utf-8").split("\n").filter((l) => l.trim()).length : 0;
1465
1527
  let artifactId;
1528
+ let existingArtifactId = null;
1466
1529
  try {
1467
1530
  const existing = await this.artifactManager.read({
1468
1531
  artifact_id: artifactAlias,
1469
1532
  _rkwargs: true
1470
1533
  });
1471
- artifactId = existing.id;
1534
+ existingArtifactId = existing.id;
1535
+ } catch {
1536
+ }
1537
+ if (existingArtifactId) {
1538
+ artifactId = existingArtifactId;
1472
1539
  await this.artifactManager.edit({
1473
1540
  artifact_id: artifactId,
1474
1541
  manifest: {
@@ -1482,7 +1549,7 @@ class SessionArtifactSync {
1482
1549
  stage: true,
1483
1550
  _rkwargs: true
1484
1551
  });
1485
- } catch {
1552
+ } else {
1486
1553
  const artifact = await this.artifactManager.create({
1487
1554
  alias: artifactAlias,
1488
1555
  parent_id: this.collectionId,
@@ -1536,6 +1603,16 @@ class SessionArtifactSync {
1536
1603
  }, delayMs);
1537
1604
  this.syncTimers.set(sessionId, timer);
1538
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
+ }
1539
1616
  /**
1540
1617
  * Download a session from artifact store to local disk.
1541
1618
  */
@@ -1903,6 +1980,7 @@ function completeToolCall(toolCallId, toolKind, content, ctx) {
1903
1980
  const toolKindStr = typeof toolKind === "string" ? toolKind : "unknown";
1904
1981
  ctx.activeToolCalls.delete(toolCallId);
1905
1982
  ctx.toolCallStartTimes.delete(toolCallId);
1983
+ ctx.toolCallIdToNameMap.delete(toolCallId);
1906
1984
  const timeout = ctx.toolCallTimeouts.get(toolCallId);
1907
1985
  if (timeout) {
1908
1986
  clearTimeout(timeout);
@@ -1912,7 +1990,12 @@ function completeToolCall(toolCallId, toolKind, content, ctx) {
1912
1990
  ctx.emit({ type: "tool-result", toolName: toolKindStr, result: content, callId: toolCallId });
1913
1991
  if (ctx.activeToolCalls.size === 0) {
1914
1992
  ctx.clearIdleTimeout();
1915
- 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);
1916
1999
  }
1917
2000
  }
1918
2001
  function failToolCall(toolCallId, status, toolKind, content, ctx) {
@@ -1921,6 +2004,7 @@ function failToolCall(toolCallId, status, toolKind, content, ctx) {
1921
2004
  const toolKindStr = typeof toolKind === "string" ? toolKind : "unknown";
1922
2005
  ctx.activeToolCalls.delete(toolCallId);
1923
2006
  ctx.toolCallStartTimes.delete(toolCallId);
2007
+ ctx.toolCallIdToNameMap.delete(toolCallId);
1924
2008
  const timeout = ctx.toolCallTimeouts.get(toolCallId);
1925
2009
  if (timeout) {
1926
2010
  clearTimeout(timeout);
@@ -1936,7 +2020,12 @@ function failToolCall(toolCallId, status, toolKind, content, ctx) {
1936
2020
  });
1937
2021
  if (ctx.activeToolCalls.size === 0) {
1938
2022
  ctx.clearIdleTimeout();
1939
- 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);
1940
2029
  }
1941
2030
  }
1942
2031
  function handleToolCallUpdate(update, ctx) {
@@ -2185,10 +2274,22 @@ class AcpBackend {
2185
2274
  this.emit({ type: "status", status: "error", detail: err.message });
2186
2275
  });
2187
2276
  this.process.on("exit", (code, signal) => {
2188
- if (!this.disposed && code !== 0 && code !== null) {
2277
+ if (this.disposed) return;
2278
+ if (code !== 0 && code !== null) {
2189
2279
  signalStartupFailure(new Error(`Exit code: ${code}`));
2190
2280
  this.log(`[ACP] Process exited: code=${code}, signal=${signal}`);
2191
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();
2192
2293
  }
2193
2294
  });
2194
2295
  const streams = nodeToWebStreams(this.process.stdin, this.process.stdout);
@@ -2343,12 +2444,14 @@ class AcpBackend {
2343
2444
  const maybeErr = error;
2344
2445
  if (startupFailure && error === startupFailure) return true;
2345
2446
  if (maybeErr.code === "ENOENT" || maybeErr.code === "EACCES" || maybeErr.code === "EPIPE") return true;
2447
+ if (maybeErr.code === "DISPOSED") return true;
2346
2448
  const msg = error.message.toLowerCase();
2347
2449
  if (msg.includes("api key") || msg.includes("not configured") || msg.includes("401") || msg.includes("403")) return true;
2348
2450
  return false;
2349
2451
  };
2350
2452
  await withRetry(
2351
2453
  async () => {
2454
+ if (this.disposed) throw Object.assign(new Error("Backend disposed during startup retry"), { code: "DISPOSED" });
2352
2455
  let timeoutHandle = null;
2353
2456
  try {
2354
2457
  const result = await Promise.race([
@@ -2399,6 +2502,7 @@ class AcpBackend {
2399
2502
  this.log(`[ACP] Creating new session...`);
2400
2503
  const sessionResponse = await withRetry(
2401
2504
  async () => {
2505
+ if (this.disposed) throw Object.assign(new Error("Backend disposed during startup retry"), { code: "DISPOSED" });
2402
2506
  let timeoutHandle = null;
2403
2507
  try {
2404
2508
  const result = await Promise.race([
@@ -2548,9 +2652,16 @@ class AcpBackend {
2548
2652
  handleThinkingUpdate(update, ctx);
2549
2653
  }
2550
2654
  emitIdleStatus() {
2655
+ const resolver = this.idleResolver;
2656
+ this.idleResolver = null;
2657
+ this.waitingForResponse = false;
2551
2658
  this.emit({ type: "status", status: "idle" });
2552
- if (this.idleResolver) {
2553
- this.idleResolver();
2659
+ if (resolver) {
2660
+ const newPromptInFlight = this.waitingForResponse;
2661
+ resolver();
2662
+ if (newPromptInFlight) {
2663
+ this.waitingForResponse = true;
2664
+ }
2554
2665
  }
2555
2666
  }
2556
2667
  async sendPrompt(sessionId, prompt) {
@@ -2604,9 +2715,14 @@ class AcpBackend {
2604
2715
  }
2605
2716
  async cancel(sessionId) {
2606
2717
  if (!this.connection || !this.acpSessionId) return;
2718
+ this.waitingForResponse = false;
2719
+ if (this.idleResolver) {
2720
+ this.idleResolver();
2721
+ this.idleResolver = null;
2722
+ }
2607
2723
  try {
2608
2724
  await this.connection.cancel({ sessionId: this.acpSessionId });
2609
- this.emit({ type: "status", status: "stopped", detail: "Cancelled by user" });
2725
+ this.emit({ type: "status", status: "cancelled", detail: "Cancelled by user" });
2610
2726
  } catch (error) {
2611
2727
  this.log("[ACP] Error cancelling:", error);
2612
2728
  }
@@ -2629,16 +2745,24 @@ class AcpBackend {
2629
2745
  }
2630
2746
  }
2631
2747
  if (this.process) {
2632
- this.process.kill("SIGTERM");
2748
+ try {
2749
+ this.process.kill("SIGTERM");
2750
+ } catch {
2751
+ }
2633
2752
  await new Promise((resolve) => {
2634
2753
  const timeout = setTimeout(() => {
2635
- if (this.process) this.process.kill("SIGKILL");
2754
+ try {
2755
+ if (this.process) this.process.kill("SIGKILL");
2756
+ } catch {
2757
+ }
2636
2758
  resolve();
2637
2759
  }, 1e3);
2638
- this.process?.once("exit", () => {
2760
+ const done = () => {
2639
2761
  clearTimeout(timeout);
2640
2762
  resolve();
2641
- });
2763
+ };
2764
+ this.process?.once("exit", done);
2765
+ this.process?.once("close", done);
2642
2766
  });
2643
2767
  this.process = null;
2644
2768
  }
@@ -2653,6 +2777,7 @@ class AcpBackend {
2653
2777
  for (const timeout of this.toolCallTimeouts.values()) clearTimeout(timeout);
2654
2778
  this.toolCallTimeouts.clear();
2655
2779
  this.toolCallStartTimes.clear();
2780
+ this.toolCallIdToNameMap.clear();
2656
2781
  }
2657
2782
  }
2658
2783
 
@@ -2718,6 +2843,7 @@ function bridgeAcpToSession(backend, sessionService, getMetadata, setMetadata, l
2718
2843
  let pendingText = "";
2719
2844
  let turnText = "";
2720
2845
  let flushTimer = null;
2846
+ let bridgeStopped = false;
2721
2847
  function flushText() {
2722
2848
  if (pendingText) {
2723
2849
  sessionService.pushMessage({
@@ -2732,6 +2858,7 @@ function bridgeAcpToSession(backend, sessionService, getMetadata, setMetadata, l
2732
2858
  }
2733
2859
  }
2734
2860
  backend.onMessage((msg) => {
2861
+ if (bridgeStopped) return;
2735
2862
  switch (msg.type) {
2736
2863
  case "model-output": {
2737
2864
  if (msg.textDelta) {
@@ -2763,6 +2890,7 @@ function bridgeAcpToSession(backend, sessionService, getMetadata, setMetadata, l
2763
2890
  setMetadata((m) => ({ ...m, lifecycleState: "running" }));
2764
2891
  } else if (msg.status === "error") {
2765
2892
  flushText();
2893
+ turnText = "";
2766
2894
  sessionService.pushMessage(
2767
2895
  { type: "message", message: `Agent process exited unexpectedly: ${msg.detail || "Unknown error"}` },
2768
2896
  "event"
@@ -2771,8 +2899,12 @@ function bridgeAcpToSession(backend, sessionService, getMetadata, setMetadata, l
2771
2899
  setMetadata((m) => ({ ...m, lifecycleState: "error" }));
2772
2900
  } else if (msg.status === "stopped") {
2773
2901
  flushText();
2902
+ turnText = "";
2774
2903
  sessionService.sendSessionEnd();
2775
2904
  setMetadata((m) => ({ ...m, lifecycleState: "stopped" }));
2905
+ } else if (msg.status === "cancelled") {
2906
+ flushText();
2907
+ turnText = "";
2776
2908
  }
2777
2909
  break;
2778
2910
  }
@@ -2853,6 +2985,14 @@ function bridgeAcpToSession(backend, sessionService, getMetadata, setMetadata, l
2853
2985
  }
2854
2986
  }
2855
2987
  });
2988
+ return () => {
2989
+ bridgeStopped = true;
2990
+ if (flushTimer) {
2991
+ clearTimeout(flushTimer);
2992
+ flushTimer = null;
2993
+ }
2994
+ pendingText = "";
2995
+ };
2856
2996
  }
2857
2997
  class HyphaPermissionHandler {
2858
2998
  constructor(shouldAutoAllow, log) {
@@ -2918,6 +3058,10 @@ class CodexMcpBackend {
2918
3058
  client;
2919
3059
  transport = null;
2920
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
2921
3065
  codexSessionId = null;
2922
3066
  conversationId = null;
2923
3067
  svampSessionId = null;
@@ -2969,7 +3113,10 @@ class CodexMcpBackend {
2969
3113
  }
2970
3114
  async sendPrompt(sessionId, prompt) {
2971
3115
  if (!this.connected) throw new Error("Codex not connected");
3116
+ this.turnCancelled = false;
3117
+ const myTurnId = ++this.turnId;
2972
3118
  this.emit({ type: "status", status: "running" });
3119
+ let hadError = false;
2973
3120
  try {
2974
3121
  let response;
2975
3122
  if (this.codexSessionId) {
@@ -2990,16 +3137,20 @@ class CodexMcpBackend {
2990
3137
  }
2991
3138
  }
2992
3139
  } catch (err) {
3140
+ hadError = true;
2993
3141
  this.log(`[Codex] Error in sendPrompt: ${err.message}`);
2994
3142
  this.emit({ type: "status", status: "error", detail: err.message });
2995
3143
  throw err;
2996
3144
  } finally {
2997
- this.emit({ type: "status", status: "idle" });
3145
+ if (!this.turnCancelled && !hadError && this.turnId === myTurnId) {
3146
+ this.emit({ type: "status", status: "idle" });
3147
+ }
2998
3148
  }
2999
3149
  }
3000
3150
  async cancel(_sessionId) {
3001
3151
  this.log("[Codex] Cancel requested");
3002
- this.emit({ type: "status", status: "idle" });
3152
+ this.turnCancelled = true;
3153
+ this.emit({ type: "status", status: "cancelled" });
3003
3154
  }
3004
3155
  async respondToPermission(requestId, approved) {
3005
3156
  const pending = this.pendingApprovals.get(requestId);
@@ -3194,8 +3345,8 @@ class CodexMcpBackend {
3194
3345
  this.emit({ type: "status", status: "running" });
3195
3346
  break;
3196
3347
  case "task_complete":
3348
+ break;
3197
3349
  case "turn_aborted":
3198
- this.emit({ type: "status", status: "idle" });
3199
3350
  break;
3200
3351
  case "agent_message": {
3201
3352
  const content = event.content;
@@ -3827,7 +3978,10 @@ class ProcessSupervisor {
3827
3978
  /** Start a stopped/failed process by id or name. */
3828
3979
  async start(idOrName) {
3829
3980
  const entry = this.require(idOrName);
3830
- 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
+ }
3831
3985
  entry.stopping = false;
3832
3986
  await this.startEntry(entry, false);
3833
3987
  }
@@ -3847,15 +4001,21 @@ class ProcessSupervisor {
3847
4001
  /** Restart a process (stop if running, then start again). */
3848
4002
  async restart(idOrName) {
3849
4003
  const entry = this.require(idOrName);
3850
- if (entry.child) {
3851
- entry.stopping = true;
3852
- this.clearTimers(entry);
3853
- await this.killChild(entry.child);
3854
- 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;
3855
4018
  }
3856
- entry.stopping = false;
3857
- entry.state.restartCount++;
3858
- await this.startEntry(entry, false);
3859
4019
  }
3860
4020
  /** Stop the process and remove it from supervision (deletes persisted spec). */
3861
4021
  async remove(idOrName) {
@@ -3989,7 +4149,9 @@ class ProcessSupervisor {
3989
4149
  }
3990
4150
  async persistSpec(spec) {
3991
4151
  const filePath = path.join(this.persistDir, `${spec.id}.json`);
3992
- 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);
3993
4155
  }
3994
4156
  async deleteSpec(id) {
3995
4157
  try {
@@ -4063,7 +4225,13 @@ class ProcessSupervisor {
4063
4225
  };
4064
4226
  child.stdout?.on("data", appendLog);
4065
4227
  child.stderr?.on("data", appendLog);
4066
- 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
+ });
4067
4235
  if (spec.probe) this.setupProbe(entry);
4068
4236
  if (spec.ttl !== void 0) this.setupTTL(entry);
4069
4237
  console.log(`[SUPERVISOR] Started '${spec.name}' pid=${child.pid}`);
@@ -4151,6 +4319,8 @@ class ProcessSupervisor {
4151
4319
  }
4152
4320
  }
4153
4321
  async triggerProbeRestart(entry) {
4322
+ if (entry.restarting) return;
4323
+ if (entry.stopping) return;
4154
4324
  console.warn(`[SUPERVISOR] Restarting '${entry.spec.name}' due to probe failures`);
4155
4325
  entry.state.consecutiveProbeFailures = 0;
4156
4326
  this.clearTimers(entry);
@@ -4175,6 +4345,7 @@ class ProcessSupervisor {
4175
4345
  console.log(`[SUPERVISOR] Process '${entry.spec.name}' TTL expired`);
4176
4346
  entry.state.status = "expired";
4177
4347
  entry.stopping = true;
4348
+ this.clearTimers(entry);
4178
4349
  const cleanup = async () => {
4179
4350
  if (entry.child) await this.killChild(entry.child);
4180
4351
  this.entries.delete(entry.spec.id);
@@ -4186,13 +4357,36 @@ class ProcessSupervisor {
4186
4357
  // ── Process kill helper ───────────────────────────────────────────────────
4187
4358
  killChild(child) {
4188
4359
  return new Promise((resolve) => {
4189
- 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
+ };
4190
4371
  child.once("exit", done);
4191
- child.kill("SIGTERM");
4192
- const forceKill = setTimeout(() => {
4193
- 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);
4194
4389
  }, 5e3);
4195
- child.once("exit", () => clearTimeout(forceKill));
4196
4390
  });
4197
4391
  }
4198
4392
  // ── Timer cleanup ─────────────────────────────────────────────────────────
@@ -4290,7 +4484,9 @@ function readSvampConfig(configPath) {
4290
4484
  function writeSvampConfig(configPath, config) {
4291
4485
  mkdirSync(dirname(configPath), { recursive: true });
4292
4486
  const content = JSON.stringify(config, null, 2);
4293
- writeFileSync(configPath, content);
4487
+ const tmpPath = configPath + ".tmp";
4488
+ writeFileSync(tmpPath, content);
4489
+ renameSync(tmpPath, configPath);
4294
4490
  return content;
4295
4491
  }
4296
4492
  function getRalphStateFilePath(directory, sessionId) {
@@ -4344,7 +4540,9 @@ started_at: "${state.started_at}"${lastIterLine}${contextModeLine}${originalResu
4344
4540
 
4345
4541
  ${state.task}
4346
4542
  `;
4347
- writeFileSync(filePath, content);
4543
+ const tmpPath = `${filePath}.tmp`;
4544
+ writeFileSync(tmpPath, content);
4545
+ renameSync(tmpPath, filePath);
4348
4546
  }
4349
4547
  function removeRalphState(filePath) {
4350
4548
  try {
@@ -4433,20 +4631,17 @@ function createSvampConfigChecker(directory, sessionId, getMetadata, setMetadata
4433
4631
  ralphSystemPrompt: ralphSysPrompt
4434
4632
  }]
4435
4633
  }));
4436
- sessionService.updateMetadata(getMetadata());
4437
4634
  sessionService.pushMessage(
4438
- { 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) },
4439
4636
  "event"
4440
4637
  );
4441
- 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)}..."`);
4442
4639
  onRalphLoopActivated?.();
4443
4640
  } else if (prevRalph.currentIteration !== ralphLoop.currentIteration || prevRalph.task !== ralphLoop.task) {
4444
4641
  setMetadata((m) => ({ ...m, ralphLoop }));
4445
- sessionService.updateMetadata(getMetadata());
4446
4642
  }
4447
4643
  } else if (prevRalph?.active) {
4448
4644
  setMetadata((m) => ({ ...m, ralphLoop: { active: false } }));
4449
- sessionService.updateMetadata(getMetadata());
4450
4645
  sessionService.pushMessage(
4451
4646
  { type: "message", message: `Ralph loop cancelled at iteration ${prevRalph.currentIteration}.` },
4452
4647
  "event"
@@ -4679,18 +4874,19 @@ function loadSessionIndex() {
4679
4874
  }
4680
4875
  }
4681
4876
  function saveSessionIndex(index) {
4682
- 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);
4683
4880
  }
4684
4881
  function saveSession(session) {
4685
4882
  const sessionDir = getSessionDir(session.directory, session.sessionId);
4686
4883
  if (!existsSync$1(sessionDir)) {
4687
4884
  mkdirSync(sessionDir, { recursive: true });
4688
4885
  }
4689
- writeFileSync(
4690
- getSessionFilePath(session.directory, session.sessionId),
4691
- JSON.stringify(session, null, 2),
4692
- "utf-8"
4693
- );
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);
4694
4890
  const index = loadSessionIndex();
4695
4891
  index[session.sessionId] = { directory: session.directory, createdAt: session.createdAt };
4696
4892
  saveSessionIndex(index);
@@ -4714,6 +4910,16 @@ function deletePersistedSession(sessionId) {
4714
4910
  if (existsSync$1(configFile)) unlinkSync(configFile);
4715
4911
  } catch {
4716
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
+ }
4717
4923
  const sessionDir = getSessionDir(entry.directory, sessionId);
4718
4924
  try {
4719
4925
  rmdirSync(sessionDir);
@@ -4779,7 +4985,9 @@ function createLogger() {
4779
4985
  }
4780
4986
  function writeDaemonStateFile(state) {
4781
4987
  ensureHomeDir();
4782
- 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);
4783
4991
  }
4784
4992
  function readDaemonStateFile() {
4785
4993
  try {
@@ -5022,6 +5230,7 @@ async function startDaemon(options) {
5022
5230
  if (agentName !== "claude" && (KNOWN_ACP_AGENTS[agentName] || KNOWN_MCP_AGENTS[agentName])) {
5023
5231
  return await spawnAgentSession(sessionId, directory, agentName, options2, resumeSessionId);
5024
5232
  }
5233
+ let stagedCredentials = null;
5025
5234
  try {
5026
5235
  let parseBashPermission2 = function(permission) {
5027
5236
  if (permission === "Bash") return;
@@ -5055,17 +5264,23 @@ async function startDaemon(options) {
5055
5264
  resolve2();
5056
5265
  return;
5057
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
+ };
5058
5275
  const timeout = setTimeout(() => {
5059
5276
  try {
5060
5277
  proc.kill("SIGKILL");
5061
5278
  } catch {
5062
5279
  }
5063
- resolve2();
5280
+ done();
5064
5281
  }, timeoutMs);
5065
- proc.on("exit", () => {
5066
- clearTimeout(timeout);
5067
- resolve2();
5068
- });
5282
+ const exitHandler = () => done();
5283
+ proc.on("exit", exitHandler);
5069
5284
  if (!proc.killed) {
5070
5285
  proc.kill(signal);
5071
5286
  }
@@ -5159,6 +5374,8 @@ async function startDaemon(options) {
5159
5374
  let userMessagePending = false;
5160
5375
  let turnInitiatedByUser = true;
5161
5376
  let isKillingClaude = false;
5377
+ let isRestartingClaude = false;
5378
+ let isSwitchingMode = false;
5162
5379
  let checkSvampConfig;
5163
5380
  let cleanupSvampConfig;
5164
5381
  const CLAUDE_PERMISSION_MODE_MAP = {
@@ -5166,7 +5383,6 @@ async function startDaemon(options) {
5166
5383
  };
5167
5384
  const toClaudePermissionMode = (mode) => CLAUDE_PERMISSION_MODE_MAP[mode] || mode;
5168
5385
  let isolationCleanupFiles = [];
5169
- let stagedCredentials = null;
5170
5386
  const spawnClaude = (initialMessage, meta) => {
5171
5387
  const effectiveMeta = { ...lastSpawnMeta, ...meta };
5172
5388
  let rawPermissionMode = effectiveMeta.permissionMode || agentConfig.default_permission_mode || currentPermissionMode;
@@ -5234,12 +5450,17 @@ async function startDaemon(options) {
5234
5450
  });
5235
5451
  claudeProcess = child;
5236
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
+ });
5237
5456
  child.on("error", (err) => {
5238
5457
  logger.log(`[Session ${sessionId}] Claude process error: ${err.message}`);
5239
5458
  sessionService.pushMessage(
5240
5459
  { type: "message", message: `Agent process exited unexpectedly: ${err.message}. Please ensure Claude Code CLI is installed.` },
5241
5460
  "event"
5242
5461
  );
5462
+ sessionWasProcessing = false;
5463
+ claudeProcess = null;
5243
5464
  signalProcessing(false);
5244
5465
  sessionService.sendSessionEnd();
5245
5466
  });
@@ -5351,7 +5572,7 @@ async function startDaemon(options) {
5351
5572
  }
5352
5573
  const textBlocks = assistantContent.filter((b) => b.type === "text").map((b) => b.text);
5353
5574
  if (textBlocks.length > 0) {
5354
- lastAssistantText = textBlocks.join("\n");
5575
+ lastAssistantText += textBlocks.join("\n");
5355
5576
  }
5356
5577
  }
5357
5578
  if (msg.type === "result") {
@@ -5389,9 +5610,12 @@ async function startDaemon(options) {
5389
5610
  turnInitiatedByUser = true;
5390
5611
  continue;
5391
5612
  }
5613
+ if (msg.session_id) {
5614
+ claudeResumeId = msg.session_id;
5615
+ }
5392
5616
  signalProcessing(false);
5393
5617
  sessionWasProcessing = false;
5394
- if (claudeResumeId) {
5618
+ if (claudeResumeId && !trackedSession.stopped) {
5395
5619
  saveSession({
5396
5620
  sessionId,
5397
5621
  directory,
@@ -5411,7 +5635,7 @@ async function startDaemon(options) {
5411
5635
  sessionService.pushMessage({ type: "session_event", message: taskInfo }, "session");
5412
5636
  }
5413
5637
  const queueLen = sessionMetadata.messageQueue?.length ?? 0;
5414
- if (queueLen > 0 && claudeResumeId) {
5638
+ if (queueLen > 0 && claudeResumeId && !trackedSession.stopped) {
5415
5639
  setTimeout(() => processMessageQueueRef?.(), 200);
5416
5640
  } else if (claudeResumeId) {
5417
5641
  const rlState = readRalphState(getRalphStateFilePath(directory, sessionId));
@@ -5435,6 +5659,7 @@ async function startDaemon(options) {
5435
5659
  logger.log(`[Session ${sessionId}] ${reason}`);
5436
5660
  sessionService.pushMessage({ type: "message", message: reason }, "event");
5437
5661
  if (isFreshMode && rlState.original_resume_id) {
5662
+ claudeResumeId = rlState.original_resume_id;
5438
5663
  (async () => {
5439
5664
  try {
5440
5665
  if (claudeProcess && claudeProcess.exitCode === null) {
@@ -5442,7 +5667,8 @@ async function startDaemon(options) {
5442
5667
  await killAndWaitForExit2(claudeProcess);
5443
5668
  isKillingClaude = false;
5444
5669
  }
5445
- claudeResumeId = rlState.original_resume_id;
5670
+ if (trackedSession.stopped) return;
5671
+ if (isRestartingClaude || isSwitchingMode) return;
5446
5672
  const progressPath = getRalphProgressFilePath(directory, sessionId);
5447
5673
  let resumeMessage;
5448
5674
  try {
@@ -5482,7 +5708,16 @@ The automated loop has finished. Review the progress above and let me know if yo
5482
5708
  if (isFreshMode && !rlState.original_resume_id && claudeResumeId) {
5483
5709
  updatedRlState.original_resume_id = claudeResumeId;
5484
5710
  }
5485
- 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
+ }
5486
5721
  const ralphLoop = {
5487
5722
  active: true,
5488
5723
  task: rlState.task,
@@ -5502,13 +5737,15 @@ The automated loop has finished. Review the progress above and let me know if yo
5502
5737
  const ralphSysPrompt = buildRalphSystemPrompt(updatedRlState, progressRelPath);
5503
5738
  const cooldownMs = Math.max(200, rlState.cooldown_seconds * 1e3);
5504
5739
  if (isFreshMode) {
5740
+ isKillingClaude = true;
5505
5741
  setTimeout(async () => {
5506
5742
  try {
5507
5743
  if (claudeProcess && claudeProcess.exitCode === null) {
5508
- isKillingClaude = true;
5509
5744
  await killAndWaitForExit2(claudeProcess);
5510
- isKillingClaude = false;
5511
5745
  }
5746
+ isKillingClaude = false;
5747
+ if (trackedSession.stopped) return;
5748
+ if (isRestartingClaude || isSwitchingMode) return;
5512
5749
  claudeResumeId = void 0;
5513
5750
  userMessagePending = true;
5514
5751
  turnInitiatedByUser = true;
@@ -5520,25 +5757,34 @@ The automated loop has finished. Review the progress above and let me know if yo
5520
5757
  } catch (err) {
5521
5758
  logger.log(`[Session ${sessionId}] Error in fresh Ralph iteration: ${err.message}`);
5522
5759
  isKillingClaude = false;
5760
+ sessionWasProcessing = false;
5523
5761
  signalProcessing(false);
5524
5762
  }
5525
5763
  }, cooldownMs);
5526
5764
  } else {
5527
5765
  setTimeout(() => {
5528
- userMessagePending = true;
5529
- turnInitiatedByUser = true;
5530
- sessionWasProcessing = true;
5531
- signalProcessing(true);
5532
- sessionService.pushMessage({ type: "message", message: buildIterationStatus(nextIteration, rlState.max_iterations, rlState.completion_promise) }, "event");
5533
- sessionService.pushMessage(rlState.task, "user");
5534
- if (claudeProcess && claudeProcess.exitCode === null) {
5535
- const stdinMsg = JSON.stringify({
5536
- type: "user",
5537
- message: { role: "user", content: prompt }
5538
- });
5539
- claudeProcess.stdin?.write(stdinMsg + "\n");
5540
- } else {
5541
- 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);
5542
5788
  }
5543
5789
  }, cooldownMs);
5544
5790
  }
@@ -5553,10 +5799,8 @@ The automated loop has finished. Review the progress above and let me know if yo
5553
5799
  }
5554
5800
  }
5555
5801
  sessionService.pushMessage(msg, "agent");
5556
- if (msg.session_id) {
5557
- claudeResumeId = msg.session_id;
5558
- }
5559
5802
  } else if (msg.type === "system" && msg.subtype === "init") {
5803
+ lastAssistantText = "";
5560
5804
  if (!userMessagePending) {
5561
5805
  turnInitiatedByUser = false;
5562
5806
  logger.log(`[Session ${sessionId}] SDK-initiated turn (likely stale task_notification)`);
@@ -5567,18 +5811,20 @@ The automated loop has finished. Review the progress above and let me know if yo
5567
5811
  claudeResumeId = msg.session_id;
5568
5812
  sessionMetadata = { ...sessionMetadata, claudeSessionId: msg.session_id };
5569
5813
  sessionService.updateMetadata(sessionMetadata);
5570
- saveSession({
5571
- sessionId,
5572
- directory,
5573
- claudeResumeId,
5574
- permissionMode: currentPermissionMode,
5575
- spawnMeta: lastSpawnMeta,
5576
- metadata: sessionMetadata,
5577
- createdAt: Date.now(),
5578
- machineId,
5579
- wasProcessing: sessionWasProcessing
5580
- });
5581
- 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
+ }
5582
5828
  if (isConversationClear) {
5583
5829
  logger.log(`[Session ${sessionId}] Conversation cleared (/clear) \u2014 new Claude session: ${msg.session_id}`);
5584
5830
  sessionService.clearMessages();
@@ -5604,6 +5850,19 @@ The automated loop has finished. Review the progress above and let me know if yo
5604
5850
  }
5605
5851
  }
5606
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
+ });
5607
5866
  let stderrBuffer = "";
5608
5867
  child.stderr?.on("data", (chunk) => {
5609
5868
  const text = chunk.toString();
@@ -5633,7 +5892,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5633
5892
  sessionService.updateMetadata(sessionMetadata);
5634
5893
  sessionWasProcessing = false;
5635
5894
  const queueLen = sessionMetadata.messageQueue?.length ?? 0;
5636
- if (queueLen > 0 && claudeResumeId) {
5895
+ if (queueLen > 0 && claudeResumeId && !trackedSession.stopped) {
5637
5896
  signalProcessing(false);
5638
5897
  setTimeout(() => processMessageQueueRef?.(), 200);
5639
5898
  } else {
@@ -5667,6 +5926,10 @@ The automated loop has finished. Review the progress above and let me know if yo
5667
5926
  };
5668
5927
  const restartClaudeHandler = async () => {
5669
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;
5670
5933
  try {
5671
5934
  if (claudeProcess && claudeProcess.exitCode === null) {
5672
5935
  isKillingClaude = true;
@@ -5675,6 +5938,9 @@ The automated loop has finished. Review the progress above and let me know if yo
5675
5938
  await killAndWaitForExit2(claudeProcess);
5676
5939
  isKillingClaude = false;
5677
5940
  }
5941
+ if (trackedSession?.stopped) {
5942
+ return { success: false, message: "Session was stopped during restart." };
5943
+ }
5678
5944
  if (claudeResumeId) {
5679
5945
  spawnClaude(void 0, { permissionMode: currentPermissionMode });
5680
5946
  logger.log(`[Session ${sessionId}] Claude respawned with --resume ${claudeResumeId}`);
@@ -5687,6 +5953,8 @@ The automated loop has finished. Review the progress above and let me know if yo
5687
5953
  isKillingClaude = false;
5688
5954
  logger.log(`[Session ${sessionId}] Restart failed: ${err.message}`);
5689
5955
  return { success: false, message: `Restart failed: ${err.message}` };
5956
+ } finally {
5957
+ isRestartingClaude = false;
5690
5958
  }
5691
5959
  };
5692
5960
  if (sessionMetadata.sharing?.enabled && isolationCapabilities.preferred) {
@@ -5705,6 +5973,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5705
5973
  { controlledByUser: false },
5706
5974
  {
5707
5975
  onUserMessage: (content, meta) => {
5976
+ if (trackedSession?.stopped) return;
5708
5977
  logger.log(`[Session ${sessionId}] User message received`);
5709
5978
  userMessagePending = true;
5710
5979
  turnInitiatedByUser = true;
@@ -5731,7 +6000,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5731
6000
  if (msgMeta) {
5732
6001
  lastSpawnMeta = { ...lastSpawnMeta, ...msgMeta };
5733
6002
  }
5734
- if (isKillingClaude) {
6003
+ if (isKillingClaude || isRestartingClaude || isSwitchingMode) {
5735
6004
  logger.log(`[Session ${sessionId}] Message received while restarting Claude, queuing to prevent loss`);
5736
6005
  const existingQueue = sessionMetadata.messageQueue || [];
5737
6006
  sessionMetadata = {
@@ -5785,6 +6054,19 @@ The automated loop has finished. Review the progress above and let me know if yo
5785
6054
  if (params.mode) {
5786
6055
  currentPermissionMode = toClaudePermissionMode(params.mode);
5787
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
+ }
5788
6070
  }
5789
6071
  if (params.allowTools && Array.isArray(params.allowTools)) {
5790
6072
  for (const tool of params.allowTools) {
@@ -5815,12 +6097,23 @@ The automated loop has finished. Review the progress above and let me know if yo
5815
6097
  },
5816
6098
  onSwitchMode: async (mode) => {
5817
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
+ }
5818
6104
  currentPermissionMode = mode;
5819
6105
  if (claudeProcess && claudeProcess.exitCode === null) {
6106
+ isSwitchingMode = true;
5820
6107
  isKillingClaude = true;
5821
- await killAndWaitForExit2(claudeProcess);
5822
- isKillingClaude = false;
5823
- 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
+ }
5824
6117
  }
5825
6118
  },
5826
6119
  onRestartClaude: restartClaudeHandler,
@@ -5841,11 +6134,15 @@ The automated loop has finished. Review the progress above and let me know if yo
5841
6134
  onMetadataUpdate: (newMeta) => {
5842
6135
  sessionMetadata = {
5843
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 } : {},
5844
6141
  ...sessionMetadata.summary && !newMeta.summary ? { summary: sessionMetadata.summary } : {},
5845
6142
  ...sessionMetadata.sessionLink && !newMeta.sessionLink ? { sessionLink: sessionMetadata.sessionLink } : {}
5846
6143
  };
5847
6144
  const queue = newMeta.messageQueue;
5848
- if (queue && queue.length > 0 && !sessionWasProcessing) {
6145
+ if (queue && queue.length > 0 && !sessionWasProcessing && !trackedSession.stopped) {
5849
6146
  setTimeout(() => {
5850
6147
  processMessageQueueRef?.();
5851
6148
  }, 200);
@@ -5882,7 +6179,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5882
6179
  },
5883
6180
  onReadFile: async (path) => {
5884
6181
  const resolvedPath = resolve(directory, path);
5885
- if (!resolvedPath.startsWith(resolve(directory))) {
6182
+ if (resolvedPath !== resolve(directory) && !resolvedPath.startsWith(resolve(directory) + "/")) {
5886
6183
  throw new Error("Path outside working directory");
5887
6184
  }
5888
6185
  const buffer = await fs.readFile(resolvedPath);
@@ -5890,7 +6187,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5890
6187
  },
5891
6188
  onWriteFile: async (path, content) => {
5892
6189
  const resolvedPath = resolve(directory, path);
5893
- if (!resolvedPath.startsWith(resolve(directory))) {
6190
+ if (resolvedPath !== resolve(directory) && !resolvedPath.startsWith(resolve(directory) + "/")) {
5894
6191
  throw new Error("Path outside working directory");
5895
6192
  }
5896
6193
  await fs.mkdir(dirname(resolvedPath), { recursive: true });
@@ -5898,7 +6195,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5898
6195
  },
5899
6196
  onListDirectory: async (path) => {
5900
6197
  const resolvedDir = resolve(directory, path || ".");
5901
- if (!resolvedDir.startsWith(resolve(directory))) {
6198
+ if (resolvedDir !== resolve(directory) && !resolvedDir.startsWith(resolve(directory) + "/")) {
5902
6199
  throw new Error("Path outside working directory");
5903
6200
  }
5904
6201
  const entries = await fs.readdir(resolvedDir, { withFileTypes: true });
@@ -5937,6 +6234,9 @@ The automated loop has finished. Review the progress above and let me know if yo
5937
6234
  }
5938
6235
  }
5939
6236
  const resolvedPath = resolve(directory, treePath);
6237
+ if (resolvedPath !== resolve(directory) && !resolvedPath.startsWith(resolve(directory) + "/")) {
6238
+ throw new Error("Path outside working directory");
6239
+ }
5940
6240
  const tree = await buildTree(resolvedPath, basename(resolvedPath), 0);
5941
6241
  return { success: !!tree, tree };
5942
6242
  }
@@ -5953,13 +6253,18 @@ The automated loop has finished. Review the progress above and let me know if yo
5953
6253
  },
5954
6254
  sessionService,
5955
6255
  logger,
5956
- () => setTimeout(() => processMessageQueueRef?.(), 200)
6256
+ () => {
6257
+ if (!trackedSession?.stopped) setTimeout(() => processMessageQueueRef?.(), 200);
6258
+ }
5957
6259
  );
5958
6260
  checkSvampConfig = svampConfig.check;
5959
6261
  cleanupSvampConfig = svampConfig.cleanup;
5960
6262
  const writeSvampConfigPatch = svampConfig.writeConfig;
5961
6263
  processMessageQueueRef = () => {
5962
6264
  if (sessionWasProcessing) return;
6265
+ if (trackedSession?.stopped) return;
6266
+ if (isKillingClaude) return;
6267
+ if (isRestartingClaude || isSwitchingMode) return;
5963
6268
  const queue = sessionMetadata.messageQueue;
5964
6269
  if (queue && queue.length > 0) {
5965
6270
  const next = queue[0];
@@ -5988,22 +6293,33 @@ The automated loop has finished. Review the progress above and let me know if yo
5988
6293
  isKillingClaude = true;
5989
6294
  await killAndWaitForExit2(claudeProcess);
5990
6295
  isKillingClaude = false;
6296
+ if (trackedSession?.stopped) return;
6297
+ if (isRestartingClaude || isSwitchingMode) return;
5991
6298
  claudeResumeId = void 0;
5992
6299
  spawnClaude(next.text, queueMeta);
5993
6300
  } catch (err) {
5994
6301
  logger.log(`[Session ${sessionId}] Error in fresh Ralph queue processing: ${err.message}`);
5995
6302
  isKillingClaude = false;
6303
+ sessionWasProcessing = false;
5996
6304
  signalProcessing(false);
5997
6305
  }
5998
6306
  })();
5999
- } else if (!claudeProcess || claudeProcess.exitCode !== null) {
6000
- spawnClaude(next.text, queueMeta);
6001
6307
  } else {
6002
- const stdinMsg = JSON.stringify({
6003
- type: "user",
6004
- message: { role: "user", content: next.text }
6005
- });
6006
- 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
+ }
6007
6323
  }
6008
6324
  }
6009
6325
  };
@@ -6022,7 +6338,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6022
6338
  return claudeProcess || void 0;
6023
6339
  }
6024
6340
  };
6025
- pidToTrackedSession.set(process.pid + Math.floor(Math.random() * 1e5), trackedSession);
6341
+ pidToTrackedSession.set(randomUUID$1(), trackedSession);
6026
6342
  sessionMetadata = { ...sessionMetadata, lifecycleState: "idle" };
6027
6343
  sessionService.updateMetadata(sessionMetadata);
6028
6344
  logger.log(`Session ${sessionId} registered on Hypha, waiting for first message to spawn Claude`);
@@ -6033,6 +6349,10 @@ The automated loop has finished. Review the progress above and let me know if yo
6033
6349
  };
6034
6350
  } catch (err) {
6035
6351
  logger.error(`Failed to spawn session: ${err?.message || err}`, err?.stack);
6352
+ if (stagedCredentials) {
6353
+ stagedCredentials.cleanup().catch(() => {
6354
+ });
6355
+ }
6036
6356
  return {
6037
6357
  type: "error",
6038
6358
  errorMessage: `Failed to register session service: ${err?.message || String(err)}`
@@ -6100,6 +6420,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6100
6420
  { controlledByUser: false },
6101
6421
  {
6102
6422
  onUserMessage: (content, meta) => {
6423
+ if (acpStopped) return;
6103
6424
  logger.log(`[${agentName} Session ${sessionId}] User message received`);
6104
6425
  let text;
6105
6426
  let msgMeta = meta;
@@ -6120,8 +6441,35 @@ The automated loop has finished. Review the progress above and let me know if yo
6120
6441
  if (msgMeta?.permissionMode) {
6121
6442
  currentPermissionMode = msgMeta.permissionMode;
6122
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);
6123
6466
  agentBackend.sendPrompt(sessionId, text).catch((err) => {
6124
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
+ }
6125
6473
  });
6126
6474
  },
6127
6475
  onAbort: () => {
@@ -6175,18 +6523,27 @@ The automated loop has finished. Review the progress above and let me know if yo
6175
6523
  onMetadataUpdate: (newMeta) => {
6176
6524
  sessionMetadata = {
6177
6525
  ...newMeta,
6526
+ // Daemon drives lifecycleState — don't let frontend overwrite with stale value
6527
+ lifecycleState: sessionMetadata.lifecycleState,
6178
6528
  ...sessionMetadata.summary && !newMeta.summary ? { summary: sessionMetadata.summary } : {},
6179
6529
  ...sessionMetadata.sessionLink && !newMeta.sessionLink ? { sessionLink: sessionMetadata.sessionLink } : {}
6180
6530
  };
6531
+ if (acpStopped) return;
6181
6532
  const queue = newMeta.messageQueue;
6182
6533
  if (queue && queue.length > 0 && sessionMetadata.lifecycleState === "idle") {
6183
6534
  const next = queue[0];
6184
6535
  const remaining = queue.slice(1);
6185
- sessionMetadata = { ...sessionMetadata, messageQueue: remaining.length > 0 ? remaining : void 0 };
6536
+ sessionMetadata = { ...sessionMetadata, messageQueue: remaining.length > 0 ? remaining : void 0, lifecycleState: "running" };
6186
6537
  sessionService.updateMetadata(sessionMetadata);
6187
6538
  logger.log(`[Session ${sessionId}] Processing queued message from metadata update: "${next.text.slice(0, 50)}..."`);
6539
+ sessionService.sendKeepAlive(true);
6188
6540
  agentBackend.sendPrompt(sessionId, next.text).catch((err) => {
6189
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
+ }
6190
6547
  });
6191
6548
  }
6192
6549
  },
@@ -6220,7 +6577,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6220
6577
  },
6221
6578
  onReadFile: async (path) => {
6222
6579
  const resolvedPath = resolve(directory, path);
6223
- if (!resolvedPath.startsWith(resolve(directory))) {
6580
+ if (resolvedPath !== resolve(directory) && !resolvedPath.startsWith(resolve(directory) + "/")) {
6224
6581
  throw new Error("Path outside working directory");
6225
6582
  }
6226
6583
  const buffer = await fs.readFile(resolvedPath);
@@ -6228,7 +6585,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6228
6585
  },
6229
6586
  onWriteFile: async (path, content) => {
6230
6587
  const resolvedPath = resolve(directory, path);
6231
- if (!resolvedPath.startsWith(resolve(directory))) {
6588
+ if (resolvedPath !== resolve(directory) && !resolvedPath.startsWith(resolve(directory) + "/")) {
6232
6589
  throw new Error("Path outside working directory");
6233
6590
  }
6234
6591
  await fs.mkdir(dirname(resolvedPath), { recursive: true });
@@ -6236,7 +6593,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6236
6593
  },
6237
6594
  onListDirectory: async (path) => {
6238
6595
  const resolvedDir = resolve(directory, path || ".");
6239
- if (!resolvedDir.startsWith(resolve(directory))) {
6596
+ if (resolvedDir !== resolve(directory) && !resolvedDir.startsWith(resolve(directory) + "/")) {
6240
6597
  throw new Error("Path outside working directory");
6241
6598
  }
6242
6599
  const entries = await fs.readdir(resolvedDir, { withFileTypes: true });
@@ -6275,12 +6632,16 @@ The automated loop has finished. Review the progress above and let me know if yo
6275
6632
  }
6276
6633
  }
6277
6634
  const resolvedPath = resolve(directory, treePath);
6635
+ if (resolvedPath !== resolve(directory) && !resolvedPath.startsWith(resolve(directory) + "/")) {
6636
+ throw new Error("Path outside working directory");
6637
+ }
6278
6638
  const tree = await buildTree(resolvedPath, basename(resolvedPath), 0);
6279
6639
  return { success: !!tree, tree };
6280
6640
  }
6281
6641
  },
6282
6642
  { messagesDir: getSessionDir(directory, sessionId) }
6283
6643
  );
6644
+ let insideOnTurnEnd = false;
6284
6645
  const svampConfigChecker = createSvampConfigChecker(
6285
6646
  directory,
6286
6647
  sessionId,
@@ -6292,6 +6653,8 @@ The automated loop has finished. Review the progress above and let me know if yo
6292
6653
  sessionService,
6293
6654
  logger,
6294
6655
  () => {
6656
+ if (acpStopped) return;
6657
+ if (insideOnTurnEnd) return;
6295
6658
  const queue = sessionMetadata.messageQueue;
6296
6659
  if (queue && queue.length > 0 && sessionMetadata.lifecycleState === "idle") {
6297
6660
  const next = queue[0];
@@ -6302,6 +6665,11 @@ The automated loop has finished. Review the progress above and let me know if yo
6302
6665
  sessionService.sendKeepAlive(true);
6303
6666
  agentBackend.sendPrompt(sessionId, next.text).catch((err) => {
6304
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
+ }
6305
6673
  });
6306
6674
  }
6307
6675
  }
@@ -6355,71 +6723,129 @@ The automated loop has finished. Review the progress above and let me know if yo
6355
6723
  isolationConfig: agentIsoConfig
6356
6724
  });
6357
6725
  }
6726
+ let acpStopped = false;
6727
+ let acpBackendReady = false;
6358
6728
  const onTurnEnd = (lastAssistantText) => {
6359
- checkSvampConfig?.();
6360
- const rlState = readRalphState(getRalphStateFilePath(directory, sessionId));
6361
- if (rlState) {
6362
- let promiseFulfilled = false;
6363
- if (rlState.completion_promise) {
6364
- const promiseMatch = lastAssistantText.match(/<promise>([\s\S]*?)<\/promise>/);
6365
- 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
+ }
6366
6825
  }
6367
- const maxReached = rlState.max_iterations > 0 && rlState.iteration >= rlState.max_iterations;
6368
- if (promiseFulfilled || maxReached) {
6369
- removeRalphState(getRalphStateFilePath(directory, sessionId));
6370
- sessionMetadata = { ...sessionMetadata, ralphLoop: { active: false }, lifecycleState: "idle" };
6371
- sessionService.updateMetadata(sessionMetadata);
6372
- 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.`;
6373
- logger.log(`[${agentName} Session ${sessionId}] ${reason}`);
6374
- sessionService.pushMessage({ type: "message", message: reason }, "event");
6375
- } else {
6376
- const nextIteration = rlState.iteration + 1;
6377
- const iterationTimestamp = (/* @__PURE__ */ new Date()).toISOString();
6378
- writeRalphState(getRalphStateFilePath(directory, sessionId), { ...rlState, iteration: nextIteration, last_iteration_at: iterationTimestamp });
6379
- const ralphLoop = {
6380
- active: true,
6381
- task: rlState.task,
6382
- completionPromise: rlState.completion_promise ?? "none",
6383
- maxIterations: rlState.max_iterations,
6384
- currentIteration: nextIteration,
6385
- startedAt: rlState.started_at,
6386
- cooldownSeconds: rlState.cooldown_seconds,
6387
- contextMode: rlState.context_mode || "fresh",
6388
- lastIterationStartedAt: (/* @__PURE__ */ new Date()).toISOString()
6389
- };
6390
- 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" };
6391
6831
  sessionService.updateMetadata(sessionMetadata);
6392
- logger.log(`[${agentName} Session ${sessionId}] Ralph loop iteration ${nextIteration}${rlState.max_iterations > 0 ? `/${rlState.max_iterations}` : ""}: spawning`);
6393
- const updatedState = { ...rlState, iteration: nextIteration, context_mode: "continue" };
6394
- const prompt = buildRalphPrompt(rlState.task, updatedState);
6395
- const cooldownMs = Math.max(200, rlState.cooldown_seconds * 1e3);
6396
- setTimeout(() => {
6397
- sessionService.sendKeepAlive(true);
6398
- sessionMetadata = { ...sessionMetadata, lifecycleState: "running" };
6399
- sessionService.updateMetadata(sessionMetadata);
6400
- sessionService.pushMessage({ type: "message", message: buildIterationStatus(nextIteration, rlState.max_iterations, rlState.completion_promise) }, "event");
6401
- sessionService.pushMessage(rlState.task, "user");
6402
- agentBackend.sendPrompt(sessionId, prompt).catch((err) => {
6403
- logger.error(`[${agentName} Session ${sessionId}] Error in Ralph loop: ${err.message}`);
6404
- });
6405
- }, cooldownMs);
6406
- 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
+ });
6407
6843
  }
6408
- }
6409
- const queue = sessionMetadata.messageQueue;
6410
- if (queue && queue.length > 0) {
6411
- const next = queue[0];
6412
- const remaining = queue.slice(1);
6413
- sessionMetadata = { ...sessionMetadata, messageQueue: remaining.length > 0 ? remaining : void 0, lifecycleState: "running" };
6414
- sessionService.updateMetadata(sessionMetadata);
6415
- logger.log(`[Session ${sessionId}] Processing queued message: "${next.text.slice(0, 50)}..."`);
6416
- sessionService.pushMessage(next.displayText || next.text, "user");
6417
- agentBackend.sendPrompt(sessionId, next.text).catch((err) => {
6418
- logger.error(`[Session ${sessionId}] Error processing queued message: ${err.message}`);
6419
- });
6844
+ } finally {
6845
+ insideOnTurnEnd = false;
6420
6846
  }
6421
6847
  };
6422
- bridgeAcpToSession(
6848
+ const cleanupBridge = bridgeAcpToSession(
6423
6849
  agentBackend,
6424
6850
  sessionService,
6425
6851
  () => sessionMetadata,
@@ -6441,11 +6867,19 @@ The automated loop has finished. Review the progress above and let me know if yo
6441
6867
  resumeSessionId,
6442
6868
  get childProcess() {
6443
6869
  return agentBackend.getProcess?.() || void 0;
6870
+ },
6871
+ onStop: () => {
6872
+ acpStopped = true;
6873
+ cleanupBridge();
6874
+ permissionHandler.rejectAll("session stopped");
6875
+ agentBackend.dispose().catch(() => {
6876
+ });
6444
6877
  }
6445
6878
  };
6446
- pidToTrackedSession.set(process.pid + Math.floor(Math.random() * 1e5), trackedSession);
6879
+ pidToTrackedSession.set(randomUUID$1(), trackedSession);
6447
6880
  logger.log(`[Agent Session ${sessionId}] Starting ${agentName} backend...`);
6448
6881
  agentBackend.startSession().then(() => {
6882
+ acpBackendReady = true;
6449
6883
  logger.log(`[Agent Session ${sessionId}] ${agentName} backend started, waiting for first message`);
6450
6884
  }).catch((err) => {
6451
6885
  logger.error(`[Agent Session ${sessionId}] Failed to start ${agentName}:`, err);
@@ -6454,6 +6888,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6454
6888
  "event"
6455
6889
  );
6456
6890
  sessionService.sendSessionEnd();
6891
+ stopSession(sessionId);
6457
6892
  });
6458
6893
  return {
6459
6894
  type: "success",
@@ -6473,6 +6908,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6473
6908
  for (const [pid, session] of pidToTrackedSession) {
6474
6909
  if (session.svampSessionId === sessionId) {
6475
6910
  session.stopped = true;
6911
+ session.onStop?.();
6476
6912
  session.hyphaService?.disconnect().catch(() => {
6477
6913
  });
6478
6914
  if (session.childProcess) {
@@ -6484,12 +6920,14 @@ The automated loop has finished. Review the progress above and let me know if yo
6484
6920
  session.cleanupCredentials?.().catch(() => {
6485
6921
  });
6486
6922
  session.cleanupSvampConfig?.();
6923
+ artifactSync.cancelSync(sessionId);
6487
6924
  pidToTrackedSession.delete(pid);
6488
6925
  deletePersistedSession(sessionId);
6489
6926
  logger.log(`Session ${sessionId} stopped`);
6490
6927
  return true;
6491
6928
  }
6492
6929
  }
6930
+ artifactSync.cancelSync(sessionId);
6493
6931
  deletePersistedSession(sessionId);
6494
6932
  logger.log(`Session ${sessionId} not found in memory, cleaned up persisted state`);
6495
6933
  return false;
@@ -6626,14 +7064,20 @@ The automated loop has finished. Review the progress above and let me know if yo
6626
7064
  for (const sessionId of sessionsToAutoContinue) {
6627
7065
  setTimeout(async () => {
6628
7066
  try {
6629
- const svc = await server.getService(`svamp-session-${sessionId}`);
6630
- await svc.sendMessage(
6631
- JSON.stringify({
6632
- role: "user",
6633
- content: { type: "text", text: "The session was interrupted. Please continue." },
6634
- meta: { sentFrom: "svamp-daemon-auto-continue" }
6635
- })
6636
- );
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
+ ]);
6637
7081
  logger.log(`Auto-continued session ${sessionId}`);
6638
7082
  } catch (err) {
6639
7083
  logger.log(`Failed to auto-continue session ${sessionId}: ${err.message}`);
@@ -6647,7 +7091,10 @@ The automated loop has finished. Review the progress above and let me know if yo
6647
7091
  logger.log(`Resuming Ralph loop for ${sessionsToRalphResume.length} session(s)...`);
6648
7092
  for (const { sessionId, directory: sessDir } of sessionsToRalphResume) {
6649
7093
  try {
6650
- 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
+ ]);
6651
7098
  const rlState = readRalphState(getRalphStateFilePath(sessDir, sessionId));
6652
7099
  if (!rlState) continue;
6653
7100
  const initDelayMs = 2e3;
@@ -6668,17 +7115,20 @@ The automated loop has finished. Review the progress above and let me know if yo
6668
7115
  const progressRelPath = `.svamp/${sessionId}/ralph-progress.md`;
6669
7116
  const prompt = buildRalphPrompt(currentState.task, currentState);
6670
7117
  const ralphSysPrompt = buildRalphSystemPrompt(currentState, progressRelPath);
6671
- await svc.sendMessage(
6672
- JSON.stringify({
6673
- role: "user",
6674
- content: { type: "text", text: prompt },
6675
- meta: {
6676
- sentFrom: "svamp-daemon-ralph-resume",
6677
- appendSystemPrompt: ralphSysPrompt,
6678
- ...isFreshMode ? { ralphFreshContext: true } : {}
6679
- }
6680
- })
6681
- );
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
+ ]);
6682
7132
  logger.log(`Resumed Ralph loop for session ${sessionId} at iteration ${currentState.iteration} (${isFreshMode ? "fresh" : "continue"})`);
6683
7133
  } catch (err) {
6684
7134
  logger.log(`Failed to resume Ralph loop for session ${sessionId}: ${err.message}`);
@@ -6764,9 +7214,16 @@ The automated loop has finished. Review the progress above and let me know if yo
6764
7214
  process.kill(child.pid, 0);
6765
7215
  } catch {
6766
7216
  logger.log(`Removing stale session (child PID ${child.pid} dead): ${session.svampSessionId}`);
7217
+ session.stopped = true;
7218
+ session.onStop?.();
6767
7219
  session.hyphaService?.disconnect().catch(() => {
6768
7220
  });
7221
+ session.cleanupCredentials?.().catch(() => {
7222
+ });
7223
+ session.cleanupSvampConfig?.();
7224
+ if (session.svampSessionId) artifactSync.cancelSync(session.svampSessionId);
6769
7225
  pidToTrackedSession.delete(key);
7226
+ if (session.svampSessionId) deletePersistedSession(session.svampSessionId);
6770
7227
  }
6771
7228
  }
6772
7229
  }
@@ -6834,6 +7291,10 @@ The automated loop has finished. Review the progress above and let me know if yo
6834
7291
  clearInterval(heartbeatInterval);
6835
7292
  if (proxyTokenRefreshInterval) clearInterval(proxyTokenRefreshInterval);
6836
7293
  if (unhandledRejectionResetTimer) clearTimeout(unhandledRejectionResetTimer);
7294
+ for (const [, session] of pidToTrackedSession) {
7295
+ session.stopped = true;
7296
+ session.onStop?.();
7297
+ }
6837
7298
  machineService.updateDaemonState({
6838
7299
  ...initialDaemonState,
6839
7300
  status: "shutting-down",
@@ -6841,7 +7302,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6841
7302
  shutdownSource: source
6842
7303
  });
6843
7304
  await new Promise((r) => setTimeout(r, 200));
6844
- for (const [pid, session] of pidToTrackedSession) {
7305
+ for (const [, session] of pidToTrackedSession) {
6845
7306
  session.hyphaService?.disconnect().catch(() => {
6846
7307
  });
6847
7308
  if (session.childProcess) {
@@ -6858,14 +7319,21 @@ The automated loop has finished. Review the progress above and let me know if yo
6858
7319
  if (shouldMarkStopped) {
6859
7320
  try {
6860
7321
  const index = loadSessionIndex();
7322
+ let markedCount = 0;
6861
7323
  for (const [sessionId, entry] of Object.entries(index)) {
6862
- const filePath = getSessionFilePath(entry.directory, sessionId);
6863
- if (existsSync$1(filePath)) {
6864
- const data = JSON.parse(readFileSync$1(filePath, "utf-8"));
6865
- 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 {
6866
7334
  }
6867
7335
  }
6868
- logger.log("Marked all sessions as stopped (--cleanup mode)");
7336
+ logger.log(`Marked ${markedCount} session(s) as stopped (--cleanup mode)`);
6869
7337
  } catch {
6870
7338
  }
6871
7339
  } else {