sensorium-mcp 3.0.1 → 3.0.3

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.
Files changed (48) hide show
  1. package/dist/dashboard/routes/data.d.ts +1 -0
  2. package/dist/dashboard/routes/data.d.ts.map +1 -1
  3. package/dist/dashboard/routes/data.js +11 -1
  4. package/dist/dashboard/routes/data.js.map +1 -1
  5. package/dist/dashboard/routes.d.ts.map +1 -1
  6. package/dist/dashboard/routes.js +3 -1
  7. package/dist/dashboard/routes.js.map +1 -1
  8. package/dist/data/memory/thread-registry.js +1 -1
  9. package/dist/data/memory/thread-registry.js.map +1 -1
  10. package/dist/http-server.d.ts.map +1 -1
  11. package/dist/http-server.js +9 -1
  12. package/dist/http-server.js.map +1 -1
  13. package/dist/index.js +4 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/services/agent-spawn.service.d.ts.map +1 -1
  16. package/dist/services/agent-spawn.service.js +12 -5
  17. package/dist/services/agent-spawn.service.js.map +1 -1
  18. package/dist/services/reconnect-snapshot.service.d.ts +30 -0
  19. package/dist/services/reconnect-snapshot.service.d.ts.map +1 -0
  20. package/dist/services/reconnect-snapshot.service.js +83 -0
  21. package/dist/services/reconnect-snapshot.service.js.map +1 -0
  22. package/dist/services/thread-lifecycle.service.js +1 -1
  23. package/dist/services/thread-lifecycle.service.js.map +1 -1
  24. package/dist/services/worker-cleanup.service.d.ts.map +1 -1
  25. package/dist/services/worker-cleanup.service.js +30 -7
  26. package/dist/services/worker-cleanup.service.js.map +1 -1
  27. package/dist/sessions.d.ts +5 -0
  28. package/dist/sessions.d.ts.map +1 -1
  29. package/dist/sessions.js +7 -0
  30. package/dist/sessions.js.map +1 -1
  31. package/dist/stdio-server.d.ts.map +1 -1
  32. package/dist/stdio-server.js +7 -1
  33. package/dist/stdio-server.js.map +1 -1
  34. package/dist/tools/delegate-tool.d.ts.map +1 -1
  35. package/dist/tools/delegate-tool.js +1 -0
  36. package/dist/tools/delegate-tool.js.map +1 -1
  37. package/dist/tools/start-session-tool.d.ts.map +1 -1
  38. package/dist/tools/start-session-tool.js +9 -8
  39. package/dist/tools/start-session-tool.js.map +1 -1
  40. package/package.json +1 -1
  41. package/supervisor/config.go +1 -1
  42. package/supervisor/health.go +38 -12
  43. package/supervisor/keeper.go +27 -26
  44. package/supervisor/lock.go +1 -0
  45. package/supervisor/main.go +13 -4
  46. package/supervisor/process.go +29 -0
  47. package/supervisor/service_windows.go +98 -120
  48. package/supervisor/updater.go +12 -11
@@ -1,6 +1,7 @@
1
1
  package main
2
2
 
3
3
  import (
4
+ "bytes"
4
5
  "context"
5
6
  "encoding/json"
6
7
  "fmt"
@@ -16,25 +17,27 @@ type MCPClient struct {
16
17
  Secret string
17
18
  Client *http.Client
18
19
  Log *Logger
20
+ headers map[string]string
19
21
  }
20
22
 
21
23
  func NewMCPClient(port int, secret string) *MCPClient {
24
+ h := map[string]string{
25
+ "Content-Type": "application/json",
26
+ "Accept": "application/json",
27
+ }
28
+ if secret != "" {
29
+ h["Authorization"] = "Bearer " + secret
30
+ }
22
31
  return &MCPClient{
23
32
  BaseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
24
33
  Secret: secret,
25
34
  Client: &http.Client{Timeout: 10 * time.Second},
35
+ headers: h,
26
36
  }
27
37
  }
28
38
 
29
39
  func (m *MCPClient) authHeaders() map[string]string {
30
- h := map[string]string{
31
- "Content-Type": "application/json",
32
- "Accept": "application/json",
33
- }
34
- if m.Secret != "" {
35
- h["Authorization"] = "Bearer " + m.Secret
36
- }
37
- return h
40
+ return m.headers
38
41
  }
39
42
 
40
43
  func (m *MCPClient) doReq(ctx context.Context, method, path string, body any) (*http.Response, error) {
@@ -44,7 +47,7 @@ func (m *MCPClient) doReq(ctx context.Context, method, path string, body any) (*
44
47
  if err != nil {
45
48
  return nil, err
46
49
  }
47
- bodyReader = strings.NewReader(string(data))
50
+ bodyReader = bytes.NewReader(data)
48
51
  }
49
52
 
50
53
  req, err := http.NewRequestWithContext(ctx, method, m.BaseURL+path, bodyReader)
@@ -96,6 +99,26 @@ func (m *MCPClient) WaitForReady(ctx context.Context, pollInterval, timeout time
96
99
  }
97
100
  }
98
101
 
102
+ // PrepareShutdown asks the MCP server to write a reconnect snapshot before being killed.
103
+ // Best-effort: returns false on any error so the caller can proceed with the kill.
104
+ func (m *MCPClient) PrepareShutdown(ctx context.Context) bool {
105
+ ctx2, cancel := context.WithTimeout(ctx, 3*time.Second)
106
+ defer cancel()
107
+ resp, err := m.doReq(ctx2, "POST", "/api/prepare-shutdown", nil)
108
+ if err != nil {
109
+ if m.Log != nil {
110
+ m.Log.Debug("PrepareShutdown: POST /api/prepare-shutdown failed: %v", err)
111
+ }
112
+ return false
113
+ }
114
+ defer resp.Body.Close()
115
+ ok := resp.StatusCode == 200
116
+ if m.Log != nil {
117
+ m.Log.Info("PrepareShutdown: POST /api/prepare-shutdown => %d", resp.StatusCode)
118
+ }
119
+ return ok
120
+ }
121
+
99
122
  // GetRootThreads fetches the list of root threads from the server.
100
123
  func (m *MCPClient) GetRootThreads(ctx context.Context) ([]map[string]any, error) {
101
124
  return m.fetchThreadList(ctx, "/api/threads/roots")
@@ -292,7 +315,7 @@ func (m *MCPClient) OpenMCPSession(ctx context.Context) (string, error) {
292
315
  if err != nil {
293
316
  return sessionID, nil // session created, notification failed — non-fatal
294
317
  }
295
- notifReq.Body = io.NopCloser(strings.NewReader(string(data)))
318
+ notifReq.Body = io.NopCloser(bytes.NewReader(data))
296
319
  for k, v := range m.authHeaders() {
297
320
  notifReq.Header.Set(k, v)
298
321
  }
@@ -357,8 +380,11 @@ func (m *MCPClient) CallStartThread(ctx context.Context, sessionID string, threa
357
380
  if err != nil {
358
381
  return "", err
359
382
  }
360
- data, _ := json.Marshal(payload)
361
- req.Body = io.NopCloser(strings.NewReader(string(data)))
383
+ data, err := json.Marshal(payload)
384
+ if err != nil {
385
+ return "", err
386
+ }
387
+ req.Body = io.NopCloser(bytes.NewReader(data))
362
388
  for k, v := range m.authHeaders() {
363
389
  req.Header.Set(k, v)
364
390
  }
@@ -114,6 +114,7 @@ func (k *Keeper) run(ctx context.Context) {
114
114
  k.log.Warn("Thread %d (worker %d) is stuck (no heartbeat for %v) — restarting", k.cfg.ThreadID, activeThreadID, k.global.StuckThreshold)
115
115
  // Kill via MCP API, then fall through to restart
116
116
  k.killThread(ctx, activeThreadID)
117
+ retryCount = 0
117
118
  activeThreadID = k.cfg.ThreadID // reset — will get new worker ID on restart
118
119
  } else {
119
120
  // Healthy — reset counters
@@ -129,7 +130,31 @@ func (k *Keeper) run(ctx context.Context) {
129
130
  }
130
131
  }
131
132
 
132
- // Thread is not running (or was stuck and killed)
133
+ // Thread is not running (or was stuck and killed).
134
+ // Detect fast-exit: if the previous successful start was recent, the thread exited too quickly.
135
+ if !lastStartTime.IsZero() && time.Since(lastStartTime) < k.global.FastExitThreshold {
136
+ fastExitCount++
137
+ if fastExitCount >= k.global.FastExitMaxCount {
138
+ cooldown := time.Duration(float64(k.global.FastExitBaseCooldown) * math.Pow(2, float64(fastExitEscalation)))
139
+ if cooldown > k.global.FastExitMaxCooldown {
140
+ cooldown = k.global.FastExitMaxCooldown
141
+ }
142
+ k.log.Warn("Thread %d: %d consecutive fast exits — backing off %v", k.cfg.ThreadID, fastExitCount, cooldown)
143
+ if k.onDeath != nil {
144
+ k.onDeath(k.cfg.ThreadID, k.cfg.SessionName+" (repeated fast exits — check credits/API key)")
145
+ }
146
+ fastExitEscalation++
147
+ k.sleep(ctx, cooldown)
148
+ fastExitCount = 0
149
+ retryCount = 0
150
+ return
151
+ }
152
+ } else if !lastStartTime.IsZero() {
153
+ // Previous start was long ago — not a fast exit pattern, reset counters
154
+ fastExitCount = 0
155
+ fastExitEscalation = 0
156
+ }
157
+
133
158
  if retryCount >= k.cfg.MaxRetries {
134
159
  cooldown := k.global.KeeperCooldown
135
160
  if k.cfg.CooldownMs > 0 {
@@ -157,10 +182,10 @@ func (k *Keeper) run(ctx context.Context) {
157
182
  return
158
183
  }
159
184
 
160
- lastStartTime = time.Now()
161
185
  ok, workerID := k.callStartThread(ctx)
162
186
 
163
187
  if ok {
188
+ lastStartTime = time.Now()
164
189
  if workerID > 0 {
165
190
  activeThreadID = workerID
166
191
  k.log.Info("Thread %d start_thread succeeded (worker %d)", k.cfg.ThreadID, workerID)
@@ -168,31 +193,7 @@ func (k *Keeper) run(ctx context.Context) {
168
193
  k.log.Info("Thread %d start_thread succeeded", k.cfg.ThreadID)
169
194
  }
170
195
  retryCount = 0
171
- // Check for fast exit on next check
172
196
  } else {
173
- // Check for fast exit pattern
174
- if !lastStartTime.IsZero() && time.Since(lastStartTime) < k.global.FastExitThreshold {
175
- fastExitCount++
176
- if fastExitCount >= k.global.FastExitMaxCount {
177
- cooldown := time.Duration(float64(k.global.FastExitBaseCooldown) * math.Pow(2, float64(fastExitEscalation)))
178
- if cooldown > k.global.FastExitMaxCooldown {
179
- cooldown = k.global.FastExitMaxCooldown
180
- }
181
- k.log.Warn("Thread %d: %d consecutive fast exits — backing off %v", k.cfg.ThreadID, fastExitCount, cooldown)
182
- if k.onDeath != nil {
183
- k.onDeath(k.cfg.ThreadID, k.cfg.SessionName+" (repeated fast exits — check credits/API key)")
184
- }
185
- fastExitEscalation++
186
- k.sleep(ctx, cooldown)
187
- fastExitCount = 0
188
- retryCount = 0
189
- return
190
- }
191
- } else {
192
- fastExitCount = 0
193
- fastExitEscalation = 0
194
- }
195
-
196
197
  retryCount++
197
198
  delay := k.backoff(retryCount)
198
199
  k.log.Info("Backing off %v before next attempt", delay)
@@ -47,6 +47,7 @@ func AcquireLock(lockPath string, log *Logger) bool {
47
47
  }
48
48
  fmt.Fprintf(f, "%d", os.Getpid())
49
49
  f.Close()
50
+ log.Info("Lock acquired: %s (PID %d)", lockPath, os.Getpid())
50
51
  return true
51
52
  }
52
53
 
@@ -166,10 +166,16 @@ func runSupervisor(runningAsService bool) error {
166
166
  mcp := NewMCPClient(cfg.MCPHttpPort, cfg.MCPHttpSecret)
167
167
  mcp.Log = log
168
168
 
169
- // Clean stale PID files from previous runs
170
- CleanStalePIDs(cfg.Paths.PIDsDir, log)
171
-
172
- // Kill orphan process on our port
169
+ // Kill orphan thread processes from previous runs, then clean PID files
170
+ KillOrphanThreads(cfg.Paths.PIDsDir, log)
171
+
172
+ // Kill orphan MCP server from previous run
173
+ if oldPid, err := ReadPIDFile(cfg.Paths.ServerPID); err == nil && oldPid > 0 && IsProcessAlive(oldPid) {
174
+ log.Info("Killing orphan MCP server (PID %d) from previous run", oldPid)
175
+ _ = KillProcess(oldPid, log)
176
+ time.Sleep(1 * time.Second) // allow port to release
177
+ }
178
+ _ = os.Remove(cfg.Paths.ServerPID)
173
179
  KillByPort(cfg.MCPHttpPort, log)
174
180
 
175
181
  // Spawn MCP server
@@ -369,6 +375,9 @@ func runSupervisor(runningAsService bool) error {
369
375
  <-keeperPollerDone
370
376
  <-healthDone
371
377
 
378
+ // Ask MCP to write reconnect snapshot before killing it
379
+ mcp.PrepareShutdown(context.Background())
380
+
372
381
  // Kill server process
373
382
  pid, err := ReadPIDFile(cfg.Paths.ServerPID)
374
383
  if err == nil && pid > 0 {
@@ -242,6 +242,35 @@ func ListThreadPIDs(pidsDir string) map[string]int {
242
242
  return result
243
243
  }
244
244
 
245
+ // KillOrphanThreads kills any alive processes listed in PID files (leftovers from
246
+ // a previous supervisor run) and then removes the PID files. Called on startup
247
+ // before the MCP server is launched, so all thread PIDs are guaranteed to be stale.
248
+ func KillOrphanThreads(pidsDir string, log *Logger) {
249
+ pids := ListThreadPIDs(pidsDir)
250
+ if len(pids) == 0 {
251
+ log.Debug("KillOrphanThreads: no PID files found")
252
+ return
253
+ }
254
+ log.Info("KillOrphanThreads: checking %d PID files for orphan processes", len(pids))
255
+ killed := 0
256
+ for threadID, pid := range pids {
257
+ path := filepath.Join(pidsDir, threadID+".pid")
258
+ if IsProcessAlive(pid) {
259
+ log.Info("Killing orphan thread %s (PID %d)", threadID, pid)
260
+ if err := KillProcess(pid, log); err != nil {
261
+ log.Warn("Failed to kill orphan PID %d: %v", pid, err)
262
+ }
263
+ killed++
264
+ }
265
+ _ = os.Remove(path)
266
+ }
267
+ if killed > 0 {
268
+ log.Info("KillOrphanThreads: killed %d orphan processes, cleaned %d PID files", killed, len(pids))
269
+ } else {
270
+ log.Info("KillOrphanThreads: no orphans found, cleaned %d stale PID files", len(pids))
271
+ }
272
+ }
273
+
245
274
  // CleanStalePIDs removes PID files for processes that are no longer running.
246
275
  func CleanStalePIDs(pidsDir string, log *Logger) {
247
276
  pids := ListThreadPIDs(pidsDir)
@@ -5,6 +5,7 @@ package main
5
5
  import (
6
6
  "fmt"
7
7
  "os"
8
+ "path/filepath"
8
9
  "time"
9
10
 
10
11
  "golang.org/x/sys/windows/svc"
@@ -58,159 +59,136 @@ func runAsService() error {
58
59
  return svc.Run(serviceName, &supervisorService{})
59
60
  }
60
61
 
61
- func installService(exePath, serviceUser, servicePassword string) error {
62
+ func withServiceManager(fn func(*mgr.Mgr) error) error {
62
63
  m, err := mgr.Connect()
63
64
  if err != nil {
64
- return fmt.Errorf("install failed: connect to service manager: %w", err)
65
+ return err
65
66
  }
66
67
  defer m.Disconnect()
68
+ return fn(m)
69
+ }
67
70
 
68
- s, err := m.OpenService(serviceName)
69
- if err == nil {
70
- s.Close()
71
- return fmt.Errorf("install failed: service %q already exists", serviceName)
72
- }
71
+ func installService(exePath, serviceUser, servicePassword string) error {
72
+ return withServiceManager(func(m *mgr.Mgr) error {
73
+ s, err := m.OpenService(serviceName)
74
+ if err == nil {
75
+ s.Close()
76
+ return fmt.Errorf("install failed: service %q already exists", serviceName)
77
+ }
73
78
 
74
- cfg := mgr.Config{
75
- DisplayName: serviceDisplay,
76
- Description: serviceDesc,
77
- StartType: mgr.StartAutomatic,
78
- DelayedAutoStart: true,
79
- }
80
- if serviceUser != "" {
81
- cfg.ServiceStartName = serviceUser
82
- cfg.Password = servicePassword
83
- if servicePassword == "" {
84
- fmt.Printf("Installing service as passwordless identity %q\n", serviceUser)
79
+ cfg := mgr.Config{
80
+ DisplayName: serviceDisplay,
81
+ Description: serviceDesc,
82
+ StartType: mgr.StartAutomatic,
83
+ DelayedAutoStart: true,
84
+ }
85
+ if serviceUser != "" {
86
+ cfg.ServiceStartName = serviceUser
87
+ cfg.Password = servicePassword
88
+ if servicePassword == "" {
89
+ fmt.Printf("Installing service as passwordless identity %q\n", serviceUser)
90
+ } else {
91
+ fmt.Printf("Installing service as user %q\n", serviceUser)
92
+ }
85
93
  } else {
86
- fmt.Printf("Installing service as user %q\n", serviceUser)
94
+ fmt.Println("Installing service as LocalSystem (default). Use -service-user to run as a specific user account.")
87
95
  }
88
- } else {
89
- fmt.Println("Installing service as LocalSystem (default). Use -service-user to run as a specific user account.")
90
- }
91
96
 
92
- s, err = m.CreateService(serviceName, exePath, cfg)
93
- if err != nil {
94
- return fmt.Errorf("install failed: create service: %w", err)
95
- }
96
- defer s.Close()
97
+ s, err = m.CreateService(serviceName, exePath, cfg)
98
+ if err != nil {
99
+ return fmt.Errorf("install failed: create service: %w", err)
100
+ }
101
+ defer s.Close()
97
102
 
98
- fmt.Printf("Service %q installed successfully.\n", serviceName)
99
- fmt.Printf("Start it with: %s start\n", filepathBase(exePath))
100
- return nil
103
+ fmt.Printf("Service %q installed successfully.\n", serviceName)
104
+ fmt.Printf("Start it with: %s start\n", filepath.Base(exePath))
105
+ return nil
106
+ })
101
107
  }
102
108
 
103
109
  func uninstallService() error {
104
- m, err := mgr.Connect()
105
- if err != nil {
106
- return fmt.Errorf("uninstall failed: connect to service manager: %w", err)
107
- }
108
- defer m.Disconnect()
109
-
110
- s, err := m.OpenService(serviceName)
111
- if err != nil {
112
- return fmt.Errorf("uninstall failed: service %q not found: %w", serviceName, err)
113
- }
114
- defer s.Close()
110
+ return withServiceManager(func(m *mgr.Mgr) error {
111
+ s, err := m.OpenService(serviceName)
112
+ if err != nil {
113
+ return fmt.Errorf("uninstall failed: service %q not found: %w", serviceName, err)
114
+ }
115
+ defer s.Close()
115
116
 
116
- if err := s.Delete(); err != nil {
117
- return fmt.Errorf("uninstall failed: delete service: %w", err)
118
- }
117
+ if err := s.Delete(); err != nil {
118
+ return fmt.Errorf("uninstall failed: delete service: %w", err)
119
+ }
119
120
 
120
- fmt.Printf("Service %q uninstalled.\n", serviceName)
121
- return nil
121
+ fmt.Printf("Service %q uninstalled.\n", serviceName)
122
+ return nil
123
+ })
122
124
  }
123
125
 
124
126
  func startService() error {
125
- m, err := mgr.Connect()
126
- if err != nil {
127
- return fmt.Errorf("start failed: connect to service manager: %w", err)
128
- }
129
- defer m.Disconnect()
130
-
131
- s, err := m.OpenService(serviceName)
132
- if err != nil {
133
- return fmt.Errorf("start failed: service %q not found: %w", serviceName, err)
134
- }
135
- defer s.Close()
127
+ return withServiceManager(func(m *mgr.Mgr) error {
128
+ s, err := m.OpenService(serviceName)
129
+ if err != nil {
130
+ return fmt.Errorf("start failed: service %q not found: %w", serviceName, err)
131
+ }
132
+ defer s.Close()
136
133
 
137
- if err := s.Start(); err != nil {
138
- return fmt.Errorf("start failed: %w", err)
139
- }
134
+ if err := s.Start(); err != nil {
135
+ return fmt.Errorf("start failed: %w", err)
136
+ }
140
137
 
141
- fmt.Printf("Service %q started.\n", serviceName)
142
- return nil
138
+ fmt.Printf("Service %q started.\n", serviceName)
139
+ return nil
140
+ })
143
141
  }
144
142
 
145
143
  func stopService() error {
146
- m, err := mgr.Connect()
147
- if err != nil {
148
- return fmt.Errorf("stop failed: connect to service manager: %w", err)
149
- }
150
- defer m.Disconnect()
151
-
152
- s, err := m.OpenService(serviceName)
153
- if err != nil {
154
- return fmt.Errorf("stop failed: service %q not found: %w", serviceName, err)
155
- }
156
- defer s.Close()
144
+ return withServiceManager(func(m *mgr.Mgr) error {
145
+ s, err := m.OpenService(serviceName)
146
+ if err != nil {
147
+ return fmt.Errorf("stop failed: service %q not found: %w", serviceName, err)
148
+ }
149
+ defer s.Close()
157
150
 
158
- if _, err := s.Control(svc.Stop); err != nil {
159
- return fmt.Errorf("stop failed: %w", err)
160
- }
151
+ if _, err := s.Control(svc.Stop); err != nil {
152
+ return fmt.Errorf("stop failed: %w", err)
153
+ }
161
154
 
162
- fmt.Printf("Service %q stopping.\n", serviceName)
163
- return nil
155
+ fmt.Printf("Service %q stopping.\n", serviceName)
156
+ return nil
157
+ })
164
158
  }
165
159
 
166
160
  func serviceStatus() error {
167
- m, err := mgr.Connect()
168
- if err != nil {
169
- return fmt.Errorf("status failed: connect to service manager: %w", err)
170
- }
171
- defer m.Disconnect()
172
-
173
- s, err := m.OpenService(serviceName)
174
- if err != nil {
175
- return fmt.Errorf("status failed: service %q not found: %w", serviceName, err)
176
- }
177
- defer s.Close()
161
+ return withServiceManager(func(m *mgr.Mgr) error {
162
+ s, err := m.OpenService(serviceName)
163
+ if err != nil {
164
+ return fmt.Errorf("status failed: service %q not found: %w", serviceName, err)
165
+ }
166
+ defer s.Close()
178
167
 
179
- st, err := s.Query()
180
- if err != nil {
181
- return fmt.Errorf("status failed: query service: %w", err)
182
- }
168
+ st, err := s.Query()
169
+ if err != nil {
170
+ return fmt.Errorf("status failed: query service: %w", err)
171
+ }
183
172
 
184
- states := map[svc.State]string{
185
- svc.Stopped: "Stopped",
186
- svc.StartPending: "StartPending",
187
- svc.StopPending: "StopPending",
188
- svc.Running: "Running",
189
- svc.ContinuePending: "ContinuePending",
190
- svc.PausePending: "PausePending",
191
- svc.Paused: "Paused",
192
- }
193
- state, ok := states[st.State]
194
- if !ok {
195
- state = fmt.Sprintf("Unknown(%d)", st.State)
196
- }
173
+ states := map[svc.State]string{
174
+ svc.Stopped: "Stopped",
175
+ svc.StartPending: "StartPending",
176
+ svc.StopPending: "StopPending",
177
+ svc.Running: "Running",
178
+ svc.ContinuePending: "ContinuePending",
179
+ svc.PausePending: "PausePending",
180
+ svc.Paused: "Paused",
181
+ }
182
+ state, ok := states[st.State]
183
+ if !ok {
184
+ state = fmt.Sprintf("Unknown(%d)", st.State)
185
+ }
197
186
 
198
- fmt.Printf("Service %q: %s\n", serviceName, state)
199
- return nil
187
+ fmt.Printf("Service %q: %s\n", serviceName, state)
188
+ return nil
189
+ })
200
190
  }
201
191
 
202
192
  func isWindowsService() (bool, error) {
203
193
  return svc.IsWindowsService()
204
194
  }
205
-
206
- func filepathBase(path string) string {
207
- if path == "" {
208
- return serviceName
209
- }
210
- for i := len(path) - 1; i >= 0; i-- {
211
- if path[i] == '\\' || path[i] == '/' {
212
- return path[i+1:]
213
- }
214
- }
215
- return path
216
- }
@@ -433,6 +433,9 @@ func (u *Updater) checkSupervisorUpdate(ctx context.Context) {
433
433
  u.state.Transition(updateScopeSupervisor, updatePhaseStaged, remote, local, "")
434
434
  notifyUpdaterOperator(u.cfg, u.log, fmt.Sprintf("⚙️ Supervisor: downloaded %s. Restarting supervisor to apply update...", remote), 0)
435
435
 
436
+ // Reset start time so minimum uptime is re-enforced after restart
437
+ u.startAt = time.Now()
438
+
436
439
  isService, err := isWindowsService()
437
440
  if err != nil {
438
441
  markFailed(err)
@@ -499,14 +502,6 @@ func (u *Updater) downloadSupervisorBinary(ctx context.Context, downloadURL stri
499
502
  return fmt.Errorf("downloaded empty binary")
500
503
  }
501
504
 
502
- info, err := os.Stat(tmpPath)
503
- if err != nil {
504
- return err
505
- }
506
- if info.Size() <= 0 {
507
- return fmt.Errorf("downloaded binary has invalid size %d", info.Size())
508
- }
509
-
510
505
  if err := os.Remove(u.cfg.Paths.PendingBinary); err != nil && !os.IsNotExist(err) {
511
506
  return err
512
507
  }
@@ -514,7 +509,7 @@ func (u *Updater) downloadSupervisorBinary(ctx context.Context, downloadURL stri
514
509
  return err
515
510
  }
516
511
 
517
- u.log.Info("Supervisor binary downloaded to %s (%d bytes)", u.cfg.Paths.PendingBinary, info.Size())
512
+ u.log.Info("Supervisor binary downloaded to %s (%d bytes)", u.cfg.Paths.PendingBinary, written)
518
513
  return nil
519
514
  }
520
515
 
@@ -548,7 +543,7 @@ func requestSupervisorRestart(log *Logger) error {
548
543
  }
549
544
 
550
545
  go func() {
551
- time.Sleep(250 * time.Millisecond)
546
+ time.Sleep(2 * time.Second)
552
547
  os.Exit(0)
553
548
  }()
554
549
 
@@ -557,6 +552,12 @@ func requestSupervisorRestart(log *Logger) error {
557
552
 
558
553
  func (u *Updater) killServer() {
559
554
  u.log.Info("Updater: stopping current MCP server for update")
555
+
556
+ // Ask the MCP server to write a reconnect snapshot before we kill it.
557
+ // On Windows, taskkill /F doesn't allow graceful shutdown, so this is
558
+ // the only way the snapshot gets written.
559
+ u.mcp.PrepareShutdown(context.Background())
560
+
560
561
  pid, err := ReadPIDFile(u.cfg.Paths.ServerPID)
561
562
  if err != nil {
562
563
  u.log.Warn("Could not read server PID file: %v", err)
@@ -600,7 +601,7 @@ func (u *Updater) clearNpxCache() {
600
601
  }
601
602
  pkgDir := filepath.Join(base, e.Name(), "node_modules", "sensorium-mcp")
602
603
  // Validate path doesn't escape base directory
603
- if !strings.HasPrefix(pkgDir, base) {
604
+ if !strings.HasPrefix(pkgDir, base+string(os.PathSeparator)) {
604
605
  continue
605
606
  }
606
607
  if _, err := os.Stat(pkgDir); err == nil {