svamp-cli 0.1.63 → 0.1.65

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");
@@ -352,21 +385,22 @@ async function registerMachineService(server, machineId, metadata, daemonState,
352
385
  const machineOwner = currentMetadata.sharing?.owner;
353
386
  const isSharedUser = callerEmail && machineOwner && callerEmail.toLowerCase() !== machineOwner.toLowerCase();
354
387
  if (isSharedUser) {
355
- const machineUser = currentMetadata.sharing?.allowedUsers?.find(
356
- (u) => u.email.toLowerCase() === callerEmail.toLowerCase()
357
- );
358
- const callerRole = machineUser?.role || "interact";
359
388
  const sharing = {
360
389
  enabled: true,
361
- owner: machineOwner,
390
+ owner: callerEmail,
391
+ // spawning user owns their session
362
392
  allowedUsers: [
363
- ...options.sharing?.allowedUsers || [],
393
+ // Machine owner gets admin access (can monitor/control sessions on their machine)
364
394
  {
365
- email: callerEmail,
366
- role: callerRole,
395
+ email: machineOwner,
396
+ role: "admin",
367
397
  addedAt: Date.now(),
368
398
  addedBy: "machine-auto"
369
- }
399
+ },
400
+ // Preserve any explicitly requested allowedUsers (e.g. additional collaborators)
401
+ ...(options.sharing?.allowedUsers || []).filter(
402
+ (u) => u.email.toLowerCase() !== machineOwner.toLowerCase()
403
+ )
370
404
  ]
371
405
  };
372
406
  options = { ...options, sharing };
@@ -386,22 +420,17 @@ async function registerMachineService(server, machineId, metadata, daemonState,
386
420
  ...options,
387
421
  securityContext: mergeSecurityContexts(machineCtx, options.securityContext)
388
422
  };
389
- if (machineCtx.role && options.sharing?.enabled) {
390
- const user = options.sharing.allowedUsers?.find(
391
- (u) => u.email.toLowerCase() === callerEmail.toLowerCase()
392
- );
393
- if (user && !user.role) {
394
- user.role = machineCtx.role;
395
- }
396
- }
397
423
  }
398
424
  }
425
+ if (options.injectPlatformGuidance === void 0 && currentMetadata.injectPlatformGuidance !== void 0) {
426
+ options = { ...options, injectPlatformGuidance: currentMetadata.injectPlatformGuidance };
427
+ }
399
428
  const result = await handlers.spawnSession({
400
429
  ...options,
401
430
  machineId
402
431
  });
403
432
  if (result.type === "success" && result.sessionId) {
404
- notifySubscribers({
433
+ notifyListeners({
405
434
  type: "new-session",
406
435
  sessionId: result.sessionId,
407
436
  machineId
@@ -414,7 +443,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
414
443
  stopSession: async (sessionId, context) => {
415
444
  authorizeRequest(context, currentMetadata.sharing, "admin");
416
445
  const result = handlers.stopSession(sessionId);
417
- notifySubscribers({
446
+ notifyListeners({
418
447
  type: "session-stopped",
419
448
  sessionId,
420
449
  machineId
@@ -423,7 +452,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
423
452
  },
424
453
  // Restart agent process in a session (machine-level fallback)
425
454
  restartSession: async (sessionId, context) => {
426
- authorizeRequest(context, currentMetadata.sharing, "interact");
455
+ authorizeRequest(context, currentMetadata.sharing, "admin");
427
456
  return await handlers.restartSession(sessionId);
428
457
  },
429
458
  // Stop the daemon
@@ -453,9 +482,10 @@ async function registerMachineService(server, machineId, metadata, daemonState,
453
482
  metadataVersion++;
454
483
  savePersistedMachineMetadata(metadata.svampHomeDir, {
455
484
  sharing: currentMetadata.sharing,
456
- securityContextConfig: currentMetadata.securityContextConfig
485
+ securityContextConfig: currentMetadata.securityContextConfig,
486
+ injectPlatformGuidance: currentMetadata.injectPlatformGuidance
457
487
  });
458
- notifySubscribers({
488
+ notifyListeners({
459
489
  type: "update-machine",
460
490
  machineId,
461
491
  metadata: { value: currentMetadata, version: metadataVersion }
@@ -485,7 +515,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
485
515
  }
486
516
  currentDaemonState = newState;
487
517
  daemonStateVersion++;
488
- notifySubscribers({
518
+ notifyListeners({
489
519
  type: "update-machine",
490
520
  machineId,
491
521
  daemonState: { value: currentDaemonState, version: daemonStateVersion }
@@ -513,9 +543,10 @@ async function registerMachineService(server, machineId, metadata, daemonState,
513
543
  metadataVersion++;
514
544
  savePersistedMachineMetadata(metadata.svampHomeDir, {
515
545
  sharing: currentMetadata.sharing,
516
- securityContextConfig: currentMetadata.securityContextConfig
546
+ securityContextConfig: currentMetadata.securityContextConfig,
547
+ injectPlatformGuidance: currentMetadata.injectPlatformGuidance
517
548
  });
518
- notifySubscribers({
549
+ notifyListeners({
519
550
  type: "update-machine",
520
551
  machineId,
521
552
  metadata: { value: currentMetadata, version: metadataVersion }
@@ -534,62 +565,21 @@ async function registerMachineService(server, machineId, metadata, daemonState,
534
565
  metadataVersion++;
535
566
  savePersistedMachineMetadata(metadata.svampHomeDir, {
536
567
  sharing: currentMetadata.sharing,
537
- securityContextConfig: currentMetadata.securityContextConfig
568
+ securityContextConfig: currentMetadata.securityContextConfig,
569
+ injectPlatformGuidance: currentMetadata.injectPlatformGuidance
538
570
  });
539
- notifySubscribers({
571
+ notifyListeners({
540
572
  type: "update-machine",
541
573
  machineId,
542
574
  metadata: { value: currentMetadata, version: metadataVersion }
543
575
  });
544
576
  return { success: true };
545
577
  },
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) {
578
+ // Register a listener for real-time updates (app calls this with _rintf callback)
579
+ registerListener: async (callback, context) => {
549
580
  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
- }
581
+ listeners.push(callback);
582
+ return { success: true, listenerId: listeners.length - 1 };
593
583
  },
594
584
  // Shell access
595
585
  bash: async (command, cwd, context) => {
@@ -615,7 +605,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
615
605
  const targetPath = resolve(path || homedir());
616
606
  const home = homedir();
617
607
  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)) {
608
+ if (!isOwner && targetPath !== home && !targetPath.startsWith(home + "/")) {
619
609
  throw new Error(`Access denied: path must be within ${home}`);
620
610
  }
621
611
  const showHidden = options?.showHidden ?? false;
@@ -656,7 +646,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
656
646
  },
657
647
  /** Add and start a new supervised process. */
658
648
  processAdd: async (params, context) => {
659
- authorizeRequest(context, currentMetadata.sharing, "interact");
649
+ authorizeRequest(context, currentMetadata.sharing, "admin");
660
650
  if (!handlers.supervisor) throw new Error("Process supervisor not available");
661
651
  return handlers.supervisor.add(params.spec);
662
652
  },
@@ -665,7 +655,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
665
655
  * Returns { action: 'created'|'updated'|'no-change', info: ProcessInfo }
666
656
  */
667
657
  processApply: async (params, context) => {
668
- authorizeRequest(context, currentMetadata.sharing, "interact");
658
+ authorizeRequest(context, currentMetadata.sharing, "admin");
669
659
  if (!handlers.supervisor) throw new Error("Process supervisor not available");
670
660
  return handlers.supervisor.apply(params.spec);
671
661
  },
@@ -674,7 +664,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
674
664
  * Returns updated ProcessInfo.
675
665
  */
676
666
  processUpdate: async (params, context) => {
677
- authorizeRequest(context, currentMetadata.sharing, "interact");
667
+ authorizeRequest(context, currentMetadata.sharing, "admin");
678
668
  if (!handlers.supervisor) throw new Error("Process supervisor not available");
679
669
  return handlers.supervisor.update(params.idOrName, params.spec);
680
670
  },
@@ -719,7 +709,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
719
709
  serviceList: async (context) => {
720
710
  authorizeRequest(context, currentMetadata.sharing, "view");
721
711
  try {
722
- const { listServiceGroups } = await import('./api-Cegey1dh.mjs');
712
+ const { listServiceGroups } = await import('./api-BRbsyqJ4.mjs');
723
713
  return await listServiceGroups();
724
714
  } catch (err) {
725
715
  return [];
@@ -728,13 +718,13 @@ async function registerMachineService(server, machineId, metadata, daemonState,
728
718
  /** Get full details of a single service group (includes backends + health). */
729
719
  serviceGet: async (params, context) => {
730
720
  authorizeRequest(context, currentMetadata.sharing, "view");
731
- const { getServiceGroup } = await import('./api-Cegey1dh.mjs');
721
+ const { getServiceGroup } = await import('./api-BRbsyqJ4.mjs');
732
722
  return getServiceGroup(params.name);
733
723
  },
734
724
  /** Delete a service group. */
735
725
  serviceDelete: async (params, context) => {
736
726
  authorizeRequest(context, currentMetadata.sharing, "admin");
737
- const { deleteServiceGroup } = await import('./api-Cegey1dh.mjs');
727
+ const { deleteServiceGroup } = await import('./api-BRbsyqJ4.mjs');
738
728
  return deleteServiceGroup(params.name);
739
729
  },
740
730
  // WISE voice — create ephemeral token for OpenAI Realtime API
@@ -745,19 +735,27 @@ async function registerMachineService(server, machineId, metadata, daemonState,
745
735
  return { success: false, error: "No OpenAI API key found. Set OPENAI_API_KEY or pass apiKey." };
746
736
  }
747
737
  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
- });
738
+ const wisCtrl = new AbortController();
739
+ const wisTimer = setTimeout(() => wisCtrl.abort(), 15e3);
740
+ let response;
741
+ try {
742
+ response = await fetch("https://api.openai.com/v1/realtime/client_secrets", {
743
+ method: "POST",
744
+ headers: {
745
+ "Authorization": `Bearer ${apiKey}`,
746
+ "Content-Type": "application/json"
747
+ },
748
+ body: JSON.stringify({
749
+ session: {
750
+ type: "realtime",
751
+ model: params.model || "gpt-realtime-mini"
752
+ }
753
+ }),
754
+ signal: wisCtrl.signal
755
+ });
756
+ } finally {
757
+ clearTimeout(wisTimer);
758
+ }
761
759
  if (!response.ok) {
762
760
  return { success: false, error: `OpenAI API error: ${response.status}` };
763
761
  }
@@ -776,7 +774,7 @@ async function registerMachineService(server, machineId, metadata, daemonState,
776
774
  updateMetadata: (newMetadata) => {
777
775
  currentMetadata = newMetadata;
778
776
  metadataVersion++;
779
- notifySubscribers({
777
+ notifyListeners({
780
778
  type: "update-machine",
781
779
  machineId,
782
780
  metadata: { value: currentMetadata, version: metadataVersion }
@@ -785,13 +783,17 @@ async function registerMachineService(server, machineId, metadata, daemonState,
785
783
  updateDaemonState: (newState) => {
786
784
  currentDaemonState = newState;
787
785
  daemonStateVersion++;
788
- notifySubscribers({
786
+ notifyListeners({
789
787
  type: "update-machine",
790
788
  machineId,
791
789
  daemonState: { value: currentDaemonState, version: daemonStateVersion }
792
790
  });
793
791
  },
794
792
  disconnect: async () => {
793
+ const toRemove = [...listeners];
794
+ for (const listener of toRemove) {
795
+ removeListener(listener, "disconnect");
796
+ }
795
797
  await server.unregisterService(serviceInfo.id);
796
798
  }
797
799
  };
@@ -809,17 +811,21 @@ function loadMessages(messagesDir, sessionId) {
809
811
  } catch {
810
812
  }
811
813
  }
812
- return messages.slice(-5e3);
814
+ return messages.slice(-1e3);
813
815
  } catch {
814
816
  return [];
815
817
  }
816
818
  }
817
819
  function appendMessage(messagesDir, sessionId, msg) {
818
- const filePath = join$1(messagesDir, "messages.jsonl");
819
- if (!existsSync(messagesDir)) {
820
- mkdirSync$1(messagesDir, { recursive: true });
820
+ try {
821
+ const filePath = join$1(messagesDir, "messages.jsonl");
822
+ if (!existsSync(messagesDir)) {
823
+ mkdirSync$1(messagesDir, { recursive: true });
824
+ }
825
+ appendFileSync(filePath, JSON.stringify(msg) + "\n");
826
+ } catch (err) {
827
+ console.error(`[HYPHA SESSION ${sessionId}] Failed to persist message: ${err?.message ?? err}`);
821
828
  }
822
- appendFileSync(filePath, JSON.stringify(msg) + "\n");
823
829
  }
824
830
  async function registerSessionService(server, sessionId, initialMetadata, initialAgentState, callbacks, options) {
825
831
  const messages = options?.messagesDir ? loadMessages(options.messagesDir) : [];
@@ -834,9 +840,36 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
834
840
  mode: "remote",
835
841
  time: Date.now()
836
842
  };
837
- const subscribers = /* @__PURE__ */ new Set();
838
- const notifySubscribers = (update) => {
839
- for (const push of subscribers) push(update);
843
+ const listeners = [];
844
+ const removeListener = (listener, reason) => {
845
+ const idx = listeners.indexOf(listener);
846
+ if (idx >= 0) {
847
+ listeners.splice(idx, 1);
848
+ console.log(`[HYPHA SESSION ${sessionId}] Listener removed (${reason}), remaining: ${listeners.length}`);
849
+ const rintfId = listener._rintf_service_id;
850
+ if (rintfId) {
851
+ server.unregisterService(rintfId).catch(() => {
852
+ });
853
+ }
854
+ }
855
+ };
856
+ const notifyListeners = (update) => {
857
+ const snapshot = [...listeners];
858
+ for (let i = snapshot.length - 1; i >= 0; i--) {
859
+ const listener = snapshot[i];
860
+ try {
861
+ const result = listener.onUpdate(update);
862
+ if (result && typeof result.catch === "function") {
863
+ result.catch((err) => {
864
+ console.error(`[HYPHA SESSION ${sessionId}] Async listener error:`, err);
865
+ removeListener(listener, "async error");
866
+ });
867
+ }
868
+ } catch (err) {
869
+ console.error(`[HYPHA SESSION ${sessionId}] Listener error:`, err);
870
+ removeListener(listener, "sync error");
871
+ }
872
+ }
840
873
  };
841
874
  const pushMessage = (content, role = "agent") => {
842
875
  let wrappedContent;
@@ -867,7 +900,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
867
900
  if (options?.messagesDir) {
868
901
  appendMessage(options.messagesDir, sessionId, msg);
869
902
  }
870
- notifySubscribers({
903
+ notifyListeners({
871
904
  type: "new-message",
872
905
  sessionId,
873
906
  message: msg
@@ -928,7 +961,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
928
961
  if (options?.messagesDir) {
929
962
  appendMessage(options.messagesDir, sessionId, msg);
930
963
  }
931
- notifySubscribers({
964
+ notifyListeners({
932
965
  type: "new-message",
933
966
  sessionId,
934
967
  message: msg
@@ -955,7 +988,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
955
988
  }
956
989
  metadata = newMetadata;
957
990
  metadataVersion++;
958
- notifySubscribers({
991
+ notifyListeners({
959
992
  type: "update-session",
960
993
  sessionId,
961
994
  metadata: { value: metadata, version: metadataVersion }
@@ -973,7 +1006,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
973
1006
  * Null values remove keys from the config.
974
1007
  */
975
1008
  updateConfig: async (patch, context) => {
976
- authorizeRequest(context, metadata.sharing, "interact");
1009
+ authorizeRequest(context, metadata.sharing, "admin");
977
1010
  callbacks.onUpdateConfig?.(patch);
978
1011
  return { success: true };
979
1012
  },
@@ -996,7 +1029,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
996
1029
  }
997
1030
  agentState = newState;
998
1031
  agentStateVersion++;
999
- notifySubscribers({
1032
+ notifyListeners({
1000
1033
  type: "update-session",
1001
1034
  sessionId,
1002
1035
  agentState: { value: agentState, version: agentStateVersion }
@@ -1019,7 +1052,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1019
1052
  return { success: true };
1020
1053
  },
1021
1054
  switchMode: async (mode, context) => {
1022
- authorizeRequest(context, metadata.sharing, "interact");
1055
+ authorizeRequest(context, metadata.sharing, "admin");
1023
1056
  callbacks.onSwitchMode(mode);
1024
1057
  return { success: true };
1025
1058
  },
@@ -1034,16 +1067,18 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1034
1067
  },
1035
1068
  // ── Activity ──
1036
1069
  keepAlive: async (thinking, mode, context) => {
1070
+ authorizeRequest(context, metadata.sharing, "interact");
1037
1071
  lastActivity = { active: true, thinking: thinking || false, mode: mode || "remote", time: Date.now() };
1038
- notifySubscribers({
1072
+ notifyListeners({
1039
1073
  type: "activity",
1040
1074
  sessionId,
1041
1075
  ...lastActivity
1042
1076
  });
1043
1077
  },
1044
1078
  sessionEnd: async (context) => {
1079
+ authorizeRequest(context, metadata.sharing, "interact");
1045
1080
  lastActivity = { active: false, thinking: false, mode: "remote", time: Date.now() };
1046
- notifySubscribers({
1081
+ notifyListeners({
1047
1082
  type: "activity",
1048
1083
  sessionId,
1049
1084
  ...lastActivity
@@ -1104,6 +1139,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1104
1139
  },
1105
1140
  /** Returns the caller's effective role (null if no access). Does not throw. */
1106
1141
  getEffectiveRole: async (context) => {
1142
+ authorizeRequest(context, metadata.sharing, "view");
1107
1143
  const role = getEffectiveRole(context, metadata.sharing);
1108
1144
  return { role };
1109
1145
  },
@@ -1117,7 +1153,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1117
1153
  }
1118
1154
  metadata = { ...metadata, sharing: newSharing };
1119
1155
  metadataVersion++;
1120
- notifySubscribers({
1156
+ notifyListeners({
1121
1157
  type: "update-session",
1122
1158
  sessionId,
1123
1159
  metadata: { value: metadata, version: metadataVersion }
@@ -1135,7 +1171,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1135
1171
  }
1136
1172
  metadata = { ...metadata, securityContext: newSecurityContext };
1137
1173
  metadataVersion++;
1138
- notifySubscribers({
1174
+ notifyListeners({
1139
1175
  type: "update-session",
1140
1176
  sessionId,
1141
1177
  metadata: { value: metadata, version: metadataVersion }
@@ -1150,67 +1186,69 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1150
1186
  }
1151
1187
  return await callbacks.onApplySystemPrompt(prompt);
1152
1188
  },
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) {
1189
+ // ── Listener Registration ──
1190
+ registerListener: async (callback, context) => {
1165
1191
  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?.();
1192
+ listeners.push(callback);
1193
+ const replayMessages = messages.slice(-50);
1194
+ const REPLAY_MESSAGE_TIMEOUT_MS = 1e4;
1195
+ for (const msg of replayMessages) {
1196
+ if (listeners.indexOf(callback) < 0) break;
1197
+ try {
1198
+ const result = callback.onUpdate({
1199
+ type: "new-message",
1200
+ sessionId,
1201
+ message: msg
1202
+ });
1203
+ if (result && typeof result.catch === "function") {
1204
+ try {
1205
+ await Promise.race([
1206
+ result,
1207
+ new Promise(
1208
+ (_, reject) => setTimeout(() => reject(new Error("Replay message timeout")), REPLAY_MESSAGE_TIMEOUT_MS)
1209
+ )
1210
+ ]);
1211
+ } catch (err) {
1212
+ console.error(`[HYPHA SESSION ${sessionId}] Replay listener error, removing:`, err?.message ?? err);
1213
+ removeListener(callback, "replay error");
1214
+ return { success: false, error: "Listener removed during replay" };
1215
+ }
1216
+ }
1217
+ } catch (err) {
1218
+ console.error(`[HYPHA SESSION ${sessionId}] Replay listener error, removing:`, err?.message ?? err);
1219
+ removeListener(callback, "replay error");
1220
+ return { success: false, error: "Listener removed during replay" };
1180
1221
  }
1181
- };
1182
- server.on("remote_client_disconnected", onDisconnect);
1183
- subscribers.add(push);
1184
- console.log(`[HYPHA SESSION ${sessionId}] subscribe() started (total: ${subscribers.size})`);
1222
+ }
1223
+ if (listeners.indexOf(callback) < 0) {
1224
+ return { success: false, error: "Listener was removed during replay" };
1225
+ }
1185
1226
  try {
1186
- yield {
1227
+ const result = callback.onUpdate({
1187
1228
  type: "update-session",
1188
1229
  sessionId,
1189
1230
  metadata: { value: metadata, version: metadataVersion },
1190
1231
  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 };
1232
+ });
1233
+ if (result && typeof result.catch === "function") {
1234
+ result.catch(() => {
1235
+ });
1195
1236
  }
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
- }
1237
+ } catch {
1238
+ }
1239
+ try {
1240
+ const result = callback.onUpdate({
1241
+ type: "activity",
1242
+ sessionId,
1243
+ ...lastActivity
1244
+ });
1245
+ if (result && typeof result.catch === "function") {
1246
+ result.catch(() => {
1247
+ });
1207
1248
  }
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})`);
1249
+ } catch {
1213
1250
  }
1251
+ return { success: true, listenerId: listeners.length - 1 };
1214
1252
  }
1215
1253
  },
1216
1254
  { overwrite: true }
@@ -1225,7 +1263,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1225
1263
  updateMetadata: (newMetadata) => {
1226
1264
  metadata = newMetadata;
1227
1265
  metadataVersion++;
1228
- notifySubscribers({
1266
+ notifyListeners({
1229
1267
  type: "update-session",
1230
1268
  sessionId,
1231
1269
  metadata: { value: metadata, version: metadataVersion }
@@ -1234,7 +1272,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1234
1272
  updateAgentState: (newAgentState) => {
1235
1273
  agentState = newAgentState;
1236
1274
  agentStateVersion++;
1237
- notifySubscribers({
1275
+ notifyListeners({
1238
1276
  type: "update-session",
1239
1277
  sessionId,
1240
1278
  agentState: { value: agentState, version: agentStateVersion }
@@ -1242,7 +1280,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1242
1280
  },
1243
1281
  sendKeepAlive: (thinking, mode) => {
1244
1282
  lastActivity = { active: true, thinking: thinking || false, mode: mode || "remote", time: Date.now() };
1245
- notifySubscribers({
1283
+ notifyListeners({
1246
1284
  type: "activity",
1247
1285
  sessionId,
1248
1286
  ...lastActivity
@@ -1250,7 +1288,7 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1250
1288
  },
1251
1289
  sendSessionEnd: () => {
1252
1290
  lastActivity = { active: false, thinking: false, mode: "remote", time: Date.now() };
1253
- notifySubscribers({
1291
+ notifyListeners({
1254
1292
  type: "activity",
1255
1293
  sessionId,
1256
1294
  ...lastActivity
@@ -1266,12 +1304,16 @@ async function registerSessionService(server, sessionId, initialMetadata, initia
1266
1304
  } catch {
1267
1305
  }
1268
1306
  }
1269
- notifySubscribers({
1307
+ notifyListeners({
1270
1308
  type: "clear-messages",
1271
1309
  sessionId
1272
1310
  });
1273
1311
  },
1274
1312
  disconnect: async () => {
1313
+ const toRemove = [...listeners];
1314
+ for (const listener of toRemove) {
1315
+ removeListener(listener, "disconnect");
1316
+ }
1275
1317
  await server.unregisterService(serviceInfo.id);
1276
1318
  }
1277
1319
  };
@@ -1406,6 +1448,19 @@ class SessionArtifactSync {
1406
1448
  this.log(`[ARTIFACT SYNC] Created new collection: ${this.collectionId}`);
1407
1449
  }
1408
1450
  }
1451
+ /**
1452
+ * fetch() with an AbortSignal-based timeout to prevent indefinite hangs
1453
+ * on slow/stalled presigned URL servers.
1454
+ */
1455
+ async fetchWithTimeout(url, options = {}, timeoutMs = 6e4) {
1456
+ const controller = new AbortController();
1457
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1458
+ try {
1459
+ return await fetch(url, { ...options, signal: controller.signal });
1460
+ } finally {
1461
+ clearTimeout(timer);
1462
+ }
1463
+ }
1409
1464
  /**
1410
1465
  * Upload a file to an artifact using the presigned URL pattern:
1411
1466
  * 1. put_file() returns a presigned upload URL
@@ -1420,11 +1475,11 @@ class SessionArtifactSync {
1420
1475
  if (!putUrl || typeof putUrl !== "string") {
1421
1476
  throw new Error(`put_file returned invalid URL for ${filePath}: ${putUrl}`);
1422
1477
  }
1423
- const resp = await fetch(putUrl, {
1478
+ const resp = await this.fetchWithTimeout(putUrl, {
1424
1479
  method: "PUT",
1425
1480
  body: content,
1426
1481
  headers: { "Content-Type": "application/octet-stream" }
1427
- });
1482
+ }, 12e4);
1428
1483
  if (!resp.ok) {
1429
1484
  throw new Error(`Upload failed for ${filePath}: ${resp.status} ${resp.statusText}`);
1430
1485
  }
@@ -1441,7 +1496,7 @@ class SessionArtifactSync {
1441
1496
  _rkwargs: true
1442
1497
  });
1443
1498
  if (!getUrl || typeof getUrl !== "string") return null;
1444
- const resp = await fetch(getUrl);
1499
+ const resp = await this.fetchWithTimeout(getUrl, {}, 6e4);
1445
1500
  if (!resp.ok) return null;
1446
1501
  return await resp.text();
1447
1502
  }
@@ -1459,16 +1514,27 @@ class SessionArtifactSync {
1459
1514
  const artifactAlias = `session-${sessionId}`;
1460
1515
  const sessionJsonPath = join$1(sessionsDir, "session.json");
1461
1516
  const messagesPath = join$1(sessionsDir, "messages.jsonl");
1462
- const sessionData = existsSync(sessionJsonPath) ? JSON.parse(readFileSync(sessionJsonPath, "utf-8")) : null;
1517
+ let sessionData = null;
1518
+ if (existsSync(sessionJsonPath)) {
1519
+ try {
1520
+ sessionData = JSON.parse(readFileSync(sessionJsonPath, "utf-8"));
1521
+ } catch {
1522
+ }
1523
+ }
1463
1524
  const messagesExist = existsSync(messagesPath);
1464
1525
  const messageCount = messagesExist ? readFileSync(messagesPath, "utf-8").split("\n").filter((l) => l.trim()).length : 0;
1465
1526
  let artifactId;
1527
+ let existingArtifactId = null;
1466
1528
  try {
1467
1529
  const existing = await this.artifactManager.read({
1468
1530
  artifact_id: artifactAlias,
1469
1531
  _rkwargs: true
1470
1532
  });
1471
- artifactId = existing.id;
1533
+ existingArtifactId = existing.id;
1534
+ } catch {
1535
+ }
1536
+ if (existingArtifactId) {
1537
+ artifactId = existingArtifactId;
1472
1538
  await this.artifactManager.edit({
1473
1539
  artifact_id: artifactId,
1474
1540
  manifest: {
@@ -1482,7 +1548,7 @@ class SessionArtifactSync {
1482
1548
  stage: true,
1483
1549
  _rkwargs: true
1484
1550
  });
1485
- } catch {
1551
+ } else {
1486
1552
  const artifact = await this.artifactManager.create({
1487
1553
  alias: artifactAlias,
1488
1554
  parent_id: this.collectionId,
@@ -1536,6 +1602,16 @@ class SessionArtifactSync {
1536
1602
  }, delayMs);
1537
1603
  this.syncTimers.set(sessionId, timer);
1538
1604
  }
1605
+ /**
1606
+ * Cancel any pending debounced sync for a session (e.g., when session is stopped).
1607
+ */
1608
+ cancelSync(sessionId) {
1609
+ const existing = this.syncTimers.get(sessionId);
1610
+ if (existing) {
1611
+ clearTimeout(existing);
1612
+ this.syncTimers.delete(sessionId);
1613
+ }
1614
+ }
1539
1615
  /**
1540
1616
  * Download a session from artifact store to local disk.
1541
1617
  */
@@ -1903,6 +1979,7 @@ function completeToolCall(toolCallId, toolKind, content, ctx) {
1903
1979
  const toolKindStr = typeof toolKind === "string" ? toolKind : "unknown";
1904
1980
  ctx.activeToolCalls.delete(toolCallId);
1905
1981
  ctx.toolCallStartTimes.delete(toolCallId);
1982
+ ctx.toolCallIdToNameMap.delete(toolCallId);
1906
1983
  const timeout = ctx.toolCallTimeouts.get(toolCallId);
1907
1984
  if (timeout) {
1908
1985
  clearTimeout(timeout);
@@ -1912,7 +1989,12 @@ function completeToolCall(toolCallId, toolKind, content, ctx) {
1912
1989
  ctx.emit({ type: "tool-result", toolName: toolKindStr, result: content, callId: toolCallId });
1913
1990
  if (ctx.activeToolCalls.size === 0) {
1914
1991
  ctx.clearIdleTimeout();
1915
- ctx.emitIdleStatus();
1992
+ const idleTimeoutMs = ctx.transport.getIdleTimeout?.() ?? DEFAULT_IDLE_TIMEOUT_MS;
1993
+ ctx.setIdleTimeout(() => {
1994
+ if (ctx.activeToolCalls.size === 0) {
1995
+ ctx.emitIdleStatus();
1996
+ }
1997
+ }, idleTimeoutMs);
1916
1998
  }
1917
1999
  }
1918
2000
  function failToolCall(toolCallId, status, toolKind, content, ctx) {
@@ -1921,6 +2003,7 @@ function failToolCall(toolCallId, status, toolKind, content, ctx) {
1921
2003
  const toolKindStr = typeof toolKind === "string" ? toolKind : "unknown";
1922
2004
  ctx.activeToolCalls.delete(toolCallId);
1923
2005
  ctx.toolCallStartTimes.delete(toolCallId);
2006
+ ctx.toolCallIdToNameMap.delete(toolCallId);
1924
2007
  const timeout = ctx.toolCallTimeouts.get(toolCallId);
1925
2008
  if (timeout) {
1926
2009
  clearTimeout(timeout);
@@ -1936,7 +2019,12 @@ function failToolCall(toolCallId, status, toolKind, content, ctx) {
1936
2019
  });
1937
2020
  if (ctx.activeToolCalls.size === 0) {
1938
2021
  ctx.clearIdleTimeout();
1939
- ctx.emitIdleStatus();
2022
+ const idleTimeoutMs = ctx.transport.getIdleTimeout?.() ?? DEFAULT_IDLE_TIMEOUT_MS;
2023
+ ctx.setIdleTimeout(() => {
2024
+ if (ctx.activeToolCalls.size === 0) {
2025
+ ctx.emitIdleStatus();
2026
+ }
2027
+ }, idleTimeoutMs);
1940
2028
  }
1941
2029
  }
1942
2030
  function handleToolCallUpdate(update, ctx) {
@@ -2185,10 +2273,22 @@ class AcpBackend {
2185
2273
  this.emit({ type: "status", status: "error", detail: err.message });
2186
2274
  });
2187
2275
  this.process.on("exit", (code, signal) => {
2188
- if (!this.disposed && code !== 0 && code !== null) {
2276
+ if (this.disposed) return;
2277
+ if (code !== 0 && code !== null) {
2189
2278
  signalStartupFailure(new Error(`Exit code: ${code}`));
2190
2279
  this.log(`[ACP] Process exited: code=${code}, signal=${signal}`);
2191
2280
  this.emit({ type: "status", status: "stopped", detail: `Exit code: ${code}` });
2281
+ } else if (code === null && this.waitingForResponse) {
2282
+ this.log(`[ACP] Process killed by signal: ${signal} (mid-turn)`);
2283
+ this.waitingForResponse = false;
2284
+ if (this.idleResolver) {
2285
+ this.idleResolver();
2286
+ this.idleResolver = null;
2287
+ }
2288
+ this.emit({ type: "status", status: "stopped", detail: `Process killed by signal: ${signal}` });
2289
+ } else if (code === 0 && this.waitingForResponse) {
2290
+ this.log(`[ACP] Process exited cleanly but response was pending \u2014 emitting idle`);
2291
+ this.emitIdleStatus();
2192
2292
  }
2193
2293
  });
2194
2294
  const streams = nodeToWebStreams(this.process.stdin, this.process.stdout);
@@ -2343,12 +2443,14 @@ class AcpBackend {
2343
2443
  const maybeErr = error;
2344
2444
  if (startupFailure && error === startupFailure) return true;
2345
2445
  if (maybeErr.code === "ENOENT" || maybeErr.code === "EACCES" || maybeErr.code === "EPIPE") return true;
2446
+ if (maybeErr.code === "DISPOSED") return true;
2346
2447
  const msg = error.message.toLowerCase();
2347
2448
  if (msg.includes("api key") || msg.includes("not configured") || msg.includes("401") || msg.includes("403")) return true;
2348
2449
  return false;
2349
2450
  };
2350
2451
  await withRetry(
2351
2452
  async () => {
2453
+ if (this.disposed) throw Object.assign(new Error("Backend disposed during startup retry"), { code: "DISPOSED" });
2352
2454
  let timeoutHandle = null;
2353
2455
  try {
2354
2456
  const result = await Promise.race([
@@ -2399,6 +2501,7 @@ class AcpBackend {
2399
2501
  this.log(`[ACP] Creating new session...`);
2400
2502
  const sessionResponse = await withRetry(
2401
2503
  async () => {
2504
+ if (this.disposed) throw Object.assign(new Error("Backend disposed during startup retry"), { code: "DISPOSED" });
2402
2505
  let timeoutHandle = null;
2403
2506
  try {
2404
2507
  const result = await Promise.race([
@@ -2548,9 +2651,16 @@ class AcpBackend {
2548
2651
  handleThinkingUpdate(update, ctx);
2549
2652
  }
2550
2653
  emitIdleStatus() {
2654
+ const resolver = this.idleResolver;
2655
+ this.idleResolver = null;
2656
+ this.waitingForResponse = false;
2551
2657
  this.emit({ type: "status", status: "idle" });
2552
- if (this.idleResolver) {
2553
- this.idleResolver();
2658
+ if (resolver) {
2659
+ const newPromptInFlight = this.waitingForResponse;
2660
+ resolver();
2661
+ if (newPromptInFlight) {
2662
+ this.waitingForResponse = true;
2663
+ }
2554
2664
  }
2555
2665
  }
2556
2666
  async sendPrompt(sessionId, prompt) {
@@ -2604,9 +2714,14 @@ class AcpBackend {
2604
2714
  }
2605
2715
  async cancel(sessionId) {
2606
2716
  if (!this.connection || !this.acpSessionId) return;
2717
+ this.waitingForResponse = false;
2718
+ if (this.idleResolver) {
2719
+ this.idleResolver();
2720
+ this.idleResolver = null;
2721
+ }
2607
2722
  try {
2608
2723
  await this.connection.cancel({ sessionId: this.acpSessionId });
2609
- this.emit({ type: "status", status: "stopped", detail: "Cancelled by user" });
2724
+ this.emit({ type: "status", status: "cancelled", detail: "Cancelled by user" });
2610
2725
  } catch (error) {
2611
2726
  this.log("[ACP] Error cancelling:", error);
2612
2727
  }
@@ -2629,16 +2744,24 @@ class AcpBackend {
2629
2744
  }
2630
2745
  }
2631
2746
  if (this.process) {
2632
- this.process.kill("SIGTERM");
2747
+ try {
2748
+ this.process.kill("SIGTERM");
2749
+ } catch {
2750
+ }
2633
2751
  await new Promise((resolve) => {
2634
2752
  const timeout = setTimeout(() => {
2635
- if (this.process) this.process.kill("SIGKILL");
2753
+ try {
2754
+ if (this.process) this.process.kill("SIGKILL");
2755
+ } catch {
2756
+ }
2636
2757
  resolve();
2637
2758
  }, 1e3);
2638
- this.process?.once("exit", () => {
2759
+ const done = () => {
2639
2760
  clearTimeout(timeout);
2640
2761
  resolve();
2641
- });
2762
+ };
2763
+ this.process?.once("exit", done);
2764
+ this.process?.once("close", done);
2642
2765
  });
2643
2766
  this.process = null;
2644
2767
  }
@@ -2653,6 +2776,7 @@ class AcpBackend {
2653
2776
  for (const timeout of this.toolCallTimeouts.values()) clearTimeout(timeout);
2654
2777
  this.toolCallTimeouts.clear();
2655
2778
  this.toolCallStartTimes.clear();
2779
+ this.toolCallIdToNameMap.clear();
2656
2780
  }
2657
2781
  }
2658
2782
 
@@ -2718,6 +2842,7 @@ function bridgeAcpToSession(backend, sessionService, getMetadata, setMetadata, l
2718
2842
  let pendingText = "";
2719
2843
  let turnText = "";
2720
2844
  let flushTimer = null;
2845
+ let bridgeStopped = false;
2721
2846
  function flushText() {
2722
2847
  if (pendingText) {
2723
2848
  sessionService.pushMessage({
@@ -2732,6 +2857,7 @@ function bridgeAcpToSession(backend, sessionService, getMetadata, setMetadata, l
2732
2857
  }
2733
2858
  }
2734
2859
  backend.onMessage((msg) => {
2860
+ if (bridgeStopped) return;
2735
2861
  switch (msg.type) {
2736
2862
  case "model-output": {
2737
2863
  if (msg.textDelta) {
@@ -2763,6 +2889,7 @@ function bridgeAcpToSession(backend, sessionService, getMetadata, setMetadata, l
2763
2889
  setMetadata((m) => ({ ...m, lifecycleState: "running" }));
2764
2890
  } else if (msg.status === "error") {
2765
2891
  flushText();
2892
+ turnText = "";
2766
2893
  sessionService.pushMessage(
2767
2894
  { type: "message", message: `Agent process exited unexpectedly: ${msg.detail || "Unknown error"}` },
2768
2895
  "event"
@@ -2771,8 +2898,12 @@ function bridgeAcpToSession(backend, sessionService, getMetadata, setMetadata, l
2771
2898
  setMetadata((m) => ({ ...m, lifecycleState: "error" }));
2772
2899
  } else if (msg.status === "stopped") {
2773
2900
  flushText();
2901
+ turnText = "";
2774
2902
  sessionService.sendSessionEnd();
2775
2903
  setMetadata((m) => ({ ...m, lifecycleState: "stopped" }));
2904
+ } else if (msg.status === "cancelled") {
2905
+ flushText();
2906
+ turnText = "";
2776
2907
  }
2777
2908
  break;
2778
2909
  }
@@ -2853,6 +2984,14 @@ function bridgeAcpToSession(backend, sessionService, getMetadata, setMetadata, l
2853
2984
  }
2854
2985
  }
2855
2986
  });
2987
+ return () => {
2988
+ bridgeStopped = true;
2989
+ if (flushTimer) {
2990
+ clearTimeout(flushTimer);
2991
+ flushTimer = null;
2992
+ }
2993
+ pendingText = "";
2994
+ };
2856
2995
  }
2857
2996
  class HyphaPermissionHandler {
2858
2997
  constructor(shouldAutoAllow, log) {
@@ -2918,6 +3057,10 @@ class CodexMcpBackend {
2918
3057
  client;
2919
3058
  transport = null;
2920
3059
  disposed = false;
3060
+ turnCancelled = false;
3061
+ // set by cancel() to suppress 'idle' in sendPrompt() finally
3062
+ turnId = 0;
3063
+ // monotonically increasing; each sendPrompt() captures its own ID
2921
3064
  codexSessionId = null;
2922
3065
  conversationId = null;
2923
3066
  svampSessionId = null;
@@ -2969,7 +3112,10 @@ class CodexMcpBackend {
2969
3112
  }
2970
3113
  async sendPrompt(sessionId, prompt) {
2971
3114
  if (!this.connected) throw new Error("Codex not connected");
3115
+ this.turnCancelled = false;
3116
+ const myTurnId = ++this.turnId;
2972
3117
  this.emit({ type: "status", status: "running" });
3118
+ let hadError = false;
2973
3119
  try {
2974
3120
  let response;
2975
3121
  if (this.codexSessionId) {
@@ -2990,16 +3136,20 @@ class CodexMcpBackend {
2990
3136
  }
2991
3137
  }
2992
3138
  } catch (err) {
3139
+ hadError = true;
2993
3140
  this.log(`[Codex] Error in sendPrompt: ${err.message}`);
2994
3141
  this.emit({ type: "status", status: "error", detail: err.message });
2995
3142
  throw err;
2996
3143
  } finally {
2997
- this.emit({ type: "status", status: "idle" });
3144
+ if (!this.turnCancelled && !hadError && this.turnId === myTurnId) {
3145
+ this.emit({ type: "status", status: "idle" });
3146
+ }
2998
3147
  }
2999
3148
  }
3000
3149
  async cancel(_sessionId) {
3001
3150
  this.log("[Codex] Cancel requested");
3002
- this.emit({ type: "status", status: "idle" });
3151
+ this.turnCancelled = true;
3152
+ this.emit({ type: "status", status: "cancelled" });
3003
3153
  }
3004
3154
  async respondToPermission(requestId, approved) {
3005
3155
  const pending = this.pendingApprovals.get(requestId);
@@ -3194,8 +3344,8 @@ class CodexMcpBackend {
3194
3344
  this.emit({ type: "status", status: "running" });
3195
3345
  break;
3196
3346
  case "task_complete":
3347
+ break;
3197
3348
  case "turn_aborted":
3198
- this.emit({ type: "status", status: "idle" });
3199
3349
  break;
3200
3350
  case "agent_message": {
3201
3351
  const content = event.content;
@@ -3603,13 +3753,17 @@ async function verifyNonoIsolation(binaryPath) {
3603
3753
  "-s",
3604
3754
  "--allow",
3605
3755
  workDir,
3606
- "--allow-cwd",
3756
+ // NOTE: Do NOT add --allow-cwd here. If the daemon's CWD happens to be
3757
+ // $HOME (common when started interactively), --allow-cwd would grant
3758
+ // access to $HOME, allowing the probe file write to succeed and making
3759
+ // verification incorrectly fail ("file leaked to host filesystem").
3760
+ // We already grant --allow workDir explicitly, so --allow-cwd is redundant.
3607
3761
  "--trust-override",
3608
3762
  "--",
3609
3763
  "sh",
3610
3764
  "-c",
3611
3765
  testScript
3612
- ], { timeout: 15e3 });
3766
+ ], { timeout: 15e3, cwd: workDir });
3613
3767
  return parseIsolationTestOutput(stdout, probeFile);
3614
3768
  } catch (e) {
3615
3769
  return { passed: false, error: e.message };
@@ -3827,7 +3981,10 @@ class ProcessSupervisor {
3827
3981
  /** Start a stopped/failed process by id or name. */
3828
3982
  async start(idOrName) {
3829
3983
  const entry = this.require(idOrName);
3830
- if (entry.child && !entry.stopping) throw new Error(`Process '${entry.spec.name}' is already running`);
3984
+ if (entry.child) {
3985
+ if (entry.stopping) throw new Error(`Process '${entry.spec.name}' is being stopped, try again shortly`);
3986
+ throw new Error(`Process '${entry.spec.name}' is already running`);
3987
+ }
3831
3988
  entry.stopping = false;
3832
3989
  await this.startEntry(entry, false);
3833
3990
  }
@@ -3847,15 +4004,21 @@ class ProcessSupervisor {
3847
4004
  /** Restart a process (stop if running, then start again). */
3848
4005
  async restart(idOrName) {
3849
4006
  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;
4007
+ if (entry.restarting) return;
4008
+ entry.restarting = true;
4009
+ try {
4010
+ if (entry.child) {
4011
+ entry.stopping = true;
4012
+ this.clearTimers(entry);
4013
+ await this.killChild(entry.child);
4014
+ entry.child = void 0;
4015
+ }
4016
+ entry.stopping = false;
4017
+ entry.state.restartCount++;
4018
+ await this.startEntry(entry, false);
4019
+ } finally {
4020
+ entry.restarting = false;
3855
4021
  }
3856
- entry.stopping = false;
3857
- entry.state.restartCount++;
3858
- await this.startEntry(entry, false);
3859
4022
  }
3860
4023
  /** Stop the process and remove it from supervision (deletes persisted spec). */
3861
4024
  async remove(idOrName) {
@@ -3989,7 +4152,9 @@ class ProcessSupervisor {
3989
4152
  }
3990
4153
  async persistSpec(spec) {
3991
4154
  const filePath = path.join(this.persistDir, `${spec.id}.json`);
3992
- await writeFile(filePath, JSON.stringify(spec, null, 2), "utf-8");
4155
+ const tmpPath = filePath + ".tmp";
4156
+ await writeFile(tmpPath, JSON.stringify(spec, null, 2), "utf-8");
4157
+ await rename(tmpPath, filePath);
3993
4158
  }
3994
4159
  async deleteSpec(id) {
3995
4160
  try {
@@ -4063,7 +4228,13 @@ class ProcessSupervisor {
4063
4228
  };
4064
4229
  child.stdout?.on("data", appendLog);
4065
4230
  child.stderr?.on("data", appendLog);
4066
- child.on("exit", (code, signal) => this.onProcessExit(entry, code, signal));
4231
+ child.on("error", (err) => {
4232
+ console.error(`[SUPERVISOR] Process '${spec.name}' error: ${err.message}`);
4233
+ });
4234
+ child.on("close", (code, signal) => {
4235
+ if (entry.child !== child) return;
4236
+ this.onProcessExit(entry, code, signal);
4237
+ });
4067
4238
  if (spec.probe) this.setupProbe(entry);
4068
4239
  if (spec.ttl !== void 0) this.setupTTL(entry);
4069
4240
  console.log(`[SUPERVISOR] Started '${spec.name}' pid=${child.pid}`);
@@ -4151,6 +4322,8 @@ class ProcessSupervisor {
4151
4322
  }
4152
4323
  }
4153
4324
  async triggerProbeRestart(entry) {
4325
+ if (entry.restarting) return;
4326
+ if (entry.stopping) return;
4154
4327
  console.warn(`[SUPERVISOR] Restarting '${entry.spec.name}' due to probe failures`);
4155
4328
  entry.state.consecutiveProbeFailures = 0;
4156
4329
  this.clearTimers(entry);
@@ -4175,6 +4348,7 @@ class ProcessSupervisor {
4175
4348
  console.log(`[SUPERVISOR] Process '${entry.spec.name}' TTL expired`);
4176
4349
  entry.state.status = "expired";
4177
4350
  entry.stopping = true;
4351
+ this.clearTimers(entry);
4178
4352
  const cleanup = async () => {
4179
4353
  if (entry.child) await this.killChild(entry.child);
4180
4354
  this.entries.delete(entry.spec.id);
@@ -4186,13 +4360,36 @@ class ProcessSupervisor {
4186
4360
  // ── Process kill helper ───────────────────────────────────────────────────
4187
4361
  killChild(child) {
4188
4362
  return new Promise((resolve) => {
4189
- const done = () => resolve();
4363
+ let resolved = false;
4364
+ let forceKillTimer;
4365
+ let hardDeadlineTimer;
4366
+ const done = () => {
4367
+ if (!resolved) {
4368
+ resolved = true;
4369
+ if (forceKillTimer) clearTimeout(forceKillTimer);
4370
+ if (hardDeadlineTimer) clearTimeout(hardDeadlineTimer);
4371
+ resolve();
4372
+ }
4373
+ };
4190
4374
  child.once("exit", done);
4191
- child.kill("SIGTERM");
4192
- const forceKill = setTimeout(() => {
4193
- child.kill("SIGKILL");
4375
+ child.once("close", done);
4376
+ try {
4377
+ child.kill("SIGTERM");
4378
+ } catch {
4379
+ }
4380
+ forceKillTimer = setTimeout(() => {
4381
+ try {
4382
+ child.kill("SIGKILL");
4383
+ } catch {
4384
+ }
4385
+ hardDeadlineTimer = setTimeout(() => {
4386
+ if (!resolved) {
4387
+ resolved = true;
4388
+ console.warn(`[SUPERVISOR] Process pid=${child.pid} did not exit after SIGKILL \u2014 forcing resolution`);
4389
+ resolve();
4390
+ }
4391
+ }, 2e3);
4194
4392
  }, 5e3);
4195
- child.once("exit", () => clearTimeout(forceKill));
4196
4393
  });
4197
4394
  }
4198
4395
  // ── Timer cleanup ─────────────────────────────────────────────────────────
@@ -4214,6 +4411,83 @@ class ProcessSupervisor {
4214
4411
 
4215
4412
  const __filename$1 = fileURLToPath(import.meta.url);
4216
4413
  const __dirname$1 = dirname(__filename$1);
4414
+ const CLAUDE_SKILLS_DIR = join(os__default.homedir(), ".claude", "skills");
4415
+ async function installSkillFromEndpoint(name, baseUrl) {
4416
+ const resp = await fetch(baseUrl, { signal: AbortSignal.timeout(15e3) });
4417
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} from ${baseUrl}`);
4418
+ const index = await resp.json();
4419
+ const files = index.files || [];
4420
+ if (files.length === 0) throw new Error(`Skill index at ${baseUrl} has no files`);
4421
+ const targetDir = join(CLAUDE_SKILLS_DIR, name);
4422
+ mkdirSync(targetDir, { recursive: true });
4423
+ for (const filePath of files) {
4424
+ if (!filePath) continue;
4425
+ const url = `${baseUrl}${filePath}`;
4426
+ const fileResp = await fetch(url, { signal: AbortSignal.timeout(3e4) });
4427
+ if (!fileResp.ok) throw new Error(`Failed to download ${filePath}: HTTP ${fileResp.status}`);
4428
+ const content = await fileResp.text();
4429
+ const localPath = join(targetDir, filePath);
4430
+ if (!localPath.startsWith(targetDir + "/")) continue;
4431
+ mkdirSync(dirname(localPath), { recursive: true });
4432
+ writeFileSync(localPath, content, "utf-8");
4433
+ }
4434
+ }
4435
+ async function installSkillFromMarketplace(name) {
4436
+ const BASE = `https://hypha.aicell.io/hypha-cloud/artifacts/${name}`;
4437
+ async function collectFiles(dir = "") {
4438
+ const url = dir ? `${BASE}/files/${dir}` : `${BASE}/files/`;
4439
+ const resp = await fetch(url, { signal: AbortSignal.timeout(15e3) });
4440
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} listing files`);
4441
+ const data = await resp.json();
4442
+ const items = Array.isArray(data) ? data : data.items || [];
4443
+ const result = [];
4444
+ for (const item of items) {
4445
+ const itemPath = dir ? `${dir}/${item.name}` : item.name;
4446
+ if (item.type === "directory") {
4447
+ result.push(...await collectFiles(itemPath));
4448
+ } else {
4449
+ result.push(itemPath);
4450
+ }
4451
+ }
4452
+ return result;
4453
+ }
4454
+ const files = await collectFiles();
4455
+ if (files.length === 0) throw new Error(`Skill ${name} has no files in marketplace`);
4456
+ const targetDir = join(CLAUDE_SKILLS_DIR, name);
4457
+ mkdirSync(targetDir, { recursive: true });
4458
+ for (const filePath of files) {
4459
+ const url = `${BASE}/files/${filePath}`;
4460
+ const resp = await fetch(url, { signal: AbortSignal.timeout(3e4) });
4461
+ if (!resp.ok) throw new Error(`Failed to download ${filePath}: HTTP ${resp.status}`);
4462
+ const content = await resp.text();
4463
+ const localPath = join(targetDir, filePath);
4464
+ if (!localPath.startsWith(targetDir + "/")) continue;
4465
+ mkdirSync(dirname(localPath), { recursive: true });
4466
+ writeFileSync(localPath, content, "utf-8");
4467
+ }
4468
+ }
4469
+ async function ensureAutoInstalledSkills(logger) {
4470
+ const tasks = [
4471
+ {
4472
+ name: "svamp",
4473
+ install: () => installSkillFromMarketplace("svamp")
4474
+ },
4475
+ {
4476
+ name: "hypha",
4477
+ install: () => installSkillFromEndpoint("hypha", "https://hypha.aicell.io/ws/agent-skills/")
4478
+ }
4479
+ ];
4480
+ for (const task of tasks) {
4481
+ const targetDir = join(CLAUDE_SKILLS_DIR, task.name);
4482
+ if (existsSync$1(targetDir)) continue;
4483
+ try {
4484
+ await task.install();
4485
+ logger.log(`[skills] Auto-installed: ${task.name}`);
4486
+ } catch (err) {
4487
+ logger.log(`[skills] Auto-install of "${task.name}" failed (non-fatal): ${err.message}`);
4488
+ }
4489
+ }
4490
+ }
4217
4491
  function loadEnvFile(path) {
4218
4492
  if (!existsSync$1(path)) return false;
4219
4493
  const lines = readFileSync$1(path, "utf-8").split("\n");
@@ -4290,7 +4564,9 @@ function readSvampConfig(configPath) {
4290
4564
  function writeSvampConfig(configPath, config) {
4291
4565
  mkdirSync(dirname(configPath), { recursive: true });
4292
4566
  const content = JSON.stringify(config, null, 2);
4293
- writeFileSync(configPath, content);
4567
+ const tmpPath = configPath + ".tmp";
4568
+ writeFileSync(tmpPath, content);
4569
+ renameSync(tmpPath, configPath);
4294
4570
  return content;
4295
4571
  }
4296
4572
  function getRalphStateFilePath(directory, sessionId) {
@@ -4344,7 +4620,9 @@ started_at: "${state.started_at}"${lastIterLine}${contextModeLine}${originalResu
4344
4620
 
4345
4621
  ${state.task}
4346
4622
  `;
4347
- writeFileSync(filePath, content);
4623
+ const tmpPath = `${filePath}.tmp`;
4624
+ writeFileSync(tmpPath, content);
4625
+ renameSync(tmpPath, filePath);
4348
4626
  }
4349
4627
  function removeRalphState(filePath) {
4350
4628
  try {
@@ -4433,20 +4711,17 @@ function createSvampConfigChecker(directory, sessionId, getMetadata, setMetadata
4433
4711
  ralphSystemPrompt: ralphSysPrompt
4434
4712
  }]
4435
4713
  }));
4436
- sessionService.updateMetadata(getMetadata());
4437
4714
  sessionService.pushMessage(
4438
- { type: "message", message: buildIterationStatus(1, state.max_iterations, state.completion_promise) },
4715
+ { type: "message", message: buildIterationStatus(state.iteration + 1, state.max_iterations, state.completion_promise) },
4439
4716
  "event"
4440
4717
  );
4441
- logger.log(`[svampConfig] Ralph loop started: "${state.task.slice(0, 50)}..."`);
4718
+ logger.log(`[svampConfig] Ralph loop started/resumed at iteration ${state.iteration + 1}: "${state.task.slice(0, 50)}..."`);
4442
4719
  onRalphLoopActivated?.();
4443
4720
  } else if (prevRalph.currentIteration !== ralphLoop.currentIteration || prevRalph.task !== ralphLoop.task) {
4444
4721
  setMetadata((m) => ({ ...m, ralphLoop }));
4445
- sessionService.updateMetadata(getMetadata());
4446
4722
  }
4447
4723
  } else if (prevRalph?.active) {
4448
4724
  setMetadata((m) => ({ ...m, ralphLoop: { active: false } }));
4449
- sessionService.updateMetadata(getMetadata());
4450
4725
  sessionService.pushMessage(
4451
4726
  { type: "message", message: `Ralph loop cancelled at iteration ${prevRalph.currentIteration}.` },
4452
4727
  "event"
@@ -4679,18 +4954,19 @@ function loadSessionIndex() {
4679
4954
  }
4680
4955
  }
4681
4956
  function saveSessionIndex(index) {
4682
- writeFileSync(SESSION_INDEX_FILE, JSON.stringify(index, null, 2), "utf-8");
4957
+ const tmp = SESSION_INDEX_FILE + ".tmp";
4958
+ writeFileSync(tmp, JSON.stringify(index, null, 2), "utf-8");
4959
+ renameSync(tmp, SESSION_INDEX_FILE);
4683
4960
  }
4684
4961
  function saveSession(session) {
4685
4962
  const sessionDir = getSessionDir(session.directory, session.sessionId);
4686
4963
  if (!existsSync$1(sessionDir)) {
4687
4964
  mkdirSync(sessionDir, { recursive: true });
4688
4965
  }
4689
- writeFileSync(
4690
- getSessionFilePath(session.directory, session.sessionId),
4691
- JSON.stringify(session, null, 2),
4692
- "utf-8"
4693
- );
4966
+ const filePath = getSessionFilePath(session.directory, session.sessionId);
4967
+ const tmpPath = filePath + ".tmp";
4968
+ writeFileSync(tmpPath, JSON.stringify(session, null, 2), "utf-8");
4969
+ renameSync(tmpPath, filePath);
4694
4970
  const index = loadSessionIndex();
4695
4971
  index[session.sessionId] = { directory: session.directory, createdAt: session.createdAt };
4696
4972
  saveSessionIndex(index);
@@ -4714,6 +4990,16 @@ function deletePersistedSession(sessionId) {
4714
4990
  if (existsSync$1(configFile)) unlinkSync(configFile);
4715
4991
  } catch {
4716
4992
  }
4993
+ const ralphStateFile = getRalphStateFilePath(entry.directory, sessionId);
4994
+ try {
4995
+ if (existsSync$1(ralphStateFile)) unlinkSync(ralphStateFile);
4996
+ } catch {
4997
+ }
4998
+ const ralphProgressFile = getRalphProgressFilePath(entry.directory, sessionId);
4999
+ try {
5000
+ if (existsSync$1(ralphProgressFile)) unlinkSync(ralphProgressFile);
5001
+ } catch {
5002
+ }
4717
5003
  const sessionDir = getSessionDir(entry.directory, sessionId);
4718
5004
  try {
4719
5005
  rmdirSync(sessionDir);
@@ -4779,7 +5065,9 @@ function createLogger() {
4779
5065
  }
4780
5066
  function writeDaemonStateFile(state) {
4781
5067
  ensureHomeDir();
4782
- writeFileSync(DAEMON_STATE_FILE, JSON.stringify(state, null, 2), "utf-8");
5068
+ const tmpPath = DAEMON_STATE_FILE + ".tmp";
5069
+ writeFileSync(tmpPath, JSON.stringify(state, null, 2), "utf-8");
5070
+ renameSync(tmpPath, DAEMON_STATE_FILE);
4783
5071
  }
4784
5072
  function readDaemonStateFile() {
4785
5073
  try {
@@ -4964,6 +5252,8 @@ async function startDaemon(options) {
4964
5252
  let server = null;
4965
5253
  const supervisor = new ProcessSupervisor(join(SVAMP_HOME, "processes"));
4966
5254
  await supervisor.init();
5255
+ ensureAutoInstalledSkills(logger).catch(() => {
5256
+ });
4967
5257
  try {
4968
5258
  logger.log("Connecting to Hypha server...");
4969
5259
  server = await connectToHypha({
@@ -5022,6 +5312,7 @@ async function startDaemon(options) {
5022
5312
  if (agentName !== "claude" && (KNOWN_ACP_AGENTS[agentName] || KNOWN_MCP_AGENTS[agentName])) {
5023
5313
  return await spawnAgentSession(sessionId, directory, agentName, options2, resumeSessionId);
5024
5314
  }
5315
+ let stagedCredentials = null;
5025
5316
  try {
5026
5317
  let parseBashPermission2 = function(permission) {
5027
5318
  if (permission === "Bash") return;
@@ -5055,17 +5346,23 @@ async function startDaemon(options) {
5055
5346
  resolve2();
5056
5347
  return;
5057
5348
  }
5349
+ let settled = false;
5350
+ const done = () => {
5351
+ if (settled) return;
5352
+ settled = true;
5353
+ clearTimeout(timeout);
5354
+ proc.off("exit", exitHandler);
5355
+ resolve2();
5356
+ };
5058
5357
  const timeout = setTimeout(() => {
5059
5358
  try {
5060
5359
  proc.kill("SIGKILL");
5061
5360
  } catch {
5062
5361
  }
5063
- resolve2();
5362
+ done();
5064
5363
  }, timeoutMs);
5065
- proc.on("exit", () => {
5066
- clearTimeout(timeout);
5067
- resolve2();
5068
- });
5364
+ const exitHandler = () => done();
5365
+ proc.on("exit", exitHandler);
5069
5366
  if (!proc.killed) {
5070
5367
  proc.kill(signal);
5071
5368
  }
@@ -5113,7 +5410,8 @@ async function startDaemon(options) {
5113
5410
  sharing: options2.sharing,
5114
5411
  securityContext: options2.securityContext,
5115
5412
  tags: options2.tags,
5116
- parentSessionId: options2.parentSessionId
5413
+ parentSessionId: options2.parentSessionId,
5414
+ ...options2.injectPlatformGuidance !== void 0 && { injectPlatformGuidance: options2.injectPlatformGuidance }
5117
5415
  };
5118
5416
  let claudeProcess = null;
5119
5417
  const allPersisted = loadPersistedSessions();
@@ -5128,6 +5426,9 @@ async function startDaemon(options) {
5128
5426
  const newState = processing ? "running" : "idle";
5129
5427
  if (sessionMetadata.lifecycleState !== newState) {
5130
5428
  sessionMetadata = { ...sessionMetadata, lifecycleState: newState };
5429
+ if (!processing) {
5430
+ sessionMetadata = { ...sessionMetadata, unread: true };
5431
+ }
5131
5432
  sessionService.updateMetadata(sessionMetadata);
5132
5433
  }
5133
5434
  };
@@ -5159,6 +5460,8 @@ async function startDaemon(options) {
5159
5460
  let userMessagePending = false;
5160
5461
  let turnInitiatedByUser = true;
5161
5462
  let isKillingClaude = false;
5463
+ let isRestartingClaude = false;
5464
+ let isSwitchingMode = false;
5162
5465
  let checkSvampConfig;
5163
5466
  let cleanupSvampConfig;
5164
5467
  const CLAUDE_PERMISSION_MODE_MAP = {
@@ -5166,7 +5469,6 @@ async function startDaemon(options) {
5166
5469
  };
5167
5470
  const toClaudePermissionMode = (mode) => CLAUDE_PERMISSION_MODE_MAP[mode] || mode;
5168
5471
  let isolationCleanupFiles = [];
5169
- let stagedCredentials = null;
5170
5472
  const spawnClaude = (initialMessage, meta) => {
5171
5473
  const effectiveMeta = { ...lastSpawnMeta, ...meta };
5172
5474
  let rawPermissionMode = effectiveMeta.permissionMode || agentConfig.default_permission_mode || currentPermissionMode;
@@ -5234,12 +5536,17 @@ async function startDaemon(options) {
5234
5536
  });
5235
5537
  claudeProcess = child;
5236
5538
  logger.log(`[Session ${sessionId}] Claude PID: ${child.pid}, stdin: ${!!child.stdin}, stdout: ${!!child.stdout}, stderr: ${!!child.stderr}`);
5539
+ child.stdin?.on("error", (err) => {
5540
+ logger.log(`[Session ${sessionId}] Claude stdin error: ${err.message}`);
5541
+ });
5237
5542
  child.on("error", (err) => {
5238
5543
  logger.log(`[Session ${sessionId}] Claude process error: ${err.message}`);
5239
5544
  sessionService.pushMessage(
5240
5545
  { type: "message", message: `Agent process exited unexpectedly: ${err.message}. Please ensure Claude Code CLI is installed.` },
5241
5546
  "event"
5242
5547
  );
5548
+ sessionWasProcessing = false;
5549
+ claudeProcess = null;
5243
5550
  signalProcessing(false);
5244
5551
  sessionService.sendSessionEnd();
5245
5552
  });
@@ -5351,7 +5658,7 @@ async function startDaemon(options) {
5351
5658
  }
5352
5659
  const textBlocks = assistantContent.filter((b) => b.type === "text").map((b) => b.text);
5353
5660
  if (textBlocks.length > 0) {
5354
- lastAssistantText = textBlocks.join("\n");
5661
+ lastAssistantText += textBlocks.join("\n");
5355
5662
  }
5356
5663
  }
5357
5664
  if (msg.type === "result") {
@@ -5389,9 +5696,12 @@ async function startDaemon(options) {
5389
5696
  turnInitiatedByUser = true;
5390
5697
  continue;
5391
5698
  }
5699
+ if (msg.session_id) {
5700
+ claudeResumeId = msg.session_id;
5701
+ }
5392
5702
  signalProcessing(false);
5393
5703
  sessionWasProcessing = false;
5394
- if (claudeResumeId) {
5704
+ if (claudeResumeId && !trackedSession.stopped) {
5395
5705
  saveSession({
5396
5706
  sessionId,
5397
5707
  directory,
@@ -5411,7 +5721,7 @@ async function startDaemon(options) {
5411
5721
  sessionService.pushMessage({ type: "session_event", message: taskInfo }, "session");
5412
5722
  }
5413
5723
  const queueLen = sessionMetadata.messageQueue?.length ?? 0;
5414
- if (queueLen > 0 && claudeResumeId) {
5724
+ if (queueLen > 0 && claudeResumeId && !trackedSession.stopped) {
5415
5725
  setTimeout(() => processMessageQueueRef?.(), 200);
5416
5726
  } else if (claudeResumeId) {
5417
5727
  const rlState = readRalphState(getRalphStateFilePath(directory, sessionId));
@@ -5435,6 +5745,7 @@ async function startDaemon(options) {
5435
5745
  logger.log(`[Session ${sessionId}] ${reason}`);
5436
5746
  sessionService.pushMessage({ type: "message", message: reason }, "event");
5437
5747
  if (isFreshMode && rlState.original_resume_id) {
5748
+ claudeResumeId = rlState.original_resume_id;
5438
5749
  (async () => {
5439
5750
  try {
5440
5751
  if (claudeProcess && claudeProcess.exitCode === null) {
@@ -5442,7 +5753,8 @@ async function startDaemon(options) {
5442
5753
  await killAndWaitForExit2(claudeProcess);
5443
5754
  isKillingClaude = false;
5444
5755
  }
5445
- claudeResumeId = rlState.original_resume_id;
5756
+ if (trackedSession.stopped) return;
5757
+ if (isRestartingClaude || isSwitchingMode) return;
5446
5758
  const progressPath = getRalphProgressFilePath(directory, sessionId);
5447
5759
  let resumeMessage;
5448
5760
  try {
@@ -5482,7 +5794,16 @@ The automated loop has finished. Review the progress above and let me know if yo
5482
5794
  if (isFreshMode && !rlState.original_resume_id && claudeResumeId) {
5483
5795
  updatedRlState.original_resume_id = claudeResumeId;
5484
5796
  }
5485
- writeRalphState(getRalphStateFilePath(directory, sessionId), updatedRlState);
5797
+ try {
5798
+ writeRalphState(getRalphStateFilePath(directory, sessionId), updatedRlState);
5799
+ } catch (writeErr) {
5800
+ logger.log(`[Session ${sessionId}] Ralph: failed to persist state (iter ${nextIteration}): ${writeErr.message} \u2014 stopping loop`);
5801
+ sessionService.pushMessage({ type: "message", message: `Ralph loop error: failed to persist iteration state \u2014 loop stopped. (${writeErr.message})` }, "event");
5802
+ removeRalphState(getRalphStateFilePath(directory, sessionId));
5803
+ sessionMetadata = { ...sessionMetadata, ralphLoop: { active: false }, lifecycleState: "idle" };
5804
+ sessionService.updateMetadata(sessionMetadata);
5805
+ break;
5806
+ }
5486
5807
  const ralphLoop = {
5487
5808
  active: true,
5488
5809
  task: rlState.task,
@@ -5502,13 +5823,15 @@ The automated loop has finished. Review the progress above and let me know if yo
5502
5823
  const ralphSysPrompt = buildRalphSystemPrompt(updatedRlState, progressRelPath);
5503
5824
  const cooldownMs = Math.max(200, rlState.cooldown_seconds * 1e3);
5504
5825
  if (isFreshMode) {
5826
+ isKillingClaude = true;
5505
5827
  setTimeout(async () => {
5506
5828
  try {
5507
5829
  if (claudeProcess && claudeProcess.exitCode === null) {
5508
- isKillingClaude = true;
5509
5830
  await killAndWaitForExit2(claudeProcess);
5510
- isKillingClaude = false;
5511
5831
  }
5832
+ isKillingClaude = false;
5833
+ if (trackedSession.stopped) return;
5834
+ if (isRestartingClaude || isSwitchingMode) return;
5512
5835
  claudeResumeId = void 0;
5513
5836
  userMessagePending = true;
5514
5837
  turnInitiatedByUser = true;
@@ -5520,25 +5843,34 @@ The automated loop has finished. Review the progress above and let me know if yo
5520
5843
  } catch (err) {
5521
5844
  logger.log(`[Session ${sessionId}] Error in fresh Ralph iteration: ${err.message}`);
5522
5845
  isKillingClaude = false;
5846
+ sessionWasProcessing = false;
5523
5847
  signalProcessing(false);
5524
5848
  }
5525
5849
  }, cooldownMs);
5526
5850
  } else {
5527
5851
  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 });
5852
+ if (trackedSession.stopped) return;
5853
+ if (isRestartingClaude || isSwitchingMode) return;
5854
+ try {
5855
+ userMessagePending = true;
5856
+ turnInitiatedByUser = true;
5857
+ sessionWasProcessing = true;
5858
+ signalProcessing(true);
5859
+ sessionService.pushMessage({ type: "message", message: buildIterationStatus(nextIteration, rlState.max_iterations, rlState.completion_promise) }, "event");
5860
+ sessionService.pushMessage(rlState.task, "user");
5861
+ if (claudeProcess && claudeProcess.exitCode === null) {
5862
+ const stdinMsg = JSON.stringify({
5863
+ type: "user",
5864
+ message: { role: "user", content: prompt }
5865
+ });
5866
+ claudeProcess.stdin?.write(stdinMsg + "\n");
5867
+ } else {
5868
+ spawnClaude(prompt, { appendSystemPrompt: ralphSysPrompt });
5869
+ }
5870
+ } catch (err) {
5871
+ logger.log(`[Session ${sessionId}] Error in continue Ralph iteration: ${err.message}`);
5872
+ sessionWasProcessing = false;
5873
+ signalProcessing(false);
5542
5874
  }
5543
5875
  }, cooldownMs);
5544
5876
  }
@@ -5553,10 +5885,8 @@ The automated loop has finished. Review the progress above and let me know if yo
5553
5885
  }
5554
5886
  }
5555
5887
  sessionService.pushMessage(msg, "agent");
5556
- if (msg.session_id) {
5557
- claudeResumeId = msg.session_id;
5558
- }
5559
5888
  } else if (msg.type === "system" && msg.subtype === "init") {
5889
+ lastAssistantText = "";
5560
5890
  if (!userMessagePending) {
5561
5891
  turnInitiatedByUser = false;
5562
5892
  logger.log(`[Session ${sessionId}] SDK-initiated turn (likely stale task_notification)`);
@@ -5567,18 +5897,20 @@ The automated loop has finished. Review the progress above and let me know if yo
5567
5897
  claudeResumeId = msg.session_id;
5568
5898
  sessionMetadata = { ...sessionMetadata, claudeSessionId: msg.session_id };
5569
5899
  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);
5900
+ if (!trackedSession.stopped) {
5901
+ saveSession({
5902
+ sessionId,
5903
+ directory,
5904
+ claudeResumeId,
5905
+ permissionMode: currentPermissionMode,
5906
+ spawnMeta: lastSpawnMeta,
5907
+ metadata: sessionMetadata,
5908
+ createdAt: Date.now(),
5909
+ machineId,
5910
+ wasProcessing: sessionWasProcessing
5911
+ });
5912
+ artifactSync.scheduleDebouncedSync(sessionId, getSessionDir(directory, sessionId), sessionMetadata, machineId);
5913
+ }
5582
5914
  if (isConversationClear) {
5583
5915
  logger.log(`[Session ${sessionId}] Conversation cleared (/clear) \u2014 new Claude session: ${msg.session_id}`);
5584
5916
  sessionService.clearMessages();
@@ -5604,6 +5936,19 @@ The automated loop has finished. Review the progress above and let me know if yo
5604
5936
  }
5605
5937
  }
5606
5938
  });
5939
+ child.stdout?.on("close", () => {
5940
+ const remaining = stdoutBuffer.trim();
5941
+ if (remaining) {
5942
+ logger.log(`[Session ${sessionId}] stdout close with remaining buffer (${remaining.length} chars): ${remaining.slice(0, 200)}`);
5943
+ try {
5944
+ const msg = JSON.parse(remaining);
5945
+ sessionService.pushMessage(msg, "agent");
5946
+ } catch {
5947
+ logger.log(`[Session ${sessionId}] Discarding non-JSON stdout remainder on close`);
5948
+ }
5949
+ stdoutBuffer = "";
5950
+ }
5951
+ });
5607
5952
  let stderrBuffer = "";
5608
5953
  child.stderr?.on("data", (chunk) => {
5609
5954
  const text = chunk.toString();
@@ -5633,7 +5978,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5633
5978
  sessionService.updateMetadata(sessionMetadata);
5634
5979
  sessionWasProcessing = false;
5635
5980
  const queueLen = sessionMetadata.messageQueue?.length ?? 0;
5636
- if (queueLen > 0 && claudeResumeId) {
5981
+ if (queueLen > 0 && claudeResumeId && !trackedSession.stopped) {
5637
5982
  signalProcessing(false);
5638
5983
  setTimeout(() => processMessageQueueRef?.(), 200);
5639
5984
  } else {
@@ -5667,6 +6012,10 @@ The automated loop has finished. Review the progress above and let me know if yo
5667
6012
  };
5668
6013
  const restartClaudeHandler = async () => {
5669
6014
  logger.log(`[Session ${sessionId}] Restart Claude requested`);
6015
+ if (isRestartingClaude || isSwitchingMode) {
6016
+ return { success: false, message: "Restart already in progress." };
6017
+ }
6018
+ isRestartingClaude = true;
5670
6019
  try {
5671
6020
  if (claudeProcess && claudeProcess.exitCode === null) {
5672
6021
  isKillingClaude = true;
@@ -5675,6 +6024,9 @@ The automated loop has finished. Review the progress above and let me know if yo
5675
6024
  await killAndWaitForExit2(claudeProcess);
5676
6025
  isKillingClaude = false;
5677
6026
  }
6027
+ if (trackedSession?.stopped) {
6028
+ return { success: false, message: "Session was stopped during restart." };
6029
+ }
5678
6030
  if (claudeResumeId) {
5679
6031
  spawnClaude(void 0, { permissionMode: currentPermissionMode });
5680
6032
  logger.log(`[Session ${sessionId}] Claude respawned with --resume ${claudeResumeId}`);
@@ -5687,9 +6039,11 @@ The automated loop has finished. Review the progress above and let me know if yo
5687
6039
  isKillingClaude = false;
5688
6040
  logger.log(`[Session ${sessionId}] Restart failed: ${err.message}`);
5689
6041
  return { success: false, message: `Restart failed: ${err.message}` };
6042
+ } finally {
6043
+ isRestartingClaude = false;
5690
6044
  }
5691
6045
  };
5692
- if (sessionMetadata.sharing?.enabled && isolationCapabilities.preferred) {
6046
+ if (sessionMetadata.sharing?.enabled) {
5693
6047
  try {
5694
6048
  stagedCredentials = await stageCredentialsForSharing(sessionId);
5695
6049
  logger.log(`[Session ${sessionId}] Credentials staged at ${stagedCredentials.homePath}`);
@@ -5705,6 +6059,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5705
6059
  { controlledByUser: false },
5706
6060
  {
5707
6061
  onUserMessage: (content, meta) => {
6062
+ if (trackedSession?.stopped) return;
5708
6063
  logger.log(`[Session ${sessionId}] User message received`);
5709
6064
  userMessagePending = true;
5710
6065
  turnInitiatedByUser = true;
@@ -5731,7 +6086,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5731
6086
  if (msgMeta) {
5732
6087
  lastSpawnMeta = { ...lastSpawnMeta, ...msgMeta };
5733
6088
  }
5734
- if (isKillingClaude) {
6089
+ if (isKillingClaude || isRestartingClaude || isSwitchingMode) {
5735
6090
  logger.log(`[Session ${sessionId}] Message received while restarting Claude, queuing to prevent loss`);
5736
6091
  const existingQueue = sessionMetadata.messageQueue || [];
5737
6092
  sessionMetadata = {
@@ -5785,6 +6140,19 @@ The automated loop has finished. Review the progress above and let me know if yo
5785
6140
  if (params.mode) {
5786
6141
  currentPermissionMode = toClaudePermissionMode(params.mode);
5787
6142
  logger.log(`[Session ${sessionId}] Permission mode changed to: ${currentPermissionMode}`);
6143
+ if (claudeResumeId && !trackedSession.stopped) {
6144
+ saveSession({
6145
+ sessionId,
6146
+ directory,
6147
+ claudeResumeId,
6148
+ permissionMode: currentPermissionMode,
6149
+ spawnMeta: lastSpawnMeta,
6150
+ metadata: sessionMetadata,
6151
+ createdAt: Date.now(),
6152
+ machineId,
6153
+ wasProcessing: sessionWasProcessing
6154
+ });
6155
+ }
5788
6156
  }
5789
6157
  if (params.allowTools && Array.isArray(params.allowTools)) {
5790
6158
  for (const tool of params.allowTools) {
@@ -5815,12 +6183,23 @@ The automated loop has finished. Review the progress above and let me know if yo
5815
6183
  },
5816
6184
  onSwitchMode: async (mode) => {
5817
6185
  logger.log(`[Session ${sessionId}] Switch mode: ${mode}`);
6186
+ if (isRestartingClaude || isSwitchingMode) {
6187
+ logger.log(`[Session ${sessionId}] Switch mode deferred \u2014 restart/switch already in progress`);
6188
+ return;
6189
+ }
5818
6190
  currentPermissionMode = mode;
5819
6191
  if (claudeProcess && claudeProcess.exitCode === null) {
6192
+ isSwitchingMode = true;
5820
6193
  isKillingClaude = true;
5821
- await killAndWaitForExit2(claudeProcess);
5822
- isKillingClaude = false;
5823
- spawnClaude(void 0, { permissionMode: mode });
6194
+ try {
6195
+ await killAndWaitForExit2(claudeProcess);
6196
+ isKillingClaude = false;
6197
+ if (trackedSession?.stopped) return;
6198
+ spawnClaude(void 0, { permissionMode: mode });
6199
+ } finally {
6200
+ isKillingClaude = false;
6201
+ isSwitchingMode = false;
6202
+ }
5824
6203
  }
5825
6204
  },
5826
6205
  onRestartClaude: restartClaudeHandler,
@@ -5841,11 +6220,15 @@ The automated loop has finished. Review the progress above and let me know if yo
5841
6220
  onMetadataUpdate: (newMeta) => {
5842
6221
  sessionMetadata = {
5843
6222
  ...newMeta,
6223
+ // Daemon drives lifecycleState — don't let frontend overwrite with stale value
6224
+ lifecycleState: sessionMetadata.lifecycleState,
6225
+ // Preserve claudeSessionId set by 'system init' (frontend may not have it)
6226
+ ...sessionMetadata.claudeSessionId ? { claudeSessionId: sessionMetadata.claudeSessionId } : {},
5844
6227
  ...sessionMetadata.summary && !newMeta.summary ? { summary: sessionMetadata.summary } : {},
5845
6228
  ...sessionMetadata.sessionLink && !newMeta.sessionLink ? { sessionLink: sessionMetadata.sessionLink } : {}
5846
6229
  };
5847
6230
  const queue = newMeta.messageQueue;
5848
- if (queue && queue.length > 0 && !sessionWasProcessing) {
6231
+ if (queue && queue.length > 0 && !sessionWasProcessing && !trackedSession.stopped) {
5849
6232
  setTimeout(() => {
5850
6233
  processMessageQueueRef?.();
5851
6234
  }, 200);
@@ -5882,7 +6265,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5882
6265
  },
5883
6266
  onReadFile: async (path) => {
5884
6267
  const resolvedPath = resolve(directory, path);
5885
- if (!resolvedPath.startsWith(resolve(directory))) {
6268
+ if (resolvedPath !== resolve(directory) && !resolvedPath.startsWith(resolve(directory) + "/")) {
5886
6269
  throw new Error("Path outside working directory");
5887
6270
  }
5888
6271
  const buffer = await fs.readFile(resolvedPath);
@@ -5890,7 +6273,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5890
6273
  },
5891
6274
  onWriteFile: async (path, content) => {
5892
6275
  const resolvedPath = resolve(directory, path);
5893
- if (!resolvedPath.startsWith(resolve(directory))) {
6276
+ if (resolvedPath !== resolve(directory) && !resolvedPath.startsWith(resolve(directory) + "/")) {
5894
6277
  throw new Error("Path outside working directory");
5895
6278
  }
5896
6279
  await fs.mkdir(dirname(resolvedPath), { recursive: true });
@@ -5898,7 +6281,7 @@ The automated loop has finished. Review the progress above and let me know if yo
5898
6281
  },
5899
6282
  onListDirectory: async (path) => {
5900
6283
  const resolvedDir = resolve(directory, path || ".");
5901
- if (!resolvedDir.startsWith(resolve(directory))) {
6284
+ if (resolvedDir !== resolve(directory) && !resolvedDir.startsWith(resolve(directory) + "/")) {
5902
6285
  throw new Error("Path outside working directory");
5903
6286
  }
5904
6287
  const entries = await fs.readdir(resolvedDir, { withFileTypes: true });
@@ -5937,6 +6320,9 @@ The automated loop has finished. Review the progress above and let me know if yo
5937
6320
  }
5938
6321
  }
5939
6322
  const resolvedPath = resolve(directory, treePath);
6323
+ if (resolvedPath !== resolve(directory) && !resolvedPath.startsWith(resolve(directory) + "/")) {
6324
+ throw new Error("Path outside working directory");
6325
+ }
5940
6326
  const tree = await buildTree(resolvedPath, basename(resolvedPath), 0);
5941
6327
  return { success: !!tree, tree };
5942
6328
  }
@@ -5953,13 +6339,18 @@ The automated loop has finished. Review the progress above and let me know if yo
5953
6339
  },
5954
6340
  sessionService,
5955
6341
  logger,
5956
- () => setTimeout(() => processMessageQueueRef?.(), 200)
6342
+ () => {
6343
+ if (!trackedSession?.stopped) setTimeout(() => processMessageQueueRef?.(), 200);
6344
+ }
5957
6345
  );
5958
6346
  checkSvampConfig = svampConfig.check;
5959
6347
  cleanupSvampConfig = svampConfig.cleanup;
5960
6348
  const writeSvampConfigPatch = svampConfig.writeConfig;
5961
6349
  processMessageQueueRef = () => {
5962
6350
  if (sessionWasProcessing) return;
6351
+ if (trackedSession?.stopped) return;
6352
+ if (isKillingClaude) return;
6353
+ if (isRestartingClaude || isSwitchingMode) return;
5963
6354
  const queue = sessionMetadata.messageQueue;
5964
6355
  if (queue && queue.length > 0) {
5965
6356
  const next = queue[0];
@@ -5988,22 +6379,33 @@ The automated loop has finished. Review the progress above and let me know if yo
5988
6379
  isKillingClaude = true;
5989
6380
  await killAndWaitForExit2(claudeProcess);
5990
6381
  isKillingClaude = false;
6382
+ if (trackedSession?.stopped) return;
6383
+ if (isRestartingClaude || isSwitchingMode) return;
5991
6384
  claudeResumeId = void 0;
5992
6385
  spawnClaude(next.text, queueMeta);
5993
6386
  } catch (err) {
5994
6387
  logger.log(`[Session ${sessionId}] Error in fresh Ralph queue processing: ${err.message}`);
5995
6388
  isKillingClaude = false;
6389
+ sessionWasProcessing = false;
5996
6390
  signalProcessing(false);
5997
6391
  }
5998
6392
  })();
5999
- } else if (!claudeProcess || claudeProcess.exitCode !== null) {
6000
- spawnClaude(next.text, queueMeta);
6001
6393
  } else {
6002
- const stdinMsg = JSON.stringify({
6003
- type: "user",
6004
- message: { role: "user", content: next.text }
6005
- });
6006
- claudeProcess.stdin?.write(stdinMsg + "\n");
6394
+ try {
6395
+ if (!claudeProcess || claudeProcess.exitCode !== null) {
6396
+ spawnClaude(next.text, queueMeta);
6397
+ } else {
6398
+ const stdinMsg = JSON.stringify({
6399
+ type: "user",
6400
+ message: { role: "user", content: next.text }
6401
+ });
6402
+ claudeProcess.stdin?.write(stdinMsg + "\n");
6403
+ }
6404
+ } catch (err) {
6405
+ logger.log(`[Session ${sessionId}] Error in processMessageQueue spawn: ${err.message}`);
6406
+ sessionWasProcessing = false;
6407
+ signalProcessing(false);
6408
+ }
6007
6409
  }
6008
6410
  }
6009
6411
  };
@@ -6022,7 +6424,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6022
6424
  return claudeProcess || void 0;
6023
6425
  }
6024
6426
  };
6025
- pidToTrackedSession.set(process.pid + Math.floor(Math.random() * 1e5), trackedSession);
6427
+ pidToTrackedSession.set(randomUUID$1(), trackedSession);
6026
6428
  sessionMetadata = { ...sessionMetadata, lifecycleState: "idle" };
6027
6429
  sessionService.updateMetadata(sessionMetadata);
6028
6430
  logger.log(`Session ${sessionId} registered on Hypha, waiting for first message to spawn Claude`);
@@ -6033,6 +6435,10 @@ The automated loop has finished. Review the progress above and let me know if yo
6033
6435
  };
6034
6436
  } catch (err) {
6035
6437
  logger.error(`Failed to spawn session: ${err?.message || err}`, err?.stack);
6438
+ if (stagedCredentials) {
6439
+ stagedCredentials.cleanup().catch(() => {
6440
+ });
6441
+ }
6036
6442
  return {
6037
6443
  type: "error",
6038
6444
  errorMessage: `Failed to register session service: ${err?.message || String(err)}`
@@ -6100,6 +6506,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6100
6506
  { controlledByUser: false },
6101
6507
  {
6102
6508
  onUserMessage: (content, meta) => {
6509
+ if (acpStopped) return;
6103
6510
  logger.log(`[${agentName} Session ${sessionId}] User message received`);
6104
6511
  let text;
6105
6512
  let msgMeta = meta;
@@ -6120,8 +6527,35 @@ The automated loop has finished. Review the progress above and let me know if yo
6120
6527
  if (msgMeta?.permissionMode) {
6121
6528
  currentPermissionMode = msgMeta.permissionMode;
6122
6529
  }
6530
+ if (!acpBackendReady) {
6531
+ logger.log(`[${agentName} Session ${sessionId}] Backend not ready \u2014 queuing message`);
6532
+ const existingQueue = sessionMetadata.messageQueue || [];
6533
+ sessionMetadata = {
6534
+ ...sessionMetadata,
6535
+ messageQueue: [...existingQueue, { id: randomUUID$1(), text, createdAt: Date.now() }]
6536
+ };
6537
+ sessionService.updateMetadata(sessionMetadata);
6538
+ return;
6539
+ }
6540
+ if (sessionMetadata.lifecycleState === "running") {
6541
+ logger.log(`[${agentName} Session ${sessionId}] Agent busy \u2014 queuing message`);
6542
+ const existingQueue = sessionMetadata.messageQueue || [];
6543
+ sessionMetadata = {
6544
+ ...sessionMetadata,
6545
+ messageQueue: [...existingQueue, { id: randomUUID$1(), text, createdAt: Date.now() }]
6546
+ };
6547
+ sessionService.updateMetadata(sessionMetadata);
6548
+ return;
6549
+ }
6550
+ sessionMetadata = { ...sessionMetadata, lifecycleState: "running" };
6551
+ sessionService.updateMetadata(sessionMetadata);
6123
6552
  agentBackend.sendPrompt(sessionId, text).catch((err) => {
6124
6553
  logger.error(`[${agentName} Session ${sessionId}] Error sending prompt:`, err);
6554
+ if (!acpStopped) {
6555
+ sessionMetadata = { ...sessionMetadata, lifecycleState: "idle" };
6556
+ sessionService.updateMetadata(sessionMetadata);
6557
+ sessionService.sendSessionEnd();
6558
+ }
6125
6559
  });
6126
6560
  },
6127
6561
  onAbort: () => {
@@ -6175,18 +6609,27 @@ The automated loop has finished. Review the progress above and let me know if yo
6175
6609
  onMetadataUpdate: (newMeta) => {
6176
6610
  sessionMetadata = {
6177
6611
  ...newMeta,
6612
+ // Daemon drives lifecycleState — don't let frontend overwrite with stale value
6613
+ lifecycleState: sessionMetadata.lifecycleState,
6178
6614
  ...sessionMetadata.summary && !newMeta.summary ? { summary: sessionMetadata.summary } : {},
6179
6615
  ...sessionMetadata.sessionLink && !newMeta.sessionLink ? { sessionLink: sessionMetadata.sessionLink } : {}
6180
6616
  };
6617
+ if (acpStopped) return;
6181
6618
  const queue = newMeta.messageQueue;
6182
6619
  if (queue && queue.length > 0 && sessionMetadata.lifecycleState === "idle") {
6183
6620
  const next = queue[0];
6184
6621
  const remaining = queue.slice(1);
6185
- sessionMetadata = { ...sessionMetadata, messageQueue: remaining.length > 0 ? remaining : void 0 };
6622
+ sessionMetadata = { ...sessionMetadata, messageQueue: remaining.length > 0 ? remaining : void 0, lifecycleState: "running" };
6186
6623
  sessionService.updateMetadata(sessionMetadata);
6187
6624
  logger.log(`[Session ${sessionId}] Processing queued message from metadata update: "${next.text.slice(0, 50)}..."`);
6625
+ sessionService.sendKeepAlive(true);
6188
6626
  agentBackend.sendPrompt(sessionId, next.text).catch((err) => {
6189
6627
  logger.error(`[Session ${sessionId}] Error processing queued message: ${err.message}`);
6628
+ if (!acpStopped) {
6629
+ sessionMetadata = { ...sessionMetadata, lifecycleState: "idle" };
6630
+ sessionService.updateMetadata(sessionMetadata);
6631
+ sessionService.sendSessionEnd();
6632
+ }
6190
6633
  });
6191
6634
  }
6192
6635
  },
@@ -6220,7 +6663,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6220
6663
  },
6221
6664
  onReadFile: async (path) => {
6222
6665
  const resolvedPath = resolve(directory, path);
6223
- if (!resolvedPath.startsWith(resolve(directory))) {
6666
+ if (resolvedPath !== resolve(directory) && !resolvedPath.startsWith(resolve(directory) + "/")) {
6224
6667
  throw new Error("Path outside working directory");
6225
6668
  }
6226
6669
  const buffer = await fs.readFile(resolvedPath);
@@ -6228,7 +6671,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6228
6671
  },
6229
6672
  onWriteFile: async (path, content) => {
6230
6673
  const resolvedPath = resolve(directory, path);
6231
- if (!resolvedPath.startsWith(resolve(directory))) {
6674
+ if (resolvedPath !== resolve(directory) && !resolvedPath.startsWith(resolve(directory) + "/")) {
6232
6675
  throw new Error("Path outside working directory");
6233
6676
  }
6234
6677
  await fs.mkdir(dirname(resolvedPath), { recursive: true });
@@ -6236,7 +6679,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6236
6679
  },
6237
6680
  onListDirectory: async (path) => {
6238
6681
  const resolvedDir = resolve(directory, path || ".");
6239
- if (!resolvedDir.startsWith(resolve(directory))) {
6682
+ if (resolvedDir !== resolve(directory) && !resolvedDir.startsWith(resolve(directory) + "/")) {
6240
6683
  throw new Error("Path outside working directory");
6241
6684
  }
6242
6685
  const entries = await fs.readdir(resolvedDir, { withFileTypes: true });
@@ -6275,12 +6718,16 @@ The automated loop has finished. Review the progress above and let me know if yo
6275
6718
  }
6276
6719
  }
6277
6720
  const resolvedPath = resolve(directory, treePath);
6721
+ if (resolvedPath !== resolve(directory) && !resolvedPath.startsWith(resolve(directory) + "/")) {
6722
+ throw new Error("Path outside working directory");
6723
+ }
6278
6724
  const tree = await buildTree(resolvedPath, basename(resolvedPath), 0);
6279
6725
  return { success: !!tree, tree };
6280
6726
  }
6281
6727
  },
6282
6728
  { messagesDir: getSessionDir(directory, sessionId) }
6283
6729
  );
6730
+ let insideOnTurnEnd = false;
6284
6731
  const svampConfigChecker = createSvampConfigChecker(
6285
6732
  directory,
6286
6733
  sessionId,
@@ -6292,6 +6739,8 @@ The automated loop has finished. Review the progress above and let me know if yo
6292
6739
  sessionService,
6293
6740
  logger,
6294
6741
  () => {
6742
+ if (acpStopped) return;
6743
+ if (insideOnTurnEnd) return;
6295
6744
  const queue = sessionMetadata.messageQueue;
6296
6745
  if (queue && queue.length > 0 && sessionMetadata.lifecycleState === "idle") {
6297
6746
  const next = queue[0];
@@ -6302,6 +6751,11 @@ The automated loop has finished. Review the progress above and let me know if yo
6302
6751
  sessionService.sendKeepAlive(true);
6303
6752
  agentBackend.sendPrompt(sessionId, next.text).catch((err) => {
6304
6753
  logger.error(`[Session ${sessionId}] Error processing queued message: ${err.message}`);
6754
+ if (!acpStopped) {
6755
+ sessionMetadata = { ...sessionMetadata, lifecycleState: "idle" };
6756
+ sessionService.updateMetadata(sessionMetadata);
6757
+ sessionService.sendSessionEnd();
6758
+ }
6305
6759
  });
6306
6760
  }
6307
6761
  }
@@ -6355,71 +6809,129 @@ The automated loop has finished. Review the progress above and let me know if yo
6355
6809
  isolationConfig: agentIsoConfig
6356
6810
  });
6357
6811
  }
6812
+ let acpStopped = false;
6813
+ let acpBackendReady = false;
6358
6814
  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);
6815
+ if (acpStopped) return;
6816
+ insideOnTurnEnd = true;
6817
+ try {
6818
+ checkSvampConfig?.();
6819
+ const rlState = readRalphState(getRalphStateFilePath(directory, sessionId));
6820
+ if (rlState) {
6821
+ let promiseFulfilled = false;
6822
+ if (rlState.completion_promise) {
6823
+ const promiseMatch = lastAssistantText.match(/<promise>([\s\S]*?)<\/promise>/);
6824
+ promiseFulfilled = !!(promiseMatch && promiseMatch[1].trim().replace(/\s+/g, " ") === rlState.completion_promise);
6825
+ }
6826
+ const maxReached = rlState.max_iterations > 0 && rlState.iteration >= rlState.max_iterations;
6827
+ if (promiseFulfilled || maxReached) {
6828
+ removeRalphState(getRalphStateFilePath(directory, sessionId));
6829
+ sessionMetadata = { ...sessionMetadata, ralphLoop: { active: false }, lifecycleState: "idle" };
6830
+ sessionService.updateMetadata(sessionMetadata);
6831
+ 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.`;
6832
+ logger.log(`[${agentName} Session ${sessionId}] ${reason}`);
6833
+ sessionService.pushMessage({ type: "message", message: reason }, "event");
6834
+ } else {
6835
+ const pendingQueue = sessionMetadata.messageQueue;
6836
+ if (pendingQueue && pendingQueue.length > 0) {
6837
+ const next = pendingQueue[0];
6838
+ const remaining = pendingQueue.slice(1);
6839
+ sessionMetadata = { ...sessionMetadata, messageQueue: remaining.length > 0 ? remaining : void 0, lifecycleState: "running" };
6840
+ sessionService.updateMetadata(sessionMetadata);
6841
+ sessionService.sendKeepAlive(true);
6842
+ sessionService.pushMessage(next.displayText || next.text, "user");
6843
+ logger.log(`[${agentName} Session ${sessionId}] Processing queued message (priority over Ralph advance): "${next.text.slice(0, 50)}..."`);
6844
+ agentBackend.sendPrompt(sessionId, next.text).catch((err) => {
6845
+ logger.error(`[${agentName} Session ${sessionId}] Error processing queued message (Ralph): ${err.message}`);
6846
+ if (!acpStopped) {
6847
+ sessionMetadata = { ...sessionMetadata, lifecycleState: "idle" };
6848
+ sessionService.updateMetadata(sessionMetadata);
6849
+ sessionService.sendSessionEnd();
6850
+ }
6851
+ });
6852
+ return;
6853
+ }
6854
+ const nextIteration = rlState.iteration + 1;
6855
+ const iterationTimestamp = (/* @__PURE__ */ new Date()).toISOString();
6856
+ try {
6857
+ writeRalphState(getRalphStateFilePath(directory, sessionId), { ...rlState, iteration: nextIteration, last_iteration_at: iterationTimestamp });
6858
+ } catch (writeErr) {
6859
+ logger.log(`[${agentName} Session ${sessionId}] Ralph: failed to persist state (iter ${nextIteration}): ${writeErr.message} \u2014 stopping loop`);
6860
+ sessionService.pushMessage({ type: "message", message: `Ralph loop error: failed to persist iteration state \u2014 loop stopped. (${writeErr.message})` }, "event");
6861
+ removeRalphState(getRalphStateFilePath(directory, sessionId));
6862
+ sessionMetadata = { ...sessionMetadata, ralphLoop: { active: false }, lifecycleState: "idle" };
6863
+ sessionService.updateMetadata(sessionMetadata);
6864
+ return;
6865
+ }
6866
+ const ralphLoop = {
6867
+ active: true,
6868
+ task: rlState.task,
6869
+ completionPromise: rlState.completion_promise ?? "none",
6870
+ maxIterations: rlState.max_iterations,
6871
+ currentIteration: nextIteration,
6872
+ startedAt: rlState.started_at,
6873
+ cooldownSeconds: rlState.cooldown_seconds,
6874
+ contextMode: rlState.context_mode || "fresh",
6875
+ lastIterationStartedAt: (/* @__PURE__ */ new Date()).toISOString()
6876
+ };
6877
+ sessionMetadata = { ...sessionMetadata, ralphLoop, lifecycleState: "running" };
6878
+ sessionService.updateMetadata(sessionMetadata);
6879
+ logger.log(`[${agentName} Session ${sessionId}] Ralph loop iteration ${nextIteration}${rlState.max_iterations > 0 ? `/${rlState.max_iterations}` : ""}: spawning`);
6880
+ const updatedState = { ...rlState, iteration: nextIteration, context_mode: "continue" };
6881
+ const prompt = buildRalphPrompt(rlState.task, updatedState);
6882
+ const cooldownMs = Math.max(200, rlState.cooldown_seconds * 1e3);
6883
+ setTimeout(() => {
6884
+ if (acpStopped) return;
6885
+ const liveRlState = readRalphState(getRalphStateFilePath(directory, sessionId));
6886
+ if (!liveRlState) {
6887
+ sessionMetadata = { ...sessionMetadata, lifecycleState: "idle" };
6888
+ sessionService.updateMetadata(sessionMetadata);
6889
+ sessionService.sendKeepAlive(false);
6890
+ sessionService.sendSessionEnd();
6891
+ return;
6892
+ }
6893
+ sessionService.sendKeepAlive(true);
6894
+ sessionMetadata = { ...sessionMetadata, lifecycleState: "running" };
6895
+ sessionService.updateMetadata(sessionMetadata);
6896
+ sessionService.pushMessage({ type: "message", message: buildIterationStatus(nextIteration, rlState.max_iterations, rlState.completion_promise) }, "event");
6897
+ sessionService.pushMessage(rlState.task, "user");
6898
+ agentBackend.sendPrompt(sessionId, prompt).catch((err) => {
6899
+ logger.error(`[${agentName} Session ${sessionId}] Error in Ralph loop: ${err.message}`);
6900
+ if (!acpStopped) {
6901
+ removeRalphState(getRalphStateFilePath(directory, sessionId));
6902
+ sessionMetadata = { ...sessionMetadata, ralphLoop: { active: false }, lifecycleState: "idle" };
6903
+ sessionService.updateMetadata(sessionMetadata);
6904
+ sessionService.sendSessionEnd();
6905
+ sessionService.pushMessage({ type: "message", message: `Ralph loop error: agent failed to start turn \u2014 loop stopped. (${err.message})` }, "event");
6906
+ }
6907
+ });
6908
+ }, cooldownMs);
6909
+ return;
6910
+ }
6366
6911
  }
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 };
6912
+ const queue = sessionMetadata.messageQueue;
6913
+ if (queue && queue.length > 0) {
6914
+ const next = queue[0];
6915
+ const remaining = queue.slice(1);
6916
+ sessionMetadata = { ...sessionMetadata, messageQueue: remaining.length > 0 ? remaining : void 0, lifecycleState: "running" };
6391
6917
  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;
6918
+ sessionService.sendKeepAlive(true);
6919
+ logger.log(`[Session ${sessionId}] Processing queued message: "${next.text.slice(0, 50)}..."`);
6920
+ sessionService.pushMessage(next.displayText || next.text, "user");
6921
+ agentBackend.sendPrompt(sessionId, next.text).catch((err) => {
6922
+ logger.error(`[Session ${sessionId}] Error processing queued message: ${err.message}`);
6923
+ if (!acpStopped) {
6924
+ sessionMetadata = { ...sessionMetadata, lifecycleState: "idle" };
6925
+ sessionService.updateMetadata(sessionMetadata);
6926
+ sessionService.sendSessionEnd();
6927
+ }
6928
+ });
6407
6929
  }
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
- });
6930
+ } finally {
6931
+ insideOnTurnEnd = false;
6420
6932
  }
6421
6933
  };
6422
- bridgeAcpToSession(
6934
+ const cleanupBridge = bridgeAcpToSession(
6423
6935
  agentBackend,
6424
6936
  sessionService,
6425
6937
  () => sessionMetadata,
@@ -6441,11 +6953,19 @@ The automated loop has finished. Review the progress above and let me know if yo
6441
6953
  resumeSessionId,
6442
6954
  get childProcess() {
6443
6955
  return agentBackend.getProcess?.() || void 0;
6956
+ },
6957
+ onStop: () => {
6958
+ acpStopped = true;
6959
+ cleanupBridge();
6960
+ permissionHandler.rejectAll("session stopped");
6961
+ agentBackend.dispose().catch(() => {
6962
+ });
6444
6963
  }
6445
6964
  };
6446
- pidToTrackedSession.set(process.pid + Math.floor(Math.random() * 1e5), trackedSession);
6965
+ pidToTrackedSession.set(randomUUID$1(), trackedSession);
6447
6966
  logger.log(`[Agent Session ${sessionId}] Starting ${agentName} backend...`);
6448
6967
  agentBackend.startSession().then(() => {
6968
+ acpBackendReady = true;
6449
6969
  logger.log(`[Agent Session ${sessionId}] ${agentName} backend started, waiting for first message`);
6450
6970
  }).catch((err) => {
6451
6971
  logger.error(`[Agent Session ${sessionId}] Failed to start ${agentName}:`, err);
@@ -6454,6 +6974,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6454
6974
  "event"
6455
6975
  );
6456
6976
  sessionService.sendSessionEnd();
6977
+ stopSession(sessionId);
6457
6978
  });
6458
6979
  return {
6459
6980
  type: "success",
@@ -6473,6 +6994,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6473
6994
  for (const [pid, session] of pidToTrackedSession) {
6474
6995
  if (session.svampSessionId === sessionId) {
6475
6996
  session.stopped = true;
6997
+ session.onStop?.();
6476
6998
  session.hyphaService?.disconnect().catch(() => {
6477
6999
  });
6478
7000
  if (session.childProcess) {
@@ -6484,12 +7006,14 @@ The automated loop has finished. Review the progress above and let me know if yo
6484
7006
  session.cleanupCredentials?.().catch(() => {
6485
7007
  });
6486
7008
  session.cleanupSvampConfig?.();
7009
+ artifactSync.cancelSync(sessionId);
6487
7010
  pidToTrackedSession.delete(pid);
6488
7011
  deletePersistedSession(sessionId);
6489
7012
  logger.log(`Session ${sessionId} stopped`);
6490
7013
  return true;
6491
7014
  }
6492
7015
  }
7016
+ artifactSync.cancelSync(sessionId);
6493
7017
  deletePersistedSession(sessionId);
6494
7018
  logger.log(`Session ${sessionId} not found in memory, cleaned up persisted state`);
6495
7019
  return false;
@@ -6516,7 +7040,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6516
7040
  const defaultHomeDir = existsSync$1("/data") ? "/data" : os__default.homedir();
6517
7041
  const persistedMachineMeta = loadPersistedMachineMetadata(SVAMP_HOME);
6518
7042
  if (persistedMachineMeta) {
6519
- logger.log(`Restored machine metadata (sharing=${!!persistedMachineMeta.sharing}, securityContextConfig=${!!persistedMachineMeta.securityContextConfig})`);
7043
+ logger.log(`Restored machine metadata (sharing=${!!persistedMachineMeta.sharing}, securityContextConfig=${!!persistedMachineMeta.securityContextConfig}, injectPlatformGuidance=${persistedMachineMeta.injectPlatformGuidance})`);
6520
7044
  }
6521
7045
  const machineMetadata = {
6522
7046
  host: os__default.hostname(),
@@ -6527,9 +7051,10 @@ The automated loop has finished. Review the progress above and let me know if yo
6527
7051
  svampLibDir: join(__dirname$1, ".."),
6528
7052
  displayName: process.env.SVAMP_DISPLAY_NAME || void 0,
6529
7053
  isolationCapabilities,
6530
- // Restore persisted sharing & security context config
7054
+ // Restore persisted sharing, security context config, and platform guidance flag
6531
7055
  ...persistedMachineMeta?.sharing && { sharing: persistedMachineMeta.sharing },
6532
- ...persistedMachineMeta?.securityContextConfig && { securityContextConfig: persistedMachineMeta.securityContextConfig }
7056
+ ...persistedMachineMeta?.securityContextConfig && { securityContextConfig: persistedMachineMeta.securityContextConfig },
7057
+ ...persistedMachineMeta?.injectPlatformGuidance !== void 0 && { injectPlatformGuidance: persistedMachineMeta.injectPlatformGuidance }
6533
7058
  };
6534
7059
  const initialDaemonState = {
6535
7060
  status: "running",
@@ -6626,14 +7151,20 @@ The automated loop has finished. Review the progress above and let me know if yo
6626
7151
  for (const sessionId of sessionsToAutoContinue) {
6627
7152
  setTimeout(async () => {
6628
7153
  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
- );
7154
+ const svc = await Promise.race([
7155
+ server.getService(`svamp-session-${sessionId}`),
7156
+ new Promise((_, reject) => setTimeout(() => reject(new Error("getService timeout")), 1e4))
7157
+ ]);
7158
+ await Promise.race([
7159
+ svc.sendMessage(
7160
+ JSON.stringify({
7161
+ role: "user",
7162
+ 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.' },
7163
+ meta: { sentFrom: "svamp-daemon-auto-continue" }
7164
+ })
7165
+ ),
7166
+ new Promise((_, reject) => setTimeout(() => reject(new Error("sendMessage timeout")), 3e4))
7167
+ ]);
6637
7168
  logger.log(`Auto-continued session ${sessionId}`);
6638
7169
  } catch (err) {
6639
7170
  logger.log(`Failed to auto-continue session ${sessionId}: ${err.message}`);
@@ -6647,7 +7178,10 @@ The automated loop has finished. Review the progress above and let me know if yo
6647
7178
  logger.log(`Resuming Ralph loop for ${sessionsToRalphResume.length} session(s)...`);
6648
7179
  for (const { sessionId, directory: sessDir } of sessionsToRalphResume) {
6649
7180
  try {
6650
- const svc = await server.getService(`svamp-session-${sessionId}`);
7181
+ const svc = await Promise.race([
7182
+ server.getService(`svamp-session-${sessionId}`),
7183
+ new Promise((_, reject) => setTimeout(() => reject(new Error("getService timeout")), 1e4))
7184
+ ]);
6651
7185
  const rlState = readRalphState(getRalphStateFilePath(sessDir, sessionId));
6652
7186
  if (!rlState) continue;
6653
7187
  const initDelayMs = 2e3;
@@ -6668,17 +7202,20 @@ The automated loop has finished. Review the progress above and let me know if yo
6668
7202
  const progressRelPath = `.svamp/${sessionId}/ralph-progress.md`;
6669
7203
  const prompt = buildRalphPrompt(currentState.task, currentState);
6670
7204
  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
- );
7205
+ await Promise.race([
7206
+ svc.sendMessage(
7207
+ JSON.stringify({
7208
+ role: "user",
7209
+ content: { type: "text", text: prompt },
7210
+ meta: {
7211
+ sentFrom: "svamp-daemon-ralph-resume",
7212
+ appendSystemPrompt: ralphSysPrompt,
7213
+ ...isFreshMode ? { ralphFreshContext: true } : {}
7214
+ }
7215
+ })
7216
+ ),
7217
+ new Promise((_, reject) => setTimeout(() => reject(new Error("sendMessage timeout")), 3e4))
7218
+ ]);
6682
7219
  logger.log(`Resumed Ralph loop for session ${sessionId} at iteration ${currentState.iteration} (${isFreshMode ? "fresh" : "continue"})`);
6683
7220
  } catch (err) {
6684
7221
  logger.log(`Failed to resume Ralph loop for session ${sessionId}: ${err.message}`);
@@ -6764,9 +7301,16 @@ The automated loop has finished. Review the progress above and let me know if yo
6764
7301
  process.kill(child.pid, 0);
6765
7302
  } catch {
6766
7303
  logger.log(`Removing stale session (child PID ${child.pid} dead): ${session.svampSessionId}`);
7304
+ session.stopped = true;
7305
+ session.onStop?.();
6767
7306
  session.hyphaService?.disconnect().catch(() => {
6768
7307
  });
7308
+ session.cleanupCredentials?.().catch(() => {
7309
+ });
7310
+ session.cleanupSvampConfig?.();
7311
+ if (session.svampSessionId) artifactSync.cancelSync(session.svampSessionId);
6769
7312
  pidToTrackedSession.delete(key);
7313
+ if (session.svampSessionId) deletePersistedSession(session.svampSessionId);
6770
7314
  }
6771
7315
  }
6772
7316
  }
@@ -6834,6 +7378,10 @@ The automated loop has finished. Review the progress above and let me know if yo
6834
7378
  clearInterval(heartbeatInterval);
6835
7379
  if (proxyTokenRefreshInterval) clearInterval(proxyTokenRefreshInterval);
6836
7380
  if (unhandledRejectionResetTimer) clearTimeout(unhandledRejectionResetTimer);
7381
+ for (const [, session] of pidToTrackedSession) {
7382
+ session.stopped = true;
7383
+ session.onStop?.();
7384
+ }
6837
7385
  machineService.updateDaemonState({
6838
7386
  ...initialDaemonState,
6839
7387
  status: "shutting-down",
@@ -6841,7 +7389,7 @@ The automated loop has finished. Review the progress above and let me know if yo
6841
7389
  shutdownSource: source
6842
7390
  });
6843
7391
  await new Promise((r) => setTimeout(r, 200));
6844
- for (const [pid, session] of pidToTrackedSession) {
7392
+ for (const [, session] of pidToTrackedSession) {
6845
7393
  session.hyphaService?.disconnect().catch(() => {
6846
7394
  });
6847
7395
  if (session.childProcess) {
@@ -6858,14 +7406,21 @@ The automated loop has finished. Review the progress above and let me know if yo
6858
7406
  if (shouldMarkStopped) {
6859
7407
  try {
6860
7408
  const index = loadSessionIndex();
7409
+ let markedCount = 0;
6861
7410
  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");
7411
+ try {
7412
+ const filePath = getSessionFilePath(entry.directory, sessionId);
7413
+ if (existsSync$1(filePath)) {
7414
+ const data = JSON.parse(readFileSync$1(filePath, "utf-8"));
7415
+ const tmpPath = filePath + ".tmp";
7416
+ writeFileSync(tmpPath, JSON.stringify({ ...data, stopped: true }, null, 2), "utf-8");
7417
+ renameSync(tmpPath, filePath);
7418
+ markedCount++;
7419
+ }
7420
+ } catch {
6866
7421
  }
6867
7422
  }
6868
- logger.log("Marked all sessions as stopped (--cleanup mode)");
7423
+ logger.log(`Marked ${markedCount} session(s) as stopped (--cleanup mode)`);
6869
7424
  } catch {
6870
7425
  }
6871
7426
  } else {