@swarmclawai/swarmclaw 0.7.1 → 0.7.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 (237) hide show
  1. package/README.md +155 -150
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/route.ts +26 -0
  4. package/src/app/api/agents/[id]/thread/route.ts +37 -9
  5. package/src/app/api/agents/route.ts +13 -2
  6. package/src/app/api/auth/route.ts +76 -7
  7. package/src/app/api/chatrooms/[id]/chat/route.ts +7 -2
  8. package/src/app/api/{sessions → chats}/[id]/browser/route.ts +5 -1
  9. package/src/app/api/{sessions → chats}/[id]/chat/route.ts +7 -3
  10. package/src/app/api/{sessions → chats}/[id]/checkpoints/route.ts +1 -1
  11. package/src/app/api/chats/[id]/main-loop/route.ts +13 -0
  12. package/src/app/api/{sessions → chats}/[id]/messages/route.ts +19 -13
  13. package/src/app/api/{sessions → chats}/[id]/restore/route.ts +1 -1
  14. package/src/app/api/{sessions → chats}/[id]/route.ts +22 -52
  15. package/src/app/api/{sessions → chats}/[id]/stop/route.ts +6 -1
  16. package/src/app/api/{sessions → chats}/route.ts +21 -7
  17. package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
  18. package/src/app/api/connectors/doctor/route.ts +13 -0
  19. package/src/app/api/files/open/route.ts +16 -14
  20. package/src/app/api/memory/maintenance/route.ts +11 -1
  21. package/src/app/api/openclaw/agent-files/route.ts +27 -4
  22. package/src/app/api/openclaw/skills/route.ts +11 -3
  23. package/src/app/api/plugins/dependencies/route.ts +24 -0
  24. package/src/app/api/plugins/install/route.ts +15 -92
  25. package/src/app/api/plugins/route.ts +6 -26
  26. package/src/app/api/plugins/settings/route.ts +40 -0
  27. package/src/app/api/plugins/ui/route.ts +1 -0
  28. package/src/app/api/settings/route.ts +49 -7
  29. package/src/app/api/tasks/[id]/route.ts +15 -6
  30. package/src/app/api/tasks/bulk/route.ts +2 -2
  31. package/src/app/api/tasks/route.ts +9 -4
  32. package/src/app/api/usage/route.ts +30 -0
  33. package/src/app/api/webhooks/[id]/route.ts +8 -1
  34. package/src/app/page.tsx +9 -2
  35. package/src/cli/index.js +39 -33
  36. package/src/cli/index.ts +43 -49
  37. package/src/cli/spec.js +29 -27
  38. package/src/components/agents/agent-card.tsx +16 -13
  39. package/src/components/agents/agent-chat-list.tsx +104 -4
  40. package/src/components/agents/agent-list.tsx +54 -22
  41. package/src/components/agents/agent-sheet.tsx +209 -18
  42. package/src/components/agents/cron-job-form.tsx +3 -3
  43. package/src/components/agents/inspector-panel.tsx +110 -50
  44. package/src/components/auth/access-key-gate.tsx +36 -97
  45. package/src/components/auth/setup-wizard.tsx +5 -38
  46. package/src/components/chat/chat-area.tsx +39 -27
  47. package/src/components/{sessions/session-card.tsx → chat/chat-card.tsx} +7 -23
  48. package/src/components/chat/chat-header.tsx +299 -314
  49. package/src/components/{sessions/session-list.tsx → chat/chat-list.tsx} +11 -14
  50. package/src/components/chat/chat-tool-toggles.tsx +26 -17
  51. package/src/components/chat/checkpoint-timeline.tsx +4 -4
  52. package/src/components/chat/message-bubble.tsx +4 -1
  53. package/src/components/chat/message-list.tsx +5 -3
  54. package/src/components/chat/session-debug-panel.tsx +1 -1
  55. package/src/components/chat/tool-request-banner.tsx +3 -3
  56. package/src/components/chatrooms/agent-hover-card.tsx +3 -3
  57. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +2 -2
  58. package/src/components/chatrooms/chatroom-view.tsx +347 -205
  59. package/src/components/connectors/connector-list.tsx +265 -127
  60. package/src/components/connectors/connector-sheet.tsx +218 -1
  61. package/src/components/home/home-view.tsx +129 -5
  62. package/src/components/layout/app-layout.tsx +392 -182
  63. package/src/components/layout/mobile-header.tsx +26 -8
  64. package/src/components/plugins/plugin-list.tsx +487 -254
  65. package/src/components/plugins/plugin-sheet.tsx +236 -13
  66. package/src/components/projects/project-detail.tsx +183 -0
  67. package/src/components/settings/gateway-connection-panel.tsx +1 -1
  68. package/src/components/shared/agent-picker-list.tsx +2 -2
  69. package/src/components/shared/command-palette.tsx +111 -25
  70. package/src/components/shared/settings/plugin-manager.tsx +20 -4
  71. package/src/components/shared/settings/section-capability-policy.tsx +105 -0
  72. package/src/components/shared/settings/section-heartbeat.tsx +78 -1
  73. package/src/components/shared/settings/section-orchestrator.tsx +3 -3
  74. package/src/components/shared/settings/section-providers.tsx +1 -1
  75. package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
  76. package/src/components/shared/settings/section-secrets.tsx +6 -6
  77. package/src/components/shared/settings/section-user-preferences.tsx +1 -1
  78. package/src/components/shared/settings/section-voice.tsx +5 -1
  79. package/src/components/shared/settings/section-web-search.tsx +10 -2
  80. package/src/components/shared/settings/settings-page.tsx +244 -56
  81. package/src/components/tasks/approvals-panel.tsx +205 -18
  82. package/src/components/tasks/task-board.tsx +242 -46
  83. package/src/components/usage/metrics-dashboard.tsx +147 -1
  84. package/src/components/wallets/wallet-panel.tsx +17 -5
  85. package/src/components/webhooks/webhook-sheet.tsx +8 -8
  86. package/src/lib/auth.ts +17 -0
  87. package/src/lib/chat-streaming-state.test.ts +108 -0
  88. package/src/lib/chat-streaming-state.ts +108 -0
  89. package/src/lib/chat.ts +1 -1
  90. package/src/lib/{sessions.ts → chats.ts} +28 -18
  91. package/src/lib/openclaw-agent-id.test.ts +14 -0
  92. package/src/lib/openclaw-agent-id.ts +31 -0
  93. package/src/lib/providers/claude-cli.ts +1 -1
  94. package/src/lib/server/agent-assignment.test.ts +112 -0
  95. package/src/lib/server/agent-assignment.ts +169 -0
  96. package/src/lib/server/approval-connector-notify.test.ts +253 -0
  97. package/src/lib/server/approvals-auto-approve.test.ts +205 -0
  98. package/src/lib/server/approvals.ts +483 -75
  99. package/src/lib/server/autonomy-runtime.test.ts +341 -0
  100. package/src/lib/server/browser-state.test.ts +118 -0
  101. package/src/lib/server/browser-state.ts +123 -0
  102. package/src/lib/server/build-llm.test.ts +36 -0
  103. package/src/lib/server/build-llm.ts +11 -4
  104. package/src/lib/server/builtin-plugins.ts +34 -0
  105. package/src/lib/server/capability-router.ts +10 -8
  106. package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
  107. package/src/lib/server/chat-execution-tool-events.test.ts +134 -0
  108. package/src/lib/server/chat-execution.ts +285 -165
  109. package/src/lib/server/chatroom-health.test.ts +26 -0
  110. package/src/lib/server/chatroom-health.ts +2 -3
  111. package/src/lib/server/chatroom-helpers.test.ts +67 -2
  112. package/src/lib/server/chatroom-helpers.ts +48 -8
  113. package/src/lib/server/connectors/discord.ts +175 -11
  114. package/src/lib/server/connectors/doctor.test.ts +80 -0
  115. package/src/lib/server/connectors/doctor.ts +116 -0
  116. package/src/lib/server/connectors/manager.ts +948 -112
  117. package/src/lib/server/connectors/policy.test.ts +222 -0
  118. package/src/lib/server/connectors/policy.ts +452 -0
  119. package/src/lib/server/connectors/slack.ts +188 -9
  120. package/src/lib/server/connectors/telegram.ts +65 -15
  121. package/src/lib/server/connectors/thread-context.test.ts +44 -0
  122. package/src/lib/server/connectors/thread-context.ts +72 -0
  123. package/src/lib/server/connectors/types.ts +41 -11
  124. package/src/lib/server/cost.ts +34 -1
  125. package/src/lib/server/daemon-state.ts +61 -3
  126. package/src/lib/server/data-dir.ts +13 -0
  127. package/src/lib/server/delegation-jobs.test.ts +140 -0
  128. package/src/lib/server/delegation-jobs.ts +248 -0
  129. package/src/lib/server/document-utils.test.ts +47 -0
  130. package/src/lib/server/document-utils.ts +397 -0
  131. package/src/lib/server/heartbeat-service.ts +14 -40
  132. package/src/lib/server/heartbeat-source.test.ts +22 -0
  133. package/src/lib/server/heartbeat-source.ts +7 -0
  134. package/src/lib/server/identity-continuity.test.ts +77 -0
  135. package/src/lib/server/identity-continuity.ts +127 -0
  136. package/src/lib/server/mailbox-utils.ts +347 -0
  137. package/src/lib/server/main-agent-loop.ts +28 -1103
  138. package/src/lib/server/memory-db.ts +4 -6
  139. package/src/lib/server/memory-tiers.ts +40 -0
  140. package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
  141. package/src/lib/server/openclaw-agent-resolver.ts +128 -0
  142. package/src/lib/server/openclaw-exec-config.ts +5 -6
  143. package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
  144. package/src/lib/server/openclaw-skills-normalize.ts +136 -0
  145. package/src/lib/server/openclaw-sync.ts +3 -2
  146. package/src/lib/server/orchestrator-lg.ts +20 -9
  147. package/src/lib/server/orchestrator.ts +7 -7
  148. package/src/lib/server/playwright-proxy.mjs +27 -3
  149. package/src/lib/server/plugins.test.ts +207 -0
  150. package/src/lib/server/plugins.ts +927 -66
  151. package/src/lib/server/provider-health.ts +38 -6
  152. package/src/lib/server/queue.ts +13 -28
  153. package/src/lib/server/scheduler.ts +2 -0
  154. package/src/lib/server/session-archive-memory.test.ts +85 -0
  155. package/src/lib/server/session-archive-memory.ts +230 -0
  156. package/src/lib/server/session-mailbox.ts +8 -18
  157. package/src/lib/server/session-reset-policy.test.ts +99 -0
  158. package/src/lib/server/session-reset-policy.ts +311 -0
  159. package/src/lib/server/session-run-manager.ts +33 -82
  160. package/src/lib/server/session-tools/autonomy-tools.test.ts +105 -0
  161. package/src/lib/server/session-tools/calendar.ts +366 -0
  162. package/src/lib/server/session-tools/canvas.ts +1 -1
  163. package/src/lib/server/session-tools/chatroom.ts +4 -2
  164. package/src/lib/server/session-tools/connector.ts +114 -10
  165. package/src/lib/server/session-tools/context.ts +21 -5
  166. package/src/lib/server/session-tools/crawl.ts +447 -0
  167. package/src/lib/server/session-tools/crud.ts +74 -28
  168. package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
  169. package/src/lib/server/session-tools/delegate.ts +497 -24
  170. package/src/lib/server/session-tools/discovery.ts +24 -6
  171. package/src/lib/server/session-tools/document.ts +283 -0
  172. package/src/lib/server/session-tools/edit_file.ts +4 -2
  173. package/src/lib/server/session-tools/email.ts +320 -0
  174. package/src/lib/server/session-tools/extract.ts +137 -0
  175. package/src/lib/server/session-tools/file-normalize.test.ts +93 -0
  176. package/src/lib/server/session-tools/file-send.test.ts +84 -1
  177. package/src/lib/server/session-tools/file.ts +241 -25
  178. package/src/lib/server/session-tools/git.ts +1 -1
  179. package/src/lib/server/session-tools/http.ts +1 -1
  180. package/src/lib/server/session-tools/human-loop.ts +227 -0
  181. package/src/lib/server/session-tools/image-gen.ts +380 -0
  182. package/src/lib/server/session-tools/index.ts +130 -50
  183. package/src/lib/server/session-tools/mailbox.ts +276 -0
  184. package/src/lib/server/session-tools/memory.ts +172 -3
  185. package/src/lib/server/session-tools/monitor.ts +151 -8
  186. package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
  187. package/src/lib/server/session-tools/openclaw-nodes.ts +1 -1
  188. package/src/lib/server/session-tools/openclaw-workspace.ts +1 -1
  189. package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
  190. package/src/lib/server/session-tools/platform.ts +148 -7
  191. package/src/lib/server/session-tools/plugin-creator.ts +89 -26
  192. package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
  193. package/src/lib/server/session-tools/replicate.ts +301 -0
  194. package/src/lib/server/session-tools/sample-ui.ts +1 -1
  195. package/src/lib/server/session-tools/sandbox.ts +4 -2
  196. package/src/lib/server/session-tools/schedule.ts +24 -12
  197. package/src/lib/server/session-tools/session-info.ts +43 -7
  198. package/src/lib/server/session-tools/session-tools-wiring.test.ts +31 -17
  199. package/src/lib/server/session-tools/shell.ts +5 -2
  200. package/src/lib/server/session-tools/subagent.ts +194 -28
  201. package/src/lib/server/session-tools/table.ts +587 -0
  202. package/src/lib/server/session-tools/wallet.ts +42 -12
  203. package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
  204. package/src/lib/server/session-tools/web.ts +926 -91
  205. package/src/lib/server/storage.ts +255 -16
  206. package/src/lib/server/stream-agent-chat.ts +116 -268
  207. package/src/lib/server/structured-extract.test.ts +72 -0
  208. package/src/lib/server/structured-extract.ts +373 -0
  209. package/src/lib/server/task-mention.test.ts +16 -2
  210. package/src/lib/server/task-mention.ts +61 -10
  211. package/src/lib/server/tool-aliases.ts +66 -18
  212. package/src/lib/server/tool-capability-policy.test.ts +9 -9
  213. package/src/lib/server/tool-capability-policy.ts +38 -27
  214. package/src/lib/server/tool-retry.ts +2 -0
  215. package/src/lib/server/watch-jobs.test.ts +173 -0
  216. package/src/lib/server/watch-jobs.ts +532 -0
  217. package/src/lib/server/ws-hub.ts +5 -3
  218. package/src/lib/tool-definitions.ts +4 -0
  219. package/src/lib/validation/schemas.test.ts +26 -0
  220. package/src/lib/validation/schemas.ts +10 -1
  221. package/src/lib/ws-client.ts +14 -12
  222. package/src/proxy.ts +5 -5
  223. package/src/stores/use-app-store.ts +5 -11
  224. package/src/stores/use-chat-store.ts +38 -9
  225. package/src/types/index.ts +352 -47
  226. package/src/app/api/sessions/[id]/main-loop/route.ts +0 -94
  227. package/src/components/sessions/new-session-sheet.tsx +0 -253
  228. package/src/lib/server/main-session.ts +0 -24
  229. package/src/lib/server/session-run-manager.test.ts +0 -23
  230. /package/src/app/api/{sessions → chats}/[id]/clear/route.ts +0 -0
  231. /package/src/app/api/{sessions → chats}/[id]/deploy/route.ts +0 -0
  232. /package/src/app/api/{sessions → chats}/[id]/devserver/route.ts +0 -0
  233. /package/src/app/api/{sessions → chats}/[id]/edit-resend/route.ts +0 -0
  234. /package/src/app/api/{sessions → chats}/[id]/fork/route.ts +0 -0
  235. /package/src/app/api/{sessions → chats}/[id]/mailbox/route.ts +0 -0
  236. /package/src/app/api/{sessions → chats}/[id]/retry/route.ts +0 -0
  237. /package/src/app/api/{sessions → chats}/heartbeat/route.ts +0 -0
@@ -1,6 +1,6 @@
1
1
  import { spawnSync } from 'child_process'
2
2
 
3
- type DelegateTool = 'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli'
3
+ type DelegateTool = 'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli' | 'delegate_to_gemini_cli'
4
4
 
5
5
  interface ProviderHealthState {
6
6
  failures: number
@@ -15,6 +15,7 @@ const states: Map<string, ProviderHealthState> =
15
15
  (globalThis as any)[gk] ?? ((globalThis as any)[gk] = new Map<string, ProviderHealthState>())
16
16
 
17
17
  const cliCheckCache = new Map<string, { at: number; ok: boolean }>()
18
+ const delegateReadyCache = new Map<string, { at: number; ok: boolean }>()
18
19
  const CLI_CHECK_TTL_MS = 30_000
19
20
 
20
21
  function commandExists(binary: string): boolean {
@@ -66,12 +67,43 @@ export function isProviderCoolingDown(providerId: string): boolean {
66
67
  function delegateBinary(delegateTool: DelegateTool): string {
67
68
  if (delegateTool === 'delegate_to_claude_code') return 'claude'
68
69
  if (delegateTool === 'delegate_to_codex_cli') return 'codex'
70
+ if (delegateTool === 'delegate_to_gemini_cli') return 'gemini'
69
71
  return 'opencode'
70
72
  }
71
73
 
74
+ function delegateToolReady(delegateTool: DelegateTool): boolean {
75
+ const now = Date.now()
76
+ const cached = delegateReadyCache.get(delegateTool)
77
+ if (cached && now - cached.at < CLI_CHECK_TTL_MS) return cached.ok
78
+
79
+ const binary = delegateBinary(delegateTool)
80
+ let ok = commandExists(binary)
81
+ if (ok && delegateTool === 'delegate_to_claude_code') {
82
+ const probe = spawnSync(binary, ['auth', 'status'], { encoding: 'utf-8', timeout: 8000 })
83
+ if ((probe.status ?? 1) !== 0) {
84
+ let loggedIn = false
85
+ try {
86
+ const parsed = JSON.parse(probe.stdout || '{}') as { loggedIn?: boolean }
87
+ loggedIn = parsed.loggedIn === true
88
+ } catch {
89
+ loggedIn = false
90
+ }
91
+ ok = loggedIn
92
+ }
93
+ } else if (ok && delegateTool === 'delegate_to_codex_cli') {
94
+ const probe = spawnSync(binary, ['login', 'status'], { encoding: 'utf-8', timeout: 8000 })
95
+ const probeText = `${probe.stdout || ''}\n${probe.stderr || ''}`.toLowerCase()
96
+ ok = (probe.status ?? 1) === 0 && probeText.includes('logged in')
97
+ }
98
+
99
+ delegateReadyCache.set(delegateTool, { at: now, ok })
100
+ return ok
101
+ }
102
+
72
103
  function delegateProviderId(delegateTool: DelegateTool): string {
73
104
  if (delegateTool === 'delegate_to_claude_code') return 'claude-cli'
74
105
  if (delegateTool === 'delegate_to_codex_cli') return 'codex-cli'
106
+ if (delegateTool === 'delegate_to_gemini_cli') return 'gemini-cli'
75
107
  return 'opencode-cli'
76
108
  }
77
109
 
@@ -83,9 +115,9 @@ export function rankDelegatesByHealth(order: DelegateTool[]): DelegateTool[] {
83
115
  return true
84
116
  })
85
117
  return deduped.sort((a, b) => {
86
- const aBinOk = commandExists(delegateBinary(a))
87
- const bBinOk = commandExists(delegateBinary(b))
88
- if (aBinOk !== bBinOk) return aBinOk ? -1 : 1
118
+ const aReady = delegateToolReady(a)
119
+ const bReady = delegateToolReady(b)
120
+ if (aReady !== bReady) return aReady ? -1 : 1
89
121
 
90
122
  const aCool = isProviderCoolingDown(delegateProviderId(a))
91
123
  const bCool = isProviderCoolingDown(delegateProviderId(b))
@@ -202,14 +234,14 @@ export async function pingOpenClaw(
202
234
 
203
235
  /**
204
236
  * Ping a provider to check reachability. Returns `{ ok, message }`.
205
- * Skips CLI-based providers (claude-cli, codex-cli, opencode-cli) — returns ok.
237
+ * Skips CLI-based providers (claude-cli, codex-cli, opencode-cli, gemini-cli) — returns ok.
206
238
  */
207
239
  export async function pingProvider(
208
240
  provider: string,
209
241
  apiKey: string | undefined,
210
242
  endpoint: string | undefined,
211
243
  ): Promise<{ ok: boolean; message: string }> {
212
- const CLI_PROVIDERS = ['claude-cli', 'codex-cli', 'opencode-cli']
244
+ const CLI_PROVIDERS = ['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli']
213
245
  if (CLI_PROVIDERS.includes(provider)) return { ok: true, message: 'CLI provider — skipped.' }
214
246
 
215
247
  try {
@@ -4,14 +4,13 @@ import path from 'node:path'
4
4
  import { loadTasks, saveTasks, loadQueue, saveQueue, loadAgents, loadSchedules, saveSchedules, loadSessions, saveSessions, loadSettings, loadConnectors, UPLOAD_DIR } from './storage'
5
5
  import { notify } from './ws-hub'
6
6
  import { WORKSPACE_DIR } from './data-dir'
7
- import { createOrchestratorSession, executeOrchestrator } from './orchestrator'
7
+ import { createOrchestratorSession } from './orchestrator'
8
8
  import { formatValidationFailure, validateTaskCompletion } from './task-validation'
9
9
  import { ensureTaskCompletionReport } from './task-reports'
10
10
  import { pushMainLoopEventToMainSessions } from './main-agent-loop'
11
11
  import { executeSessionChatTurn } from './chat-execution'
12
12
  import { extractTaskResult, formatResultBody } from './task-result'
13
13
  import { getCheckpointSaver } from './langgraph-checkpoint'
14
- import { isProtectedMainSession } from './main-session'
15
14
  import { cascadeUnblock } from './dag-validation'
16
15
  import { performGuardianRollback } from './guardian'
17
16
  import type { Agent, BoardTask, Connector, Message } from '@/types'
@@ -282,10 +281,6 @@ function pushQueueUnique(queue: string[], id: string): void {
282
281
  if (!queueContains(queue, id)) queue.push(id)
283
282
  }
284
283
 
285
- function isMainSession(session: SessionLike | null | undefined): boolean {
286
- return isProtectedMainSession(session)
287
- }
288
-
289
284
  function resolveTaskOwnerUser(task: ScheduleTaskMeta, sessions: Record<string, SessionLike>): string | null {
290
285
  const direct = typeof task.user === 'string' ? task.user.trim() : ''
291
286
  if (direct) return direct
@@ -521,10 +516,8 @@ async function executeTaskRun(
521
516
  '- Include concrete evidence in your final summary: changed file paths, commands run, and verification results.',
522
517
  '- If blocked, state the blocker explicitly and what input or permission is missing.',
523
518
  ].join('\n')
524
- if (agent?.isOrchestrator) {
525
- return executeOrchestrator(agent, prompt, sessionId, task.id)
526
- }
527
-
519
+ // All agents (including orchestrators) go through the unified chat execution path.
520
+ // Agents with subAgentIds get delegation tools automatically via session-tools.
528
521
  const run = await executeSessionChatTurn({
529
522
  sessionId,
530
523
  message: prompt,
@@ -596,18 +589,7 @@ function notifyMainChatScheduleResult(task: BoardTask): void {
596
589
  return msg
597
590
  }
598
591
 
599
- for (const session of Object.values(sessions) as SessionLike[]) {
600
- if (!isMainSession(session)) continue
601
- if (ownerUser && session?.user && session.user !== ownerUser) continue
602
- const last = Array.isArray(session.messages) ? session.messages.at(-1) : null
603
- if (last?.role === 'assistant' && last?.text === body && typeof last?.time === 'number' && now - last.time < 30_000) continue
604
- if (!Array.isArray(session.messages)) session.messages = []
605
- session.messages.push(buildMsg())
606
- session.lastActiveAt = now
607
- changed = true
608
- }
609
-
610
- // Also push to the agent's persistent thread session
592
+ // Push to the agent's shortcut chat session.
611
593
  try {
612
594
  const agents = loadAgents()
613
595
  const agent = agents[task.agentId]
@@ -773,6 +755,7 @@ function notifyAgentThreadTaskResult(task: BoardTask): void {
773
755
  if (task.claudeResumeId) resumeLines.push(`Claude session: \`${task.claudeResumeId}\``)
774
756
  if (task.codexResumeId) resumeLines.push(`Codex thread: \`${task.codexResumeId}\``)
775
757
  if (task.opencodeResumeId) resumeLines.push(`OpenCode session: \`${task.opencodeResumeId}\``)
758
+ if (task.geminiResumeId) resumeLines.push(`Gemini session: \`${task.geminiResumeId}\``)
776
759
  // Fallback to legacy field
777
760
  if (resumeLines.length === 0 && task.cliResumeId) {
778
761
  resumeLines.push(`${task.cliProvider || 'CLI'} session: \`${task.cliResumeId}\``)
@@ -831,13 +814,12 @@ function notifyAgentThreadTaskResult(task: BoardTask): void {
831
814
  }
832
815
 
833
816
  // Push to delegating agent's active user-facing chat sessions
834
- // so the result is visible in the chat the user is looking at
817
+ // so the result is visible in the chat the user is looking at.
835
818
  if (delegator) {
836
819
  for (const session of Object.values(sessions)) {
837
820
  if (!session || session.agentId !== delegatedBy) continue
838
- // Skip thread sessions and orchestrated/subagent sessions
821
+ // Skip the agent shortcut session itself.
839
822
  if (session.id === delegator.threadSessionId) continue
840
- if (session.sessionType === 'orchestrated') continue
841
823
  // Only push to recently-active sessions (within last 30 minutes)
842
824
  const lastActive = typeof session.lastActiveAt === 'number' ? session.lastActiveAt : 0
843
825
  if (now - lastActive > 30 * 60_000) continue
@@ -1315,24 +1297,27 @@ export async function processNext() {
1315
1297
  const execSession = execSessions[sessionId] as Record<string, unknown> | undefined
1316
1298
  if (execSession) {
1317
1299
  const delegateIds = execSession.delegateResumeIds as
1318
- | { claudeCode?: string | null; codex?: string | null; opencode?: string | null }
1300
+ | { claudeCode?: string | null; codex?: string | null; opencode?: string | null; gemini?: string | null }
1319
1301
  | undefined
1320
1302
  // Store each CLI resume ID separately
1321
1303
  const claudeId = (execSession.claudeSessionId as string) || delegateIds?.claudeCode || null
1322
1304
  const codexId = (execSession.codexThreadId as string) || delegateIds?.codex || null
1323
1305
  const opencodeId = (execSession.opencodeSessionId as string) || delegateIds?.opencode || null
1306
+ const geminiId = delegateIds?.gemini || null
1324
1307
  if (claudeId) t2[taskId].claudeResumeId = claudeId
1325
1308
  if (codexId) t2[taskId].codexResumeId = codexId
1326
1309
  if (opencodeId) t2[taskId].opencodeResumeId = opencodeId
1310
+ if (geminiId) t2[taskId].geminiResumeId = geminiId
1327
1311
  // Keep backward-compat single field (first available)
1328
- const primaryId = claudeId || codexId || opencodeId
1312
+ const primaryId = claudeId || codexId || opencodeId || geminiId
1329
1313
  if (primaryId) {
1330
1314
  t2[taskId].cliResumeId = primaryId
1331
1315
  if (claudeId) t2[taskId].cliProvider = 'claude-cli'
1332
1316
  else if (codexId) t2[taskId].cliProvider = 'codex-cli'
1333
1317
  else if (opencodeId) t2[taskId].cliProvider = 'opencode-cli'
1318
+ else if (geminiId) t2[taskId].cliProvider = 'gemini-cli'
1334
1319
  }
1335
- console.log(`[queue] CLI resume IDs for task ${taskId}: claude=${claudeId}, codex=${codexId}, opencode=${opencodeId}`)
1320
+ console.log(`[queue] CLI resume IDs for task ${taskId}: claude=${claudeId}, codex=${codexId}, opencode=${opencodeId}, gemini=${geminiId}`)
1336
1321
  }
1337
1322
  } catch (e) {
1338
1323
  console.warn(`[queue] Failed to extract CLI resume IDs for task ${taskId}:`, e)
@@ -6,6 +6,7 @@ import { pushMainLoopEventToMainSessions } from './main-agent-loop'
6
6
  import { getScheduleSignatureKey } from '@/lib/schedule-dedupe'
7
7
  import { enqueueSystemEvent } from './system-events'
8
8
  import { requestHeartbeatNow } from './heartbeat-wake'
9
+ import { processDueWatchJobs } from './watch-jobs'
9
10
 
10
11
  const TICK_INTERVAL = 60_000 // 60 seconds
11
12
  let intervalId: ReturnType<typeof setInterval> | null = null
@@ -73,6 +74,7 @@ function computeNextRuns() {
73
74
 
74
75
  async function tick() {
75
76
  const now = Date.now()
77
+ await processDueWatchJobs(now)
76
78
  const schedules = loadSchedules()
77
79
  const agents = loadAgents()
78
80
  const tasks = loadTasks()
@@ -0,0 +1,85 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import type { Session } from '@/types'
4
+ import { buildSessionArchiveMarkdown, buildSessionArchivePayload } from './session-archive-memory'
5
+
6
+ test('buildSessionArchivePayload summarizes session transcript and metadata', () => {
7
+ const session = {
8
+ id: 'session-1',
9
+ name: 'Support Thread',
10
+ cwd: process.cwd(),
11
+ user: 'Alice',
12
+ provider: 'openai',
13
+ model: 'gpt-4.1',
14
+ claudeSessionId: null,
15
+ codexThreadId: null,
16
+ opencodeSessionId: null,
17
+ createdAt: Date.parse('2026-03-05T00:00:00.000Z'),
18
+ lastActiveAt: Date.parse('2026-03-05T10:00:00.000Z'),
19
+ sessionType: 'human',
20
+ messages: [
21
+ { role: 'user', text: 'Can you help me debug this issue?', time: 1 },
22
+ { role: 'assistant', text: 'Yes, show me the stack trace.', time: 2, toolEvents: [{ name: 'files', input: '{}' }] },
23
+ ],
24
+ identityState: { personaLabel: 'Debugger' },
25
+ } as Session
26
+
27
+ const payload = buildSessionArchivePayload(session, { name: 'Swarmy' })
28
+
29
+ assert.ok(payload)
30
+ assert.equal(payload?.title, 'Session archive: Support Thread')
31
+ assert.match(payload?.content || '', /Transcript excerpt:/)
32
+ assert.match(payload?.content || '', /Swarmy/)
33
+ assert.equal(payload?.metadata.tier, 'archive')
34
+ assert.equal(payload?.references[0]?.type, 'session')
35
+ })
36
+
37
+ test('buildSessionArchiveMarkdown creates a portable markdown snapshot', () => {
38
+ const session = {
39
+ id: 'session-3',
40
+ name: 'Architecture Review',
41
+ cwd: process.cwd(),
42
+ user: 'Alice',
43
+ provider: 'openai',
44
+ model: 'gpt-4.1',
45
+ claudeSessionId: null,
46
+ codexThreadId: null,
47
+ opencodeSessionId: null,
48
+ createdAt: Date.parse('2026-03-05T00:00:00.000Z'),
49
+ lastActiveAt: Date.parse('2026-03-05T10:00:00.000Z'),
50
+ sessionType: 'human',
51
+ messages: [
52
+ { role: 'user', text: 'Summarize the new connector policy.', time: 1 },
53
+ { role: 'assistant', text: 'It now uses scoped sessions and freshness resets.', time: 2 },
54
+ ],
55
+ identityState: { personaLabel: 'Reviewer' },
56
+ } as Session
57
+
58
+ const payload = buildSessionArchivePayload(session, { name: 'Swarmy' })
59
+ assert.ok(payload)
60
+
61
+ const markdown = buildSessionArchiveMarkdown(session, payload!, { name: 'Swarmy' })
62
+ assert.match(markdown, /^# Session archive: Architecture Review/m)
63
+ assert.match(markdown, /## Archive Snapshot/)
64
+ assert.match(markdown, /## Transcript Excerpt/)
65
+ assert.match(markdown, /\*\*Swarmy\*\*/)
66
+ })
67
+
68
+ test('buildSessionArchivePayload skips trivial sessions', () => {
69
+ const session = {
70
+ id: 'session-2',
71
+ name: 'Too Short',
72
+ cwd: process.cwd(),
73
+ user: 'Bob',
74
+ provider: 'openai',
75
+ model: 'gpt-4.1',
76
+ claudeSessionId: null,
77
+ codexThreadId: null,
78
+ opencodeSessionId: null,
79
+ createdAt: 1,
80
+ lastActiveAt: 1,
81
+ messages: [{ role: 'user', text: 'hi', time: 1 }],
82
+ } as Session
83
+
84
+ assert.equal(buildSessionArchivePayload(session), null)
85
+ })
@@ -0,0 +1,230 @@
1
+ import { createHash } from 'crypto'
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+ import type { Agent, MemoryEntry, MemoryReference, Session } from '@/types'
5
+ import { getMemoryDb } from './memory-db'
6
+ import { loadAgents, loadSessions, saveSessions } from './storage'
7
+ import { DATA_DIR } from './data-dir'
8
+
9
+ const MAX_ARCHIVE_MESSAGES = 36
10
+ const MAX_ARCHIVE_LINE_CHARS = 320
11
+ const SESSION_ARCHIVE_EXPORT_DIR = path.join(DATA_DIR, 'session-archives')
12
+
13
+ function toOneLine(value: unknown, maxChars: number): string {
14
+ return String(value || '').replace(/\s+/g, ' ').trim().slice(0, maxChars)
15
+ }
16
+
17
+ function messageSpeaker(session: Session, agent: Partial<Agent> | null | undefined, message: Session['messages'][number]): string {
18
+ if (message.role === 'assistant') return agent?.name || 'assistant'
19
+ return session.connectorContext?.senderName || session.user || 'user'
20
+ }
21
+
22
+ function slugifySegment(value: string, fallback: string): string {
23
+ const normalized = value
24
+ .toLowerCase()
25
+ .replace(/[^a-z0-9._-]+/g, '-')
26
+ .replace(/^-+|-+$/g, '')
27
+ return normalized || fallback
28
+ }
29
+
30
+ export function buildSessionArchivePayload(
31
+ session: Session,
32
+ agent?: Partial<Agent> | null,
33
+ ): {
34
+ title: string
35
+ content: string
36
+ metadata: Record<string, unknown>
37
+ references: MemoryReference[]
38
+ hash: string
39
+ } | null {
40
+ if (!Array.isArray(session.messages) || session.messages.length < 2) return null
41
+
42
+ const excerpt = session.messages.slice(-MAX_ARCHIVE_MESSAGES).map((message) => {
43
+ const speaker = messageSpeaker(session, agent, message)
44
+ const kind = message.kind && message.kind !== 'chat' ? ` [${message.kind}]` : ''
45
+ const text = toOneLine(message.text, MAX_ARCHIVE_LINE_CHARS)
46
+ const tools = Array.isArray(message.toolEvents) && message.toolEvents.length > 0
47
+ ? ` | tools=${message.toolEvents.map((event) => event.name).join(',')}`
48
+ : ''
49
+ return `- ${speaker}${kind}: ${text}${tools}`
50
+ }).join('\n')
51
+
52
+ const title = `Session archive: ${session.name || session.id}`
53
+ const content = [
54
+ `session_id: ${session.id}`,
55
+ `session_name: ${toOneLine(session.name, 160)}`,
56
+ `session_type: ${toOneLine(session.sessionType || 'human', 32)}`,
57
+ `agent_name: ${toOneLine(agent?.name || '', 80)}`,
58
+ `last_active_iso: ${new Date(session.lastActiveAt || Date.now()).toISOString()}`,
59
+ `message_count: ${session.messages.length}`,
60
+ session.identityState?.personaLabel ? `persona_label: ${toOneLine(session.identityState.personaLabel, 120)}` : '',
61
+ '',
62
+ 'Transcript excerpt:',
63
+ excerpt,
64
+ ].filter(Boolean).join('\n')
65
+
66
+ const hash = createHash('sha256').update(`${title}\n${content}`).digest('hex').slice(0, 16)
67
+ return {
68
+ title,
69
+ content,
70
+ metadata: {
71
+ tier: 'archive',
72
+ archiveHash: hash,
73
+ sessionName: session.name,
74
+ sessionType: session.sessionType || 'human',
75
+ messageCount: session.messages.length,
76
+ lastActiveAt: session.lastActiveAt || Date.now(),
77
+ personaLabel: session.identityState?.personaLabel || null,
78
+ },
79
+ references: [{
80
+ type: 'session',
81
+ path: session.id,
82
+ title: session.name,
83
+ note: 'Searchable session archive snapshot',
84
+ timestamp: Date.now(),
85
+ }],
86
+ hash,
87
+ }
88
+ }
89
+
90
+ export function buildSessionArchiveMarkdown(
91
+ session: Session,
92
+ payload: NonNullable<ReturnType<typeof buildSessionArchivePayload>>,
93
+ agent?: Partial<Agent> | null,
94
+ ): string {
95
+ const transcriptLines = session.messages.slice(-MAX_ARCHIVE_MESSAGES).map((message) => {
96
+ const speaker = messageSpeaker(session, agent, message)
97
+ const kind = message.kind && message.kind !== 'chat' ? ` (${message.kind})` : ''
98
+ const toolSummary = Array.isArray(message.toolEvents) && message.toolEvents.length > 0
99
+ ? ` [tools: ${message.toolEvents.map((event) => event.name).join(', ')}]`
100
+ : ''
101
+ return `- **${speaker}**${kind}: ${toOneLine(message.text, MAX_ARCHIVE_LINE_CHARS)}${toolSummary}`
102
+ })
103
+
104
+ return [
105
+ `# ${payload.title}`,
106
+ '',
107
+ `- Session ID: ${session.id}`,
108
+ `- Session Name: ${toOneLine(session.name, 160)}`,
109
+ `- Session Type: ${toOneLine(session.sessionType || 'human', 32)}`,
110
+ `- Agent: ${toOneLine(agent?.name || session.agentId || 'unknown', 80)}`,
111
+ `- Last Active: ${new Date(session.lastActiveAt || Date.now()).toISOString()}`,
112
+ `- Messages: ${session.messages.length}`,
113
+ session.identityState?.personaLabel ? `- Persona: ${toOneLine(session.identityState.personaLabel, 120)}` : '',
114
+ '',
115
+ '## Archive Snapshot',
116
+ '',
117
+ '```text',
118
+ payload.content,
119
+ '```',
120
+ '',
121
+ '## Transcript Excerpt',
122
+ '',
123
+ ...transcriptLines,
124
+ '',
125
+ ].filter(Boolean).join('\n')
126
+ }
127
+
128
+ function exportSessionArchiveMarkdown(
129
+ session: Session,
130
+ payload: NonNullable<ReturnType<typeof buildSessionArchivePayload>>,
131
+ agent?: Partial<Agent> | null,
132
+ ): string | null {
133
+ try {
134
+ const agentSegment = slugifySegment(agent?.name || session.agentId || 'shared', 'shared')
135
+ const sessionSegment = slugifySegment(session.name || session.id, session.id)
136
+ const dir = path.join(SESSION_ARCHIVE_EXPORT_DIR, agentSegment)
137
+ fs.mkdirSync(dir, { recursive: true })
138
+ const filePath = path.join(dir, `${sessionSegment}-${session.id}.md`)
139
+ fs.writeFileSync(filePath, buildSessionArchiveMarkdown(session, payload, agent))
140
+ return filePath
141
+ } catch {
142
+ return null
143
+ }
144
+ }
145
+
146
+ export function syncSessionArchiveMemory(
147
+ session: Session,
148
+ opts?: { agent?: Partial<Agent> | null },
149
+ ): { stored: boolean; memoryId?: string; reason?: string } {
150
+ const agent = opts?.agent ?? (session.agentId ? loadAgents()[session.agentId] : null)
151
+ if (!session.agentId && !agent?.id) {
152
+ return { stored: false, reason: 'missing_agent' }
153
+ }
154
+
155
+ const payload = buildSessionArchivePayload(session, agent)
156
+ if (!payload) {
157
+ return { stored: false, reason: 'insufficient_messages' }
158
+ }
159
+
160
+ const memDb = getMemoryDb()
161
+ const existing = memDb.getLatestBySessionCategory(session.id, 'session_archive')
162
+ const existingHash = typeof existing?.metadata?.archiveHash === 'string'
163
+ ? existing.metadata.archiveHash
164
+ : null
165
+ if (session.sessionArchiveState?.lastHash === payload.hash || existingHash === payload.hash) {
166
+ session.sessionArchiveState = {
167
+ memoryId: session.sessionArchiveState?.memoryId || existing?.id || null,
168
+ lastHash: payload.hash,
169
+ lastSyncedAt: session.sessionArchiveState?.lastSyncedAt || existing?.updatedAt || null,
170
+ messageCount: session.messages.length,
171
+ exportPath: session.sessionArchiveState?.exportPath || null,
172
+ }
173
+ return { stored: false, memoryId: existing?.id || session.sessionArchiveState.memoryId || undefined, reason: 'unchanged' }
174
+ }
175
+ const entry: MemoryEntry | null = existing
176
+ ? memDb.update(existing.id, {
177
+ title: payload.title,
178
+ content: payload.content,
179
+ metadata: payload.metadata,
180
+ references: payload.references,
181
+ linkedMemoryIds: existing.linkedMemoryIds,
182
+ })
183
+ : memDb.add({
184
+ agentId: session.agentId || agent?.id || null,
185
+ sessionId: session.id,
186
+ category: 'session_archive',
187
+ title: payload.title,
188
+ content: payload.content,
189
+ metadata: payload.metadata,
190
+ references: payload.references,
191
+ linkedMemoryIds: [],
192
+ })
193
+
194
+ if (!entry) return { stored: false, reason: 'store_failed' }
195
+ const exportPath = exportSessionArchiveMarkdown(session, payload, agent)
196
+
197
+ session.sessionArchiveState = {
198
+ memoryId: entry.id,
199
+ lastHash: payload.hash,
200
+ lastSyncedAt: Date.now(),
201
+ messageCount: session.messages.length,
202
+ exportPath,
203
+ }
204
+
205
+ return { stored: true, memoryId: entry.id }
206
+ }
207
+
208
+ export function syncAllSessionArchiveMemories(): { synced: number; skipped: number; sessionIds: string[] } {
209
+ const sessions = loadSessions()
210
+ const agents = loadAgents()
211
+ let changed = false
212
+ let synced = 0
213
+ let skipped = 0
214
+ const sessionIds: string[] = []
215
+
216
+ for (const session of Object.values(sessions) as Session[]) {
217
+ const agent = session.agentId ? agents[session.agentId] : null
218
+ const result = syncSessionArchiveMemory(session, { agent })
219
+ if (result.stored) {
220
+ synced += 1
221
+ sessionIds.push(session.id)
222
+ changed = true
223
+ } else {
224
+ skipped += 1
225
+ }
226
+ }
227
+
228
+ if (changed) saveSessions(sessions)
229
+ return { synced, skipped, sessionIds }
230
+ }
@@ -1,23 +1,7 @@
1
1
  import { genId } from '@/lib/id'
2
+ import type { MailboxEnvelope } from '@/types'
2
3
  import { loadSessions, saveSessions } from './storage'
3
4
 
4
- export type MailboxStatus = 'new' | 'ack'
5
-
6
- export interface MailboxEnvelope {
7
- id: string
8
- type: string
9
- payload: string
10
- fromSessionId?: string | null
11
- fromAgentId?: string | null
12
- toSessionId: string
13
- toAgentId?: string | null
14
- correlationId?: string | null
15
- status: MailboxStatus
16
- createdAt: number
17
- expiresAt?: number | null
18
- ackAt?: number | null
19
- }
20
-
21
5
  interface MailboxOptions {
22
6
  limit?: number
23
7
  includeAcked?: boolean
@@ -78,6 +62,13 @@ export function sendMailboxEnvelope(input: {
78
62
  target.lastActiveAt = now
79
63
  sessions[input.toSessionId] = target
80
64
  saveSessions(sessions)
65
+ import('./watch-jobs')
66
+ .then(({ triggerMailboxWatchJobs }) => {
67
+ triggerMailboxWatchJobs({ sessionId: input.toSessionId, envelope })
68
+ })
69
+ .catch(() => {
70
+ // best-effort trigger only
71
+ })
81
72
  return envelope
82
73
  }
83
74
 
@@ -126,4 +117,3 @@ export function clearMailbox(sessionId: string, includeAcked = true): { before:
126
117
  saveSessions(sessions)
127
118
  return { before, after: afterList.length }
128
119
  }
129
-
@@ -0,0 +1,99 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import type { Session } from '@/types'
4
+ import {
5
+ evaluateSessionFreshness,
6
+ inferSessionResetType,
7
+ resetSessionRuntime,
8
+ resolveSessionResetPolicy,
9
+ } from './session-reset-policy'
10
+
11
+ function makeSession(overrides: Partial<Session> = {}): Session {
12
+ return {
13
+ id: 's1',
14
+ name: 'Test Session',
15
+ cwd: process.cwd(),
16
+ user: 'user',
17
+ provider: 'openai',
18
+ model: 'gpt-4.1',
19
+ claudeSessionId: null,
20
+ codexThreadId: null,
21
+ opencodeSessionId: null,
22
+ messages: [{ role: 'user', text: 'hello', time: 1 }],
23
+ createdAt: 1,
24
+ lastActiveAt: 1,
25
+ ...overrides,
26
+ }
27
+ }
28
+
29
+ test('inferSessionResetType distinguishes direct, group, and thread sessions', () => {
30
+ assert.equal(inferSessionResetType(makeSession()), 'direct')
31
+ assert.equal(inferSessionResetType(makeSession({ connectorContext: { isGroup: true } })), 'group')
32
+ assert.equal(inferSessionResetType(makeSession({ connectorContext: { threadId: 'thread-1' } })), 'thread')
33
+ })
34
+
35
+ test('resolveSessionResetPolicy falls back to type defaults', () => {
36
+ const direct = resolveSessionResetPolicy({ session: makeSession() })
37
+ assert.equal(direct.mode, 'idle')
38
+ assert.equal(direct.idleTimeoutSec, 12 * 60 * 60)
39
+
40
+ const thread = resolveSessionResetPolicy({ session: makeSession({ connectorContext: { threadId: 'thread-1' } }) })
41
+ assert.equal(thread.mode, 'idle')
42
+ assert.equal(thread.idleTimeoutSec, 4 * 60 * 60)
43
+ })
44
+
45
+ test('evaluateSessionFreshness expires idle sessions', () => {
46
+ const session = makeSession({ createdAt: 0, lastActiveAt: 0 })
47
+ const policy = resolveSessionResetPolicy({
48
+ session: { ...session, sessionIdleTimeoutSec: 10, sessionMaxAgeSec: 60 },
49
+ })
50
+ const freshness = evaluateSessionFreshness({ session, policy, now: 11_000 })
51
+ assert.deepEqual(freshness.reason, 'idle_timeout:10')
52
+ assert.equal(freshness.fresh, false)
53
+ })
54
+
55
+ test('evaluateSessionFreshness supports daily reset boundaries', () => {
56
+ const session = makeSession({
57
+ createdAt: Date.parse('2026-03-04T00:00:00.000Z'),
58
+ lastActiveAt: Date.parse('2026-03-05T03:30:00.000Z'),
59
+ })
60
+ const policy = resolveSessionResetPolicy({
61
+ session: {
62
+ ...session,
63
+ sessionResetMode: 'daily',
64
+ sessionDailyResetAt: '04:00',
65
+ sessionResetTimezone: 'UTC',
66
+ sessionMaxAgeSec: 999999,
67
+ sessionIdleTimeoutSec: 0,
68
+ },
69
+ })
70
+ const freshness = evaluateSessionFreshness({
71
+ session,
72
+ policy,
73
+ now: Date.parse('2026-03-05T10:00:00.000Z'),
74
+ })
75
+ assert.equal(freshness.fresh, false)
76
+ assert.equal(freshness.reason, 'daily_reset:04:00')
77
+ })
78
+
79
+ test('resetSessionRuntime clears transient state but preserves continuity state', () => {
80
+ const session = makeSession({
81
+ claudeSessionId: 'claude',
82
+ codexThreadId: 'codex',
83
+ opencodeSessionId: 'open',
84
+ delegateResumeIds: { claudeCode: 'a', codex: 'b', opencode: 'c', gemini: 'd' },
85
+ lastHeartbeatText: 'heartbeat',
86
+ lastHeartbeatSentAt: 123,
87
+ lastAutoMemoryAt: 456,
88
+ conversationTone: 'formal',
89
+ identityState: { personaLabel: 'Planner' },
90
+ })
91
+
92
+ const cleared = resetSessionRuntime(session, 'idle_timeout:10', { now: 1000 })
93
+
94
+ assert.equal(cleared, 1)
95
+ assert.deepEqual(session.messages, [])
96
+ assert.equal(session.claudeSessionId, null)
97
+ assert.equal(session.identityState?.personaLabel, 'Planner')
98
+ assert.equal(session.lastSessionResetReason, 'idle_timeout:10')
99
+ })