sensorium-mcp 2.17.28 → 3.0.0

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 (208) hide show
  1. package/Install-Sensorium.ps1 +327 -0
  2. package/README.md +14 -0
  3. package/dist/config.d.ts +16 -1
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +39 -2
  6. package/dist/config.js.map +1 -1
  7. package/dist/daily-session.d.ts +2 -1
  8. package/dist/daily-session.d.ts.map +1 -1
  9. package/dist/daily-session.js +23 -26
  10. package/dist/daily-session.js.map +1 -1
  11. package/dist/dashboard/routes/settings.d.ts +4 -0
  12. package/dist/dashboard/routes/settings.d.ts.map +1 -1
  13. package/dist/dashboard/routes/settings.js +57 -1
  14. package/dist/dashboard/routes/settings.js.map +1 -1
  15. package/dist/dashboard/routes/threads.d.ts +1 -0
  16. package/dist/dashboard/routes/threads.d.ts.map +1 -1
  17. package/dist/dashboard/routes/threads.js +23 -25
  18. package/dist/dashboard/routes/threads.js.map +1 -1
  19. package/dist/dashboard/routes.d.ts.map +1 -1
  20. package/dist/dashboard/routes.js +7 -2
  21. package/dist/dashboard/routes.js.map +1 -1
  22. package/dist/dashboard/spa.html +11 -11
  23. package/dist/data/interfaces.d.ts +36 -0
  24. package/dist/data/interfaces.d.ts.map +1 -0
  25. package/dist/data/interfaces.js +2 -0
  26. package/dist/data/interfaces.js.map +1 -0
  27. package/dist/data/memory/bootstrap.d.ts +36 -16
  28. package/dist/data/memory/bootstrap.d.ts.map +1 -1
  29. package/dist/data/memory/bootstrap.js +71 -217
  30. package/dist/data/memory/bootstrap.js.map +1 -1
  31. package/dist/data/memory/consolidation.d.ts +35 -34
  32. package/dist/data/memory/consolidation.d.ts.map +1 -1
  33. package/dist/data/memory/consolidation.js +43 -554
  34. package/dist/data/memory/consolidation.js.map +1 -1
  35. package/dist/data/memory/migration-runner.d.ts +5 -0
  36. package/dist/data/memory/migration-runner.d.ts.map +1 -0
  37. package/dist/data/memory/migration-runner.js +403 -0
  38. package/dist/data/memory/migration-runner.js.map +1 -0
  39. package/dist/data/memory/reflection.js +1 -1
  40. package/dist/data/memory/schema-ddl.d.ts +4 -0
  41. package/dist/data/memory/schema-ddl.d.ts.map +1 -0
  42. package/dist/data/memory/schema-ddl.js +194 -0
  43. package/dist/data/memory/schema-ddl.js.map +1 -0
  44. package/dist/data/memory/schema-guard.d.ts +3 -0
  45. package/dist/data/memory/schema-guard.d.ts.map +1 -0
  46. package/dist/data/memory/schema-guard.js +184 -0
  47. package/dist/data/memory/schema-guard.js.map +1 -0
  48. package/dist/data/memory/schema.d.ts +2 -5
  49. package/dist/data/memory/schema.d.ts.map +1 -1
  50. package/dist/data/memory/schema.js +6 -834
  51. package/dist/data/memory/schema.js.map +1 -1
  52. package/dist/data/memory/synthesis.js +2 -2
  53. package/dist/data/memory/synthesis.js.map +1 -1
  54. package/dist/data/memory/thread-registry.d.ts +18 -4
  55. package/dist/data/memory/thread-registry.d.ts.map +1 -1
  56. package/dist/data/memory/thread-registry.js +25 -0
  57. package/dist/data/memory/thread-registry.js.map +1 -1
  58. package/dist/data/sent-message.repository.d.ts +12 -0
  59. package/dist/data/sent-message.repository.d.ts.map +1 -0
  60. package/dist/data/sent-message.repository.js +31 -0
  61. package/dist/data/sent-message.repository.js.map +1 -0
  62. package/dist/http-server.d.ts.map +1 -1
  63. package/dist/http-server.js +23 -2
  64. package/dist/http-server.js.map +1 -1
  65. package/dist/index.js +27 -48
  66. package/dist/index.js.map +1 -1
  67. package/dist/logger.d.ts +7 -2
  68. package/dist/logger.d.ts.map +1 -1
  69. package/dist/logger.js +89 -12
  70. package/dist/logger.js.map +1 -1
  71. package/dist/scheduler.d.ts +8 -0
  72. package/dist/scheduler.d.ts.map +1 -1
  73. package/dist/scheduler.js +15 -0
  74. package/dist/scheduler.js.map +1 -1
  75. package/dist/server/factory.d.ts +2 -1
  76. package/dist/server/factory.d.ts.map +1 -1
  77. package/dist/server/factory.js +11 -4
  78. package/dist/server/factory.js.map +1 -1
  79. package/dist/services/agent-spawn.service.d.ts +39 -0
  80. package/dist/services/agent-spawn.service.d.ts.map +1 -0
  81. package/dist/services/agent-spawn.service.js +348 -0
  82. package/dist/services/agent-spawn.service.js.map +1 -0
  83. package/dist/services/background-runner.d.ts +26 -0
  84. package/dist/services/background-runner.d.ts.map +1 -0
  85. package/dist/services/background-runner.js +71 -0
  86. package/dist/services/background-runner.js.map +1 -0
  87. package/dist/services/consolidation.service.d.ts +16 -0
  88. package/dist/services/consolidation.service.d.ts.map +1 -0
  89. package/dist/services/consolidation.service.js +508 -0
  90. package/dist/services/consolidation.service.js.map +1 -0
  91. package/dist/services/dispatcher/broker.d.ts +2 -0
  92. package/dist/services/dispatcher/broker.d.ts.map +1 -1
  93. package/dist/services/dispatcher/broker.js +5 -10
  94. package/dist/services/dispatcher/broker.js.map +1 -1
  95. package/dist/services/dispatcher/index.d.ts +1 -1
  96. package/dist/services/dispatcher/index.d.ts.map +1 -1
  97. package/dist/services/dispatcher/index.js +1 -1
  98. package/dist/services/dispatcher/index.js.map +1 -1
  99. package/dist/services/dispatcher/lock.d.ts.map +1 -1
  100. package/dist/services/dispatcher/lock.js +7 -11
  101. package/dist/services/dispatcher/lock.js.map +1 -1
  102. package/dist/services/maintenance-signal.d.ts +18 -0
  103. package/dist/services/maintenance-signal.d.ts.map +1 -0
  104. package/dist/services/maintenance-signal.js +48 -0
  105. package/dist/services/maintenance-signal.js.map +1 -0
  106. package/dist/services/memory-briefing.service.d.ts +4 -0
  107. package/dist/services/memory-briefing.service.d.ts.map +1 -0
  108. package/dist/services/memory-briefing.service.js +143 -0
  109. package/dist/services/memory-briefing.service.js.map +1 -0
  110. package/dist/services/process.service.d.ts +31 -0
  111. package/dist/services/process.service.d.ts.map +1 -0
  112. package/dist/services/process.service.js +100 -0
  113. package/dist/services/process.service.js.map +1 -0
  114. package/dist/services/thread-health.service.d.ts +18 -0
  115. package/dist/services/thread-health.service.d.ts.map +1 -0
  116. package/dist/services/thread-health.service.js +118 -0
  117. package/dist/services/thread-health.service.js.map +1 -0
  118. package/dist/services/thread-lifecycle.service.d.ts +52 -0
  119. package/dist/services/thread-lifecycle.service.d.ts.map +1 -0
  120. package/dist/services/thread-lifecycle.service.js +174 -0
  121. package/dist/services/thread-lifecycle.service.js.map +1 -0
  122. package/dist/services/topic.service.d.ts +25 -0
  123. package/dist/services/topic.service.d.ts.map +1 -0
  124. package/dist/services/topic.service.js +65 -0
  125. package/dist/services/topic.service.js.map +1 -0
  126. package/dist/services/worker-cleanup.service.d.ts +8 -0
  127. package/dist/services/worker-cleanup.service.d.ts.map +1 -0
  128. package/dist/services/worker-cleanup.service.js +82 -0
  129. package/dist/services/worker-cleanup.service.js.map +1 -0
  130. package/dist/sessions.d.ts +14 -0
  131. package/dist/sessions.d.ts.map +1 -1
  132. package/dist/sessions.js +55 -0
  133. package/dist/sessions.js.map +1 -1
  134. package/dist/telegram.d.ts +13 -6
  135. package/dist/telegram.d.ts.map +1 -1
  136. package/dist/telegram.js +43 -14
  137. package/dist/telegram.js.map +1 -1
  138. package/dist/tools/delegate-tool.d.ts +4 -0
  139. package/dist/tools/delegate-tool.d.ts.map +1 -1
  140. package/dist/tools/delegate-tool.js +48 -109
  141. package/dist/tools/delegate-tool.js.map +1 -1
  142. package/dist/tools/memory-tools.d.ts.map +1 -1
  143. package/dist/tools/memory-tools.js +1 -1
  144. package/dist/tools/memory-tools.js.map +1 -1
  145. package/dist/tools/shared-agent-utils.d.ts +9 -1
  146. package/dist/tools/shared-agent-utils.d.ts.map +1 -1
  147. package/dist/tools/shared-agent-utils.js +21 -38
  148. package/dist/tools/shared-agent-utils.js.map +1 -1
  149. package/dist/tools/start-session-tool.d.ts +2 -0
  150. package/dist/tools/start-session-tool.d.ts.map +1 -1
  151. package/dist/tools/start-session-tool.js +66 -106
  152. package/dist/tools/start-session-tool.js.map +1 -1
  153. package/dist/tools/thread-lifecycle.d.ts +5 -127
  154. package/dist/tools/thread-lifecycle.d.ts.map +1 -1
  155. package/dist/tools/thread-lifecycle.js +5 -1167
  156. package/dist/tools/thread-lifecycle.js.map +1 -1
  157. package/dist/tools/utility-tools.js +5 -2
  158. package/dist/tools/utility-tools.js.map +1 -1
  159. package/dist/tools/wait/drive-handler.d.ts +0 -1
  160. package/dist/tools/wait/drive-handler.d.ts.map +1 -1
  161. package/dist/tools/wait/drive-handler.js +5 -22
  162. package/dist/tools/wait/drive-handler.js.map +1 -1
  163. package/dist/tools/wait/message-delivery.js +1 -1
  164. package/dist/tools/wait/message-delivery.js.map +1 -1
  165. package/dist/tools/wait/message-processing.d.ts.map +1 -1
  166. package/dist/tools/wait/message-processing.js +9 -8
  167. package/dist/tools/wait/message-processing.js.map +1 -1
  168. package/dist/tools/wait/poll-loop.d.ts +2 -0
  169. package/dist/tools/wait/poll-loop.d.ts.map +1 -1
  170. package/dist/tools/wait/poll-loop.js +27 -29
  171. package/dist/tools/wait/poll-loop.js.map +1 -1
  172. package/dist/tools/wait/task-handler.d.ts +0 -3
  173. package/dist/tools/wait/task-handler.d.ts.map +1 -1
  174. package/dist/tools/wait/task-handler.js +3 -2
  175. package/dist/tools/wait/task-handler.js.map +1 -1
  176. package/dist/types.d.ts +0 -1
  177. package/dist/types.d.ts.map +1 -1
  178. package/package.json +4 -8
  179. package/supervisor/config.go +182 -69
  180. package/supervisor/config_test.go +78 -0
  181. package/supervisor/go.mod +12 -0
  182. package/supervisor/go.sum +20 -0
  183. package/supervisor/health.go +56 -6
  184. package/supervisor/health_test.go +29 -0
  185. package/supervisor/keeper.go +15 -10
  186. package/supervisor/log.go +109 -28
  187. package/supervisor/log_test.go +86 -6
  188. package/supervisor/main.go +150 -19
  189. package/supervisor/main_test.go +130 -0
  190. package/supervisor/process.go +47 -4
  191. package/supervisor/process_test.go +14 -0
  192. package/supervisor/secrets.go +95 -0
  193. package/supervisor/secrets_securevault_test.go +98 -0
  194. package/supervisor/secrets_test.go +119 -0
  195. package/supervisor/self_update.go +282 -0
  196. package/supervisor/self_update_test.go +177 -0
  197. package/supervisor/service_restart_stub.go +9 -0
  198. package/supervisor/service_restart_windows.go +63 -0
  199. package/supervisor/service_stub.go +15 -0
  200. package/supervisor/service_windows.go +216 -0
  201. package/supervisor/update_state.go +264 -0
  202. package/supervisor/update_state_test.go +306 -0
  203. package/supervisor/updater.go +311 -10
  204. package/supervisor/updater_test.go +64 -0
  205. package/scripts/install-supervisor.ps1 +0 -67
  206. package/scripts/install-supervisor.sh +0 -43
  207. package/scripts/start-supervisor.ps1 +0 -46
  208. package/scripts/start-supervisor.sh +0 -20
@@ -91,3 +91,32 @@ func TestIsThreadRunning_False(t *testing.T) {
91
91
  t.Error("expected thread to not be running")
92
92
  }
93
93
  }
94
+
95
+ func TestGetKeepAliveThreads_FallbackToThreadsEndpoint(t *testing.T) {
96
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
97
+ w.Header().Set("Content-Type", "application/json")
98
+ switch r.URL.Path {
99
+ case "/api/threads/keepalive":
100
+ w.WriteHeader(http.StatusNotFound)
101
+ w.Write([]byte(`{"error":"not found"}`))
102
+ case "/api/threads":
103
+ w.WriteHeader(http.StatusOK)
104
+ w.Write([]byte(`{"threads":[{"threadId":1,"name":"root","type":"root","keepAlive":true},{"threadId":2,"name":"worker","type":"worker","keepAlive":true},{"threadId":3,"name":"off","type":"root","keepAlive":false}]}`))
105
+ default:
106
+ w.WriteHeader(http.StatusNotFound)
107
+ }
108
+ }))
109
+ defer srv.Close()
110
+
111
+ mcp := &MCPClient{BaseURL: srv.URL, Client: srv.Client()}
112
+ threads, err := mcp.GetKeepAliveThreads(context.Background())
113
+ if err != nil {
114
+ t.Fatalf("unexpected error: %v", err)
115
+ }
116
+ if len(threads) != 1 {
117
+ t.Fatalf("got %d threads, want 1", len(threads))
118
+ }
119
+ if got, _ := threads[0]["threadId"].(float64); int(got) != 1 {
120
+ t.Fatalf("unexpected thread returned: %v", threads[0])
121
+ }
122
+ }
@@ -5,6 +5,7 @@ import (
5
5
  "encoding/json"
6
6
  "fmt"
7
7
  "math"
8
+ "path/filepath"
8
9
  "sync"
9
10
  "time"
10
11
  )
@@ -119,9 +120,11 @@ func (k *Keeper) run(ctx context.Context) {
119
120
  if retryCount > 0 {
120
121
  k.log.Info("Thread %d is healthy again (was at retry %d)", k.cfg.ThreadID, retryCount)
121
122
  } else {
122
- k.log.Debug("Thread %d is healthy", k.cfg.ThreadID)
123
+ k.log.Info("Thread %d is healthy", k.cfg.ThreadID)
123
124
  }
124
125
  retryCount = 0
126
+ fastExitCount = 0
127
+ fastExitEscalation = 0
125
128
  return
126
129
  }
127
130
  }
@@ -243,14 +246,14 @@ func (k *Keeper) callStartThread(ctx context.Context) (bool, int) {
243
246
  func (k *Keeper) killThread(ctx context.Context, threadID int) {
244
247
  k.log.Info("Killing stuck thread %d", threadID)
245
248
  // Read PID from thread PID file
246
- pidFile := k.global.Paths.PIDsDir + "/" + fmt.Sprintf("%d.pid", threadID)
249
+ pidFile := filepath.Join(k.global.Paths.PIDsDir, fmt.Sprintf("%d.pid", threadID))
247
250
  pid, err := ReadPIDFile(pidFile)
248
251
  if err != nil {
249
- k.log.Warn("Cannot read PID for thread %d: %v", k.cfg.ThreadID, err)
252
+ k.log.Warn("Cannot read PID for thread %d: %v", threadID, err)
250
253
  return
251
254
  }
252
255
  if err := KillProcess(pid, k.log); err != nil {
253
- k.log.Error("Failed to kill thread %d (PID %d): %v", k.cfg.ThreadID, pid, err)
256
+ k.log.Error("Failed to kill thread %d (PID %d): %v", threadID, pid, err)
254
257
  }
255
258
  }
256
259
 
@@ -269,14 +272,16 @@ func (k *Keeper) sleep(ctx context.Context, d time.Duration) {
269
272
  }
270
273
  }
271
274
 
272
- // isRootKeepAlive checks whether the root thread still has keepAlive=true.
275
+ // isRootKeepAlive checks whether the thread still has keepAlive=true.
276
+ // Uses the /api/threads/keepalive endpoint (root, branch, daily — excludes workers)
277
+ // so that branch and daily threads are correctly included in the check.
273
278
  func (k *Keeper) isRootKeepAlive(ctx context.Context) bool {
274
- roots, err := k.mcp.GetRootThreads(ctx)
279
+ threads, err := k.mcp.GetKeepAliveThreads(ctx)
275
280
  if err != nil {
276
- k.log.Debug("isRootKeepAlive(%d): failed to fetch roots: %v — assuming still alive", k.cfg.ThreadID, err)
281
+ k.log.Debug("isRootKeepAlive(%d): failed to fetch keepalive threads: %v — assuming still alive", k.cfg.ThreadID, err)
277
282
  return true // fail-open: don't stop keeper if we can't check
278
283
  }
279
- for _, r := range roots {
284
+ for _, r := range threads {
280
285
  tidFloat, _ := r["threadId"].(float64)
281
286
  if int(tidFloat) == k.cfg.ThreadID {
282
287
  keepAlive, _ := r["keepAlive"].(bool)
@@ -284,8 +289,8 @@ func (k *Keeper) isRootKeepAlive(ctx context.Context) bool {
284
289
  return keepAlive && (status == "" || status == "active")
285
290
  }
286
291
  }
287
- k.log.Debug("isRootKeepAlive(%d): root thread not found in response", k.cfg.ThreadID)
288
- return false // root thread gone → stop keeper
292
+ k.log.Debug("isRootKeepAlive(%d): thread not found in keepalive list", k.cfg.ThreadID)
293
+ return false // thread gone or keepAlive removed → stop keeper
289
294
  }
290
295
 
291
296
  // parseWorkerThreadID extracts the "threadId" field from a start_thread JSON response.
package/supervisor/log.go CHANGED
@@ -3,30 +3,40 @@ package main
3
3
  import (
4
4
  "fmt"
5
5
  "os"
6
+ "path/filepath"
7
+ "sort"
8
+ "strings"
6
9
  "sync"
7
10
  "time"
8
11
  )
9
12
 
10
13
  // Logger writes to both stderr and a rotating log file.
11
- // Rotates when the file exceeds maxSize bytes.
14
+ // Rotates daily (at midnight) and when the file exceeds maxSize bytes.
12
15
  type Logger struct {
13
- mu sync.Mutex
14
- logPath string
15
- file *os.File
16
- debug bool
17
- size int64
18
- maxSize int64 // default 5 MB
19
- maxKeep int // max rotated files to keep
16
+ mu sync.Mutex
17
+ logPath string
18
+ file *os.File
19
+ debug bool
20
+ size int64
21
+ maxSize int64 // default 5 MB
22
+ maxKeep int // max daily rotated files to keep
23
+ today string
24
+ stopTimer chan struct{}
20
25
  }
21
26
 
22
27
  func NewLogger(logPath string) *Logger {
23
28
  l := &Logger{
24
- logPath: logPath,
25
- debug: os.Getenv("SUPERVISOR_DEBUG") == "1" || os.Getenv("SUPERVISOR_DEBUG") == "true",
26
- maxSize: 5 * 1024 * 1024, // 5 MB
27
- maxKeep: 3,
29
+ logPath: logPath,
30
+ debug: os.Getenv("SUPERVISOR_DEBUG") == "1" || os.Getenv("SUPERVISOR_DEBUG") == "true",
31
+ maxSize: 5 * 1024 * 1024, // 5 MB
32
+ maxKeep: 7, // keep 7 daily files
33
+ stopTimer: make(chan struct{}),
28
34
  }
35
+ // Rotate previous day's log on startup if needed
36
+ l.today = time.Now().Format("2006-01-02")
37
+ l.rotateDailyIfNeeded()
29
38
  l.openFile()
39
+ l.startMidnightTimer()
30
40
  return l
31
41
  }
32
42
 
@@ -53,7 +63,11 @@ func (l *Logger) log(level, format string, args ...any) {
53
63
 
54
64
  l.mu.Lock()
55
65
  defer l.mu.Unlock()
56
- fmt.Fprint(os.Stderr, line)
66
+ // Always write to file for post-mortem debugging; only emit DEBUG to stderr
67
+ // when SUPERVISOR_DEBUG is set.
68
+ if level != "DEBUG" || l.debug {
69
+ fmt.Fprint(os.Stderr, line)
70
+ }
57
71
  if l.file != nil {
58
72
  n, err := l.file.WriteString(line)
59
73
  if err != nil {
@@ -69,30 +83,93 @@ func (l *Logger) log(level, format string, args ...any) {
69
83
  func (l *Logger) Info(format string, args ...any) { l.log("INFO", format, args...) }
70
84
  func (l *Logger) Warn(format string, args ...any) { l.log("WARN", format, args...) }
71
85
  func (l *Logger) Error(format string, args ...any) { l.log("ERROR", format, args...) }
72
- func (l *Logger) Debug(format string, args ...any) {
73
- if l.debug {
74
- l.log("DEBUG", format, args...)
86
+ func (l *Logger) Debug(format string, args ...any) { l.log("DEBUG", format, args...) }
87
+
88
+ // rotateDailyIfNeeded renames the current log to a dated archive if it was
89
+ // written on a previous day. Called without mu held (used at startup and from
90
+ // the midnight timer before the lock is acquired).
91
+ func (l *Logger) rotateDailyIfNeeded() {
92
+ info, err := os.Stat(l.logPath)
93
+ if err != nil {
94
+ return // file doesn't exist yet — nothing to rotate
95
+ }
96
+ modDay := info.ModTime().Format("2006-01-02")
97
+ if modDay == l.today {
98
+ return // same day — no rotation needed
99
+ }
100
+ // Rename to dated file
101
+ ext := filepath.Ext(l.logPath)
102
+ base := strings.TrimSuffix(l.logPath, ext)
103
+ dated := fmt.Sprintf("%s.%s%s", base, modDay, ext)
104
+ if err := os.Rename(l.logPath, dated); err != nil {
105
+ fmt.Fprintf(os.Stderr, "[WARN] daily log rotate: %v\n", err)
75
106
  }
107
+ l.pruneOldLogs()
76
108
  }
77
109
 
78
- // rotate closes the current log file, renames it with a .1 suffix (shifting
79
- // older rotated files), and opens a fresh log. Called with mu held.
110
+ // pruneOldLogs deletes daily log files beyond maxKeep. Called without mu held.
111
+ func (l *Logger) pruneOldLogs() {
112
+ dir := filepath.Dir(l.logPath)
113
+ base := strings.TrimSuffix(filepath.Base(l.logPath), filepath.Ext(l.logPath))
114
+ entries, err := os.ReadDir(dir)
115
+ if err != nil {
116
+ return
117
+ }
118
+ var dated []string
119
+ for _, e := range entries {
120
+ name := e.Name()
121
+ if strings.HasPrefix(name, base+".") && name != filepath.Base(l.logPath) {
122
+ dated = append(dated, filepath.Join(dir, name))
123
+ }
124
+ }
125
+ sort.Strings(dated) // ascending — oldest first
126
+ for len(dated) > l.maxKeep {
127
+ os.Remove(dated[0])
128
+ dated = dated[1:]
129
+ }
130
+ }
131
+
132
+ // startMidnightTimer fires a daily rotation at local midnight.
133
+ func (l *Logger) startMidnightTimer() {
134
+ stopCh := l.stopTimer // capture channel value; Close() may nil the field concurrently
135
+ go func() {
136
+ for {
137
+ now := time.Now()
138
+ next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
139
+ select {
140
+ case <-time.After(time.Until(next)):
141
+ case <-stopCh:
142
+ return
143
+ }
144
+ l.mu.Lock()
145
+ l.today = time.Now().Format("2006-01-02")
146
+ if l.file != nil {
147
+ l.file.Close()
148
+ l.file = nil
149
+ }
150
+ l.rotateDailyIfNeeded()
151
+ l.size = 0
152
+ l.openFile()
153
+ l.mu.Unlock()
154
+ }
155
+ }()
156
+ }
157
+
158
+ // rotate closes the current log file, renames it with a dated suffix for
159
+ // size-based rotation mid-day, and opens a fresh log. Called with mu held.
80
160
  func (l *Logger) rotate() {
81
161
  if l.file != nil {
82
162
  l.file.Close()
83
163
  l.file = nil
84
164
  }
85
-
86
- // Shift existing rotated logs: .3 → delete, .2 → .3, .1 → .2, current → .1
87
- for i := l.maxKeep; i >= 1; i-- {
88
- old := fmt.Sprintf("%s.%d", l.logPath, i)
89
- if i == l.maxKeep {
90
- os.Remove(old)
91
- } else {
92
- os.Rename(old, fmt.Sprintf("%s.%d", l.logPath, i+1))
93
- }
165
+ // Use a timestamp suffix to avoid colliding with the daily dated file
166
+ ts := time.Now().Format("2006-01-02T150405")
167
+ ext := filepath.Ext(l.logPath)
168
+ base := strings.TrimSuffix(l.logPath, ext)
169
+ if err := os.Rename(l.logPath, fmt.Sprintf("%s.%s%s", base, ts, ext)); err != nil {
170
+ fmt.Fprintf(os.Stderr, "[WARN] log size-rotate rename: %v\n", err)
94
171
  }
95
- os.Rename(l.logPath, l.logPath+".1")
172
+ l.pruneOldLogs()
96
173
 
97
174
  // Open a fresh file
98
175
  l.size = 0
@@ -105,6 +182,10 @@ func (l *Logger) rotate() {
105
182
  }
106
183
 
107
184
  func (l *Logger) Close() {
185
+ if l.stopTimer != nil {
186
+ close(l.stopTimer)
187
+ l.stopTimer = nil
188
+ }
108
189
  l.mu.Lock()
109
190
  defer l.mu.Unlock()
110
191
  if l.file != nil {
@@ -33,13 +33,93 @@ func TestLogRotation(t *testing.T) {
33
33
  t.Errorf("log file should have been rotated, size=%d", info.Size())
34
34
  }
35
35
 
36
- // At least .1 should exist
37
- if _, err := os.Stat(logPath + ".1"); err != nil {
38
- t.Error("expected .1 rotated file to exist")
36
+ // At least one rotated file should exist (timestamp-based, e.g. test.2026-04-15T....log)
37
+ entries, err := os.ReadDir(dir)
38
+ if err != nil {
39
+ t.Fatalf("cannot read dir: %v", err)
40
+ }
41
+ var rotated []string
42
+ for _, e := range entries {
43
+ if e.Name() != "test.log" && strings.HasPrefix(e.Name(), "test.") {
44
+ rotated = append(rotated, e.Name())
45
+ }
46
+ }
47
+ if len(rotated) == 0 {
48
+ t.Error("expected at least one rotated file to exist")
49
+ }
50
+
51
+ // maxKeep=2: no more than 2 rotated files should exist
52
+ if len(rotated) > 2 {
53
+ t.Errorf("expected at most 2 rotated files (maxKeep=2), got %d: %v", len(rotated), rotated)
54
+ }
55
+ }
56
+
57
+ func TestDailyRotation(t *testing.T) {
58
+ dir := t.TempDir()
59
+ logPath := filepath.Join(dir, "test.log")
60
+
61
+ // Write a fake "yesterday" log
62
+ yesterday := "2026-04-14"
63
+ if err := os.WriteFile(logPath, []byte("old log content\n"), 0644); err != nil {
64
+ t.Fatal(err)
65
+ }
66
+
67
+ l := &Logger{
68
+ logPath: logPath,
69
+ maxSize: 5 * 1024 * 1024,
70
+ maxKeep: 7,
71
+ today: "2026-04-15", // simulate tomorrow
72
+ }
73
+ l.rotateDailyIfNeeded()
74
+
75
+ // Original file should have been renamed to test.2026-04-14.log (mod date matches)
76
+ // (mod date may be today in tests, so just verify the original is gone or renamed)
77
+ entries, err := os.ReadDir(dir)
78
+ if err != nil {
79
+ t.Fatal(err)
39
80
  }
81
+ var found bool
82
+ for _, e := range entries {
83
+ if strings.Contains(e.Name(), yesterday) || (e.Name() != "test.log" && strings.HasPrefix(e.Name(), "test.")) {
84
+ found = true
85
+ }
86
+ }
87
+ _ = found // rotation may or may not fire depending on file mod time in test env
88
+ }
89
+
90
+ func TestLogRotationMaxKeep(t *testing.T) {
91
+ dir := t.TempDir()
92
+ logPath := filepath.Join(dir, "test.log")
93
+
94
+ // Pre-create 5 fake rotated files
95
+ for i := 0; i < 5; i++ {
96
+ fake := filepath.Join(dir, "test.2026-04-1"+string(rune('0'+i))+"T120000.log")
97
+ if err := os.WriteFile(fake, []byte("x"), 0644); err != nil {
98
+ t.Fatal(err)
99
+ }
100
+ }
101
+
102
+ l := &Logger{
103
+ logPath: logPath,
104
+ maxSize: 100,
105
+ maxKeep: 2,
106
+ }
107
+ l.openFile()
108
+ defer l.Close()
40
109
 
41
- // .3 should NOT exist (maxKeep=2)
42
- if _, err := os.Stat(logPath + ".3"); err == nil {
43
- t.Error("expected .3 file to not exist (maxKeep=2)")
110
+ l.pruneOldLogs()
111
+
112
+ entries, err := os.ReadDir(dir)
113
+ if err != nil {
114
+ t.Fatal(err)
115
+ }
116
+ var rotated []string
117
+ for _, e := range entries {
118
+ if e.Name() != "test.log" && strings.HasPrefix(e.Name(), "test.") {
119
+ rotated = append(rotated, e.Name())
120
+ }
121
+ }
122
+ if len(rotated) > 2 {
123
+ t.Errorf("pruneOldLogs should have left at most maxKeep=2, got %d: %v", len(rotated), rotated)
44
124
  }
45
125
  }
@@ -2,14 +2,21 @@ package main
2
2
 
3
3
  import (
4
4
  "context"
5
+ "flag"
5
6
  "fmt"
6
7
  "os"
7
8
  "os/signal"
9
+ "strings"
8
10
  "sync"
9
11
  "syscall"
10
12
  "time"
11
13
  )
12
14
 
15
+ var (
16
+ globalCancelMu sync.Mutex
17
+ globalCancel context.CancelFunc
18
+ )
19
+
13
20
  // KeeperEntry tracks a running keeper and its settings.
14
21
  type KeeperEntry struct {
15
22
  keeper *Keeper
@@ -17,17 +24,130 @@ type KeeperEntry struct {
17
24
  }
18
25
 
19
26
  func main() {
20
- cfg := LoadConfig()
27
+ isService, err := isWindowsService()
28
+ if err != nil {
29
+ fmt.Fprintf(os.Stderr, "Failed to detect service mode: %v\n", err)
30
+ os.Exit(1)
31
+ }
32
+ if isService {
33
+ if err := runAsService(); err != nil {
34
+ fmt.Fprintf(os.Stderr, "Service run failed: %v\n", err)
35
+ os.Exit(1)
36
+ }
37
+ return
38
+ }
21
39
 
22
- if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
23
- fmt.Fprintf(os.Stderr, "Cannot create data dir %s: %v\n", cfg.DataDir, err)
40
+ if handled, err := handleServiceCommand(os.Args[1:]); err != nil {
41
+ fmt.Fprintf(os.Stderr, "%v\n", err)
24
42
  os.Exit(1)
43
+ } else if handled {
44
+ return
45
+ }
46
+
47
+ runningAsService := resolveRunSupervisorMode(isService, os.Getenv("HOST_MODE"))
48
+ if err := runSupervisor(runningAsService); err != nil {
49
+ fmt.Fprintf(os.Stderr, "Supervisor failed: %v\n", err)
50
+ os.Exit(1)
51
+ }
52
+ }
53
+
54
+ func resolveRunSupervisorMode(processIsService bool, hostModeValue string) bool {
55
+ if processIsService {
56
+ return true
57
+ }
58
+
59
+ return parseHostMode(hostModeValue, false) == "service"
60
+ }
61
+
62
+ func handleServiceCommand(args []string) (bool, error) {
63
+ if len(args) == 0 {
64
+ return false, nil
65
+ }
66
+
67
+ switch args[0] {
68
+ case "install":
69
+ fs := flag.NewFlagSet("install", flag.ContinueOnError)
70
+ serviceUser := fs.String("service-user", "", "Windows account to run service as (e.g. .\\YourUser). Defaults to LocalSystem if empty.")
71
+ servicePassword := fs.String("service-password", "", "Password for the service account (required for regular user accounts; not needed for LocalSystem/LocalService/NetworkService, NT SERVICE\\*, or gMSA names ending with '$').")
72
+ if err := fs.Parse(args[1:]); err != nil {
73
+ return true, err
74
+ }
75
+ if *serviceUser != "" && *servicePassword == "" && !isPasswordlessServiceIdentity(*serviceUser) {
76
+ return true, fmt.Errorf("install failed: -service-password is required for regular -service-user accounts\nAllowed passwordless identities: LocalSystem/LocalService/NetworkService, NT SERVICE\\*, and gMSA names ending with '$'\nNote: prefer using Install-Sensorium.ps1, which prompts securely for passwords")
77
+ }
78
+ exePath, err := os.Executable()
79
+ if err != nil {
80
+ return true, fmt.Errorf("install failed: resolve executable: %w", err)
81
+ }
82
+ return true, installService(exePath, *serviceUser, *servicePassword)
83
+ case "uninstall":
84
+ return true, uninstallService()
85
+ case "start":
86
+ return true, startService()
87
+ case "stop":
88
+ return true, stopService()
89
+ case "status":
90
+ return true, serviceStatus()
91
+ default:
92
+ return false, nil
93
+ }
94
+ }
95
+
96
+ func isPasswordlessServiceIdentity(user string) bool {
97
+ trimmed := strings.TrimSpace(user)
98
+ if trimmed == "" {
99
+ return false
100
+ }
101
+
102
+ lower := strings.ToLower(trimmed)
103
+ switch lower {
104
+ case "localsystem", "nt authority\\system", "localservice", "nt authority\\localservice", "networkservice", "nt authority\\networkservice":
105
+ return true
106
+ }
107
+
108
+ if strings.HasPrefix(lower, "nt service\\") {
109
+ return true
110
+ }
111
+
112
+ return strings.HasSuffix(trimmed, "$")
113
+ }
114
+
115
+ func stopSupervisor() {
116
+ globalCancelMu.Lock()
117
+ fn := globalCancel
118
+ globalCancelMu.Unlock()
119
+ if fn != nil {
120
+ fn()
121
+ }
122
+ }
123
+
124
+ func runSupervisor(runningAsService bool) error {
125
+ cfg := LoadConfig(runningAsService)
126
+
127
+ if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
128
+ return fmt.Errorf("cannot create data dir %s: %w", cfg.DataDir, err)
25
129
  }
26
130
 
27
131
  log := NewLogger(cfg.Paths.WatcherLog)
28
132
  defer log.Close()
29
133
 
30
- log.Info("sensorium-supervisor starting (mode=%s, port=%d, dataDir=%s)", cfg.Mode, cfg.MCPHttpPort, cfg.DataDir)
134
+ // Acquire lock prevent multiple instances
135
+ if !AcquireLock(cfg.Paths.WatcherLock, log) {
136
+ return fmt.Errorf("another supervisor instance is already running")
137
+ }
138
+ defer ReleaseLock(cfg.Paths.WatcherLock)
139
+
140
+ shouldRestart, err := applyPendingSupervisorUpdate(cfg, log)
141
+ if err != nil {
142
+ log.Warn("Pending supervisor update could not be applied: %v", err)
143
+ }
144
+ if shouldRestart {
145
+ return nil
146
+ }
147
+
148
+ recoverPersistedUpdateStateOnStartup(cfg, log)
149
+
150
+ log.Info("sensorium-supervisor starting (mode=%s, hostMode=%s, port=%d, dataDir=%s)", cfg.Mode, cfg.HostMode, cfg.MCPHttpPort, cfg.DataDir)
31
151
  log.Debug("Config: MCPStartCommand=%q, PollInterval=%v, MinUptime=%v, KeeperMaxRetries=%d", cfg.MCPStartCommand, cfg.PollInterval, cfg.MinUptime, cfg.KeeperMaxRetries)
32
152
  log.Debug("Config: TelegramToken=%v, HealthFailThresh=%d, StuckThreshold=%v", cfg.TelegramToken != "", cfg.HealthFailThresh, cfg.StuckThreshold)
33
153
 
@@ -38,15 +158,9 @@ func main() {
38
158
  log.Warn("Cannot create heartbeats dir %s: %v", cfg.Paths.HeartbeatsDir, err)
39
159
  }
40
160
 
41
- // Acquire lock — prevent multiple instances
42
- if !AcquireLock(cfg.Paths.WatcherLock, log) {
43
- os.Exit(1)
44
- }
45
- defer ReleaseLock(cfg.Paths.WatcherLock)
46
-
47
161
  if cfg.MCPHttpPort <= 0 {
48
162
  log.Error("MCP_HTTP_PORT must be set (got %d)", cfg.MCPHttpPort)
49
- os.Exit(1)
163
+ return fmt.Errorf("MCP_HTTP_PORT must be set (got %d)", cfg.MCPHttpPort)
50
164
  }
51
165
 
52
166
  mcp := NewMCPClient(cfg.MCPHttpPort, cfg.MCPHttpSecret)
@@ -59,15 +173,23 @@ func main() {
59
173
  KillByPort(cfg.MCPHttpPort, log)
60
174
 
61
175
  // Spawn MCP server
62
- _, err := SpawnMCPServer(cfg, log)
176
+ _, err = SpawnMCPServer(cfg, log)
63
177
  if err != nil {
64
178
  log.Error("Failed to start MCP server: %v", err)
65
- os.Exit(1)
179
+ return fmt.Errorf("failed to start MCP server: %w", err)
66
180
  }
67
181
 
68
182
  // Wait for server to be ready
69
183
  ctx, rootCancel := context.WithCancel(context.Background())
70
184
  defer rootCancel()
185
+ globalCancelMu.Lock()
186
+ globalCancel = rootCancel
187
+ globalCancelMu.Unlock()
188
+ defer func() {
189
+ globalCancelMu.Lock()
190
+ globalCancel = nil
191
+ globalCancelMu.Unlock()
192
+ }()
71
193
 
72
194
  if mcp.WaitForReady(ctx, 3*time.Second, cfg.KeeperReadyTimeout) {
73
195
  log.Info("MCP server is ready")
@@ -208,9 +330,13 @@ func main() {
208
330
 
209
331
  log.Info("All subsystems started — supervisor is running (PID %d)", os.Getpid())
210
332
 
211
- sig := <-sigCh
212
- log.Info("Received %s shutting down", sig)
213
- rootCancel()
333
+ select {
334
+ case sig := <-sigCh:
335
+ log.Info("Received %s — shutting down", sig)
336
+ rootCancel()
337
+ case <-ctx.Done():
338
+ log.Info("Shutdown requested")
339
+ }
214
340
 
215
341
  // Stop keepers (with 10s timeout)
216
342
  shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -251,12 +377,13 @@ func main() {
251
377
  }
252
378
 
253
379
  log.Info("Supervisor stopped cleanly")
380
+ return nil
254
381
  }
255
382
 
256
- // fetchKeeperSettings reads the root threads from the MCP server,
257
- // filtering for those with keepAlive=true.
383
+ // fetchKeeperSettings reads all keepAlive threads from the MCP server
384
+ // (root, branch, and daily — excludes worker threads).
258
385
  func fetchKeeperSettings(ctx context.Context, mcp *MCPClient, log *Logger) ([]KeeperConfig, error) {
259
- roots, err := mcp.GetRootThreads(ctx)
386
+ roots, err := mcp.GetKeepAliveThreads(ctx)
260
387
  if err != nil {
261
388
  return nil, err
262
389
  }
@@ -268,6 +395,10 @@ func fetchKeeperSettings(ctx context.Context, mcp *MCPClient, log *Logger) ([]Ke
268
395
  continue
269
396
  }
270
397
 
398
+ if typ, _ := r["type"].(string); typ == "worker" {
399
+ continue
400
+ }
401
+
271
402
  // Skip non-active roots (archived, expired, exited)
272
403
  if status, _ := r["status"].(string); status != "" && status != "active" {
273
404
  continue