@opengsd/gsd-pi 1.0.2-dev.50223bc → 1.0.2-dev.5961fbf

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 (251) hide show
  1. package/dist/resource-loader.d.ts +5 -0
  2. package/dist/resource-loader.js +24 -8
  3. package/dist/resources/.managed-resources-content-hash +1 -1
  4. package/dist/resources/extensions/gsd/auto/loop.js +19 -0
  5. package/dist/resources/extensions/gsd/auto/phases.js +1 -1
  6. package/dist/resources/extensions/gsd/auto-worktree.js +2 -54
  7. package/dist/resources/extensions/gsd/worktree-post-create-hook.js +117 -0
  8. package/dist/web/standalone/.next/BUILD_ID +1 -1
  9. package/dist/web/standalone/.next/app-path-routes-manifest.json +11 -11
  10. package/dist/web/standalone/.next/build-manifest.json +2 -2
  11. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  12. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  13. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  14. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  15. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  16. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  17. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  21. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/api/boot/route.js +1 -1
  29. package/dist/web/standalone/.next/server/app/api/session/events/route.js +1 -1
  30. package/dist/web/standalone/.next/server/app/api/shutdown/route.js +1 -1
  31. package/dist/web/standalone/.next/server/app/index.html +1 -1
  32. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app-paths-manifest.json +11 -11
  39. package/dist/web/standalone/.next/server/chunks/1834.js +1 -1
  40. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  41. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  42. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  43. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  44. package/dist/web/standalone/node_modules/node-pty/build/Makefile +1 -1
  45. package/dist/web/standalone/package.json +0 -1
  46. package/dist/worktree-cli.d.ts +0 -2
  47. package/dist/worktree-cli.js +21 -9
  48. package/package.json +9 -4
  49. package/packages/cloud-mcp-gateway/bin/gsd-cloud-mcp-gateway.js +14 -0
  50. package/packages/cloud-mcp-gateway/package.json +5 -4
  51. package/packages/contracts/package.json +2 -2
  52. package/packages/daemon/bin/gsd-daemon.js +14 -0
  53. package/packages/daemon/bin/gsd-mcp-runtime.js +14 -0
  54. package/packages/daemon/bin/gsd-mcp.js +14 -0
  55. package/packages/daemon/dist/channel-manager.d.ts +53 -0
  56. package/packages/daemon/dist/channel-manager.d.ts.map +1 -0
  57. package/packages/daemon/dist/channel-manager.js +167 -0
  58. package/packages/daemon/dist/channel-manager.js.map +1 -0
  59. package/packages/daemon/dist/cli.d.ts +3 -0
  60. package/packages/daemon/dist/cli.d.ts.map +1 -0
  61. package/packages/daemon/dist/cli.js +94 -0
  62. package/packages/daemon/dist/cli.js.map +1 -0
  63. package/packages/daemon/dist/cloud-cli.d.ts +7 -0
  64. package/packages/daemon/dist/cloud-cli.d.ts.map +1 -0
  65. package/packages/daemon/dist/cloud-cli.js +96 -0
  66. package/packages/daemon/dist/cloud-cli.js.map +1 -0
  67. package/packages/daemon/dist/cloud-config.d.ts +18 -0
  68. package/packages/daemon/dist/cloud-config.d.ts.map +1 -0
  69. package/packages/daemon/dist/cloud-config.js +209 -0
  70. package/packages/daemon/dist/cloud-config.js.map +1 -0
  71. package/packages/daemon/dist/cloud-config.test.d.ts +2 -0
  72. package/packages/daemon/dist/cloud-config.test.d.ts.map +1 -0
  73. package/packages/daemon/dist/cloud-config.test.js +132 -0
  74. package/packages/daemon/dist/cloud-config.test.js.map +1 -0
  75. package/packages/daemon/dist/cloud-runtime.d.ts +26 -0
  76. package/packages/daemon/dist/cloud-runtime.d.ts.map +1 -0
  77. package/packages/daemon/dist/cloud-runtime.js +180 -0
  78. package/packages/daemon/dist/cloud-runtime.js.map +1 -0
  79. package/packages/daemon/dist/cloud-runtime.test.d.ts +2 -0
  80. package/packages/daemon/dist/cloud-runtime.test.d.ts.map +1 -0
  81. package/packages/daemon/dist/cloud-runtime.test.js +28 -0
  82. package/packages/daemon/dist/cloud-runtime.test.js.map +1 -0
  83. package/packages/daemon/dist/cloud-token.d.ts +3 -0
  84. package/packages/daemon/dist/cloud-token.d.ts.map +1 -0
  85. package/packages/daemon/dist/cloud-token.js +37 -0
  86. package/packages/daemon/dist/cloud-token.js.map +1 -0
  87. package/packages/daemon/dist/commands.d.ts +25 -0
  88. package/packages/daemon/dist/commands.d.ts.map +1 -0
  89. package/packages/daemon/dist/commands.js +81 -0
  90. package/packages/daemon/dist/commands.js.map +1 -0
  91. package/packages/daemon/dist/config.d.ts +17 -0
  92. package/packages/daemon/dist/config.d.ts.map +1 -0
  93. package/packages/daemon/dist/config.js +146 -0
  94. package/packages/daemon/dist/config.js.map +1 -0
  95. package/packages/daemon/dist/daemon.d.ts +38 -0
  96. package/packages/daemon/dist/daemon.d.ts.map +1 -0
  97. package/packages/daemon/dist/daemon.js +194 -0
  98. package/packages/daemon/dist/daemon.js.map +1 -0
  99. package/packages/daemon/dist/daemon.test.d.ts +2 -0
  100. package/packages/daemon/dist/daemon.test.d.ts.map +1 -0
  101. package/packages/daemon/dist/daemon.test.js +692 -0
  102. package/packages/daemon/dist/daemon.test.js.map +1 -0
  103. package/packages/daemon/dist/discord-bot.d.ts +70 -0
  104. package/packages/daemon/dist/discord-bot.d.ts.map +1 -0
  105. package/packages/daemon/dist/discord-bot.js +433 -0
  106. package/packages/daemon/dist/discord-bot.js.map +1 -0
  107. package/packages/daemon/dist/discord-bot.test.d.ts +2 -0
  108. package/packages/daemon/dist/discord-bot.test.d.ts.map +1 -0
  109. package/packages/daemon/dist/discord-bot.test.js +667 -0
  110. package/packages/daemon/dist/discord-bot.test.js.map +1 -0
  111. package/packages/daemon/dist/event-bridge.d.ts +72 -0
  112. package/packages/daemon/dist/event-bridge.d.ts.map +1 -0
  113. package/packages/daemon/dist/event-bridge.js +366 -0
  114. package/packages/daemon/dist/event-bridge.js.map +1 -0
  115. package/packages/daemon/dist/event-bridge.test.d.ts +9 -0
  116. package/packages/daemon/dist/event-bridge.test.d.ts.map +1 -0
  117. package/packages/daemon/dist/event-bridge.test.js +528 -0
  118. package/packages/daemon/dist/event-bridge.test.js.map +1 -0
  119. package/packages/daemon/dist/event-formatter.d.ts +34 -0
  120. package/packages/daemon/dist/event-formatter.d.ts.map +1 -0
  121. package/packages/daemon/dist/event-formatter.js +355 -0
  122. package/packages/daemon/dist/event-formatter.js.map +1 -0
  123. package/packages/daemon/dist/event-formatter.test.d.ts +2 -0
  124. package/packages/daemon/dist/event-formatter.test.d.ts.map +1 -0
  125. package/packages/daemon/dist/event-formatter.test.js +333 -0
  126. package/packages/daemon/dist/event-formatter.test.js.map +1 -0
  127. package/packages/daemon/dist/index.d.ts +25 -0
  128. package/packages/daemon/dist/index.d.ts.map +1 -0
  129. package/packages/daemon/dist/index.js +17 -0
  130. package/packages/daemon/dist/index.js.map +1 -0
  131. package/packages/daemon/dist/launchd.d.ts +49 -0
  132. package/packages/daemon/dist/launchd.d.ts.map +1 -0
  133. package/packages/daemon/dist/launchd.js +188 -0
  134. package/packages/daemon/dist/launchd.js.map +1 -0
  135. package/packages/daemon/dist/launchd.test.d.ts +2 -0
  136. package/packages/daemon/dist/launchd.test.d.ts.map +1 -0
  137. package/packages/daemon/dist/launchd.test.js +296 -0
  138. package/packages/daemon/dist/launchd.test.js.map +1 -0
  139. package/packages/daemon/dist/local-tool-executor.d.ts +22 -0
  140. package/packages/daemon/dist/local-tool-executor.d.ts.map +1 -0
  141. package/packages/daemon/dist/local-tool-executor.js +307 -0
  142. package/packages/daemon/dist/local-tool-executor.js.map +1 -0
  143. package/packages/daemon/dist/local-tool-executor.test.d.ts +2 -0
  144. package/packages/daemon/dist/local-tool-executor.test.d.ts.map +1 -0
  145. package/packages/daemon/dist/local-tool-executor.test.js +111 -0
  146. package/packages/daemon/dist/local-tool-executor.test.js.map +1 -0
  147. package/packages/daemon/dist/logger.d.ts +25 -0
  148. package/packages/daemon/dist/logger.d.ts.map +1 -0
  149. package/packages/daemon/dist/logger.js +72 -0
  150. package/packages/daemon/dist/logger.js.map +1 -0
  151. package/packages/daemon/dist/mcp-cli.d.ts +3 -0
  152. package/packages/daemon/dist/mcp-cli.d.ts.map +1 -0
  153. package/packages/daemon/dist/mcp-cli.js +8 -0
  154. package/packages/daemon/dist/mcp-cli.js.map +1 -0
  155. package/packages/daemon/dist/mcp-cli.test.d.ts +2 -0
  156. package/packages/daemon/dist/mcp-cli.test.d.ts.map +1 -0
  157. package/packages/daemon/dist/mcp-cli.test.js +13 -0
  158. package/packages/daemon/dist/mcp-cli.test.js.map +1 -0
  159. package/packages/daemon/dist/mcp-runtime-cli.d.ts +3 -0
  160. package/packages/daemon/dist/mcp-runtime-cli.d.ts.map +1 -0
  161. package/packages/daemon/dist/mcp-runtime-cli.js +8 -0
  162. package/packages/daemon/dist/mcp-runtime-cli.js.map +1 -0
  163. package/packages/daemon/dist/message-batcher.d.ts +78 -0
  164. package/packages/daemon/dist/message-batcher.d.ts.map +1 -0
  165. package/packages/daemon/dist/message-batcher.js +173 -0
  166. package/packages/daemon/dist/message-batcher.js.map +1 -0
  167. package/packages/daemon/dist/message-batcher.test.d.ts +2 -0
  168. package/packages/daemon/dist/message-batcher.test.d.ts.map +1 -0
  169. package/packages/daemon/dist/message-batcher.test.js +242 -0
  170. package/packages/daemon/dist/message-batcher.test.js.map +1 -0
  171. package/packages/daemon/dist/orchestrator.d.ts +98 -0
  172. package/packages/daemon/dist/orchestrator.d.ts.map +1 -0
  173. package/packages/daemon/dist/orchestrator.js +359 -0
  174. package/packages/daemon/dist/orchestrator.js.map +1 -0
  175. package/packages/daemon/dist/orchestrator.test.d.ts +8 -0
  176. package/packages/daemon/dist/orchestrator.test.d.ts.map +1 -0
  177. package/packages/daemon/dist/orchestrator.test.js +425 -0
  178. package/packages/daemon/dist/orchestrator.test.js.map +1 -0
  179. package/packages/daemon/dist/project-scanner.d.ts +18 -0
  180. package/packages/daemon/dist/project-scanner.d.ts.map +1 -0
  181. package/packages/daemon/dist/project-scanner.js +90 -0
  182. package/packages/daemon/dist/project-scanner.js.map +1 -0
  183. package/packages/daemon/dist/project-scanner.test.d.ts +5 -0
  184. package/packages/daemon/dist/project-scanner.test.d.ts.map +1 -0
  185. package/packages/daemon/dist/project-scanner.test.js +183 -0
  186. package/packages/daemon/dist/project-scanner.test.js.map +1 -0
  187. package/packages/daemon/dist/session-manager.d.ts +70 -0
  188. package/packages/daemon/dist/session-manager.d.ts.map +1 -0
  189. package/packages/daemon/dist/session-manager.js +358 -0
  190. package/packages/daemon/dist/session-manager.js.map +1 -0
  191. package/packages/daemon/dist/session-manager.test.d.ts +9 -0
  192. package/packages/daemon/dist/session-manager.test.d.ts.map +1 -0
  193. package/packages/daemon/dist/session-manager.test.js +616 -0
  194. package/packages/daemon/dist/session-manager.test.js.map +1 -0
  195. package/packages/daemon/dist/types.d.ts +133 -0
  196. package/packages/daemon/dist/types.d.ts.map +1 -0
  197. package/packages/daemon/dist/types.js +8 -0
  198. package/packages/daemon/dist/types.js.map +1 -0
  199. package/packages/daemon/dist/verbosity.d.ts +27 -0
  200. package/packages/daemon/dist/verbosity.d.ts.map +1 -0
  201. package/packages/daemon/dist/verbosity.js +86 -0
  202. package/packages/daemon/dist/verbosity.js.map +1 -0
  203. package/packages/daemon/dist/verbosity.test.d.ts +2 -0
  204. package/packages/daemon/dist/verbosity.test.d.ts.map +1 -0
  205. package/packages/daemon/dist/verbosity.test.js +136 -0
  206. package/packages/daemon/dist/verbosity.test.js.map +1 -0
  207. package/packages/daemon/package.json +9 -8
  208. package/packages/gsd-agent-core/package.json +6 -6
  209. package/packages/gsd-agent-modes/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  210. package/packages/gsd-agent-modes/dist/modes/interactive/components/tool-execution.js +3 -1
  211. package/packages/gsd-agent-modes/dist/modes/interactive/components/tool-execution.js.map +1 -1
  212. package/packages/gsd-agent-modes/dist/modes/interactive/components/transcript-design.d.ts.map +1 -1
  213. package/packages/gsd-agent-modes/dist/modes/interactive/components/transcript-design.js +0 -1
  214. package/packages/gsd-agent-modes/dist/modes/interactive/components/transcript-design.js.map +1 -1
  215. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode-class-constants.d.ts +1 -0
  216. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode-class-constants.d.ts.map +1 -1
  217. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode-class-constants.js +1 -0
  218. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode-class-constants.js.map +1 -1
  219. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  220. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode.js +2 -1
  221. package/packages/gsd-agent-modes/dist/modes/interactive/interactive-mode.js.map +1 -1
  222. package/packages/gsd-agent-modes/package.json +8 -8
  223. package/packages/mcp-server/bin/gsd-mcp-server.js +14 -0
  224. package/packages/mcp-server/package.json +6 -5
  225. package/packages/native/package.json +3 -3
  226. package/packages/pi-agent-core/package.json +4 -4
  227. package/packages/pi-ai/bin/pi-ai.js +14 -0
  228. package/packages/pi-ai/dist/models.generated.d.ts +0 -17
  229. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  230. package/packages/pi-ai/dist/models.generated.js +18 -35
  231. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  232. package/packages/pi-ai/package.json +5 -4
  233. package/packages/pi-coding-agent/dist/core/tools/read.d.ts +2 -2
  234. package/packages/pi-coding-agent/dist/core/tools/read.d.ts.map +1 -1
  235. package/packages/pi-coding-agent/dist/core/tools/read.js +5 -3
  236. package/packages/pi-coding-agent/dist/core/tools/read.js.map +1 -1
  237. package/packages/pi-coding-agent/package.json +9 -9
  238. package/packages/pi-tui/package.json +2 -2
  239. package/packages/rpc-client/package.json +3 -3
  240. package/pkg/package.json +1 -1
  241. package/scripts/ensure-workspace-builds.cjs +4 -4
  242. package/scripts/install/deps.js +10 -0
  243. package/src/resources/extensions/gsd/auto/loop.ts +22 -0
  244. package/src/resources/extensions/gsd/auto/phases.ts +1 -1
  245. package/src/resources/extensions/gsd/auto-worktree.ts +2 -56
  246. package/src/resources/extensions/gsd/tests/custom-engine-loop-integration.test.ts +64 -0
  247. package/src/resources/extensions/gsd/tests/worktree-post-create-hook.test.ts +141 -1
  248. package/src/resources/extensions/gsd/worktree-post-create-hook.ts +127 -0
  249. package/dist/tsconfig.extensions.tsbuildinfo +0 -1
  250. /package/dist/web/standalone/.next/static/{JP7xjsa5zSaO76XhE-mFJ → spUYLkQXoHJyxYOMH9VQy}/_buildManifest.js +0 -0
  251. /package/dist/web/standalone/.next/static/{JP7xjsa5zSaO76XhE-mFJ → spUYLkQXoHJyxYOMH9VQy}/_ssgManifest.js +0 -0
@@ -0,0 +1,692 @@
1
+ import { describe, it, afterEach, before, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync, mkdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { tmpdir, homedir } from 'node:os';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { execFileSync, spawn } from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { dirname } from 'node:path';
10
+ import { resolveConfigPath, loadConfig, validateConfig } from './config.js';
11
+ import { Logger } from './logger.js';
12
+ import { Daemon } from './daemon.js';
13
+ import { CloudRuntime } from './cloud-runtime.js';
14
+ import { SessionManager } from './session-manager.js';
15
+ // ---------- helpers ----------
16
+ function tmpDir() {
17
+ return mkdtempSync(join(tmpdir(), `daemon-test-${randomUUID().slice(0, 8)}-`));
18
+ }
19
+ const cleanupDirs = [];
20
+ afterEach(() => {
21
+ while (cleanupDirs.length) {
22
+ const d = cleanupDirs.pop();
23
+ if (existsSync(d))
24
+ rmSync(d, { recursive: true, force: true });
25
+ }
26
+ });
27
+ it('cloud runtime handles gateway cancel for in-flight requests', async () => {
28
+ let finishTool;
29
+ const calls = [];
30
+ const executor = {
31
+ execute: (toolName, args, projectAlias) => {
32
+ calls.push({ toolName, args, projectAlias });
33
+ if (toolName === 'gsd_cancel')
34
+ return Promise.resolve({ content: [] });
35
+ return new Promise((resolve) => {
36
+ finishTool = resolve;
37
+ });
38
+ },
39
+ advertisedProjects: async () => [],
40
+ };
41
+ const sent = [];
42
+ const runtime = new CloudRuntime({ gateway_url: 'ws://127.0.0.1:1', device_token: 'token', runtime_id: 'runtime' }, executor, { info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined });
43
+ runtime.socket = {
44
+ readyState: 1,
45
+ send: (payload) => sent.push(JSON.parse(payload)),
46
+ };
47
+ const running = runtime.handleMessage(JSON.stringify({
48
+ type: 'tool_call',
49
+ requestId: 'request-1',
50
+ toolName: 'gsd_progress',
51
+ args: { projectDir: '/project' },
52
+ }));
53
+ await new Promise((resolve) => setImmediate(resolve));
54
+ await runtime.handleMessage(JSON.stringify({
55
+ type: 'cancel',
56
+ requestId: 'request-1',
57
+ }));
58
+ finishTool({ late: true });
59
+ await running;
60
+ assert.deepEqual(calls.map((call) => call.toolName), ['gsd_progress', 'gsd_cancel']);
61
+ assert.deepEqual(calls[1], { toolName: 'gsd_cancel', args: { projectDir: '/project' }, projectAlias: undefined });
62
+ assert.deepEqual(sent, []);
63
+ });
64
+ // ---------- config ----------
65
+ describe('resolveConfigPath', () => {
66
+ it('prefers explicit CLI path', () => {
67
+ const p = resolveConfigPath('/custom/config.yaml');
68
+ assert.equal(p, '/custom/config.yaml');
69
+ });
70
+ it('expands ~ in CLI path', () => {
71
+ const p = resolveConfigPath('~/my-daemon.yaml');
72
+ assert.ok(p.startsWith(homedir()));
73
+ assert.ok(p.endsWith('my-daemon.yaml'));
74
+ });
75
+ it('falls back to GSD_DAEMON_CONFIG env var', () => {
76
+ const prev = process.env['GSD_DAEMON_CONFIG'];
77
+ try {
78
+ process.env['GSD_DAEMON_CONFIG'] = '/env/path.yaml';
79
+ const p = resolveConfigPath();
80
+ assert.equal(p, '/env/path.yaml');
81
+ }
82
+ finally {
83
+ if (prev === undefined)
84
+ delete process.env['GSD_DAEMON_CONFIG'];
85
+ else
86
+ process.env['GSD_DAEMON_CONFIG'] = prev;
87
+ }
88
+ });
89
+ it('defaults to ~/.gsd/daemon.yaml', () => {
90
+ const prev = process.env['GSD_DAEMON_CONFIG'];
91
+ try {
92
+ delete process.env['GSD_DAEMON_CONFIG'];
93
+ const p = resolveConfigPath();
94
+ assert.equal(p, join(homedir(), '.gsd', 'daemon.yaml'));
95
+ }
96
+ finally {
97
+ if (prev !== undefined)
98
+ process.env['GSD_DAEMON_CONFIG'] = prev;
99
+ }
100
+ });
101
+ });
102
+ describe('loadConfig', () => {
103
+ // Save and clear DISCORD_BOT_TOKEN for this suite — env override interferes with file-token assertions
104
+ let savedToken;
105
+ before(() => {
106
+ savedToken = process.env['DISCORD_BOT_TOKEN'];
107
+ delete process.env['DISCORD_BOT_TOKEN'];
108
+ });
109
+ afterEach(() => { }); // cleanup dirs handled by top-level afterEach
110
+ // Restore after all tests in this suite
111
+ after(() => {
112
+ if (savedToken !== undefined)
113
+ process.env['DISCORD_BOT_TOKEN'] = savedToken;
114
+ });
115
+ it('parses valid YAML config', () => {
116
+ const dir = tmpDir();
117
+ cleanupDirs.push(dir);
118
+ const configPath = join(dir, 'daemon.yaml');
119
+ writeFileSync(configPath, `
120
+ discord:
121
+ token: "fixture"
122
+ guild_id: "g1"
123
+ owner_id: "o1"
124
+ projects:
125
+ scan_roots:
126
+ - ~/projects
127
+ - /absolute/path
128
+ log:
129
+ file: ~/logs/daemon.log
130
+ level: debug
131
+ max_size_mb: 100
132
+ `);
133
+ const cfg = loadConfig(configPath);
134
+ assert.equal(cfg.discord?.token, 'fixture');
135
+ assert.equal(cfg.discord?.guild_id, 'g1');
136
+ assert.equal(cfg.log.level, 'debug');
137
+ assert.equal(cfg.log.max_size_mb, 100);
138
+ assert.ok(cfg.log.file.startsWith(homedir()));
139
+ assert.ok(cfg.projects.scan_roots[0].startsWith(homedir()));
140
+ assert.equal(cfg.projects.scan_roots[1], '/absolute/path');
141
+ });
142
+ it('returns defaults when config file is missing', () => {
143
+ const cfg = loadConfig('/nonexistent/path/daemon.yaml');
144
+ assert.equal(cfg.log.level, 'info');
145
+ assert.equal(cfg.log.max_size_mb, 50);
146
+ assert.ok(cfg.log.file.endsWith('daemon.log'));
147
+ assert.deepEqual(cfg.projects.scan_roots, []);
148
+ assert.equal(cfg.discord, undefined);
149
+ });
150
+ it('throws on malformed YAML', () => {
151
+ const dir = tmpDir();
152
+ cleanupDirs.push(dir);
153
+ const configPath = join(dir, 'bad.yaml');
154
+ writeFileSync(configPath, ':\n :\n bad: [unclosed');
155
+ assert.throws(() => loadConfig(configPath), (err) => {
156
+ assert.ok(err instanceof Error);
157
+ assert.ok(err.message.includes('Failed to parse YAML'));
158
+ assert.ok(err.message.includes(configPath));
159
+ return true;
160
+ });
161
+ });
162
+ it('returns defaults for empty YAML file', () => {
163
+ const dir = tmpDir();
164
+ cleanupDirs.push(dir);
165
+ const configPath = join(dir, 'empty.yaml');
166
+ writeFileSync(configPath, '');
167
+ const cfg = loadConfig(configPath);
168
+ assert.equal(cfg.log.level, 'info');
169
+ assert.equal(cfg.log.max_size_mb, 50);
170
+ assert.deepEqual(cfg.projects.scan_roots, []);
171
+ });
172
+ });
173
+ describe('validateConfig', () => {
174
+ // Save and clear DISCORD_BOT_TOKEN for tests that don't expect it
175
+ let savedToken;
176
+ before(() => {
177
+ savedToken = process.env['DISCORD_BOT_TOKEN'];
178
+ delete process.env['DISCORD_BOT_TOKEN'];
179
+ });
180
+ after(() => {
181
+ if (savedToken !== undefined)
182
+ process.env['DISCORD_BOT_TOKEN'] = savedToken;
183
+ });
184
+ it('fills remaining defaults for partial config', () => {
185
+ const cfg = validateConfig({ projects: { scan_roots: ['/a'] } });
186
+ assert.equal(cfg.log.level, 'info');
187
+ assert.equal(cfg.log.max_size_mb, 50);
188
+ assert.ok(cfg.log.file.endsWith('daemon.log'));
189
+ assert.deepEqual(cfg.projects.scan_roots, ['/a']);
190
+ assert.equal(cfg.discord, undefined);
191
+ });
192
+ it('falls back to info for invalid log level', () => {
193
+ const cfg = validateConfig({ log: { level: 'trace' } });
194
+ assert.equal(cfg.log.level, 'info');
195
+ });
196
+ it('returns full defaults for null input', () => {
197
+ const cfg = validateConfig(null);
198
+ assert.equal(cfg.log.level, 'info');
199
+ assert.equal(cfg.log.max_size_mb, 50);
200
+ });
201
+ it('returns full defaults for non-object input', () => {
202
+ const cfg = validateConfig('not-an-object');
203
+ assert.equal(cfg.log.level, 'info');
204
+ });
205
+ it('expands ~ in log file path', () => {
206
+ const cfg = validateConfig({ log: { file: '~/my.log' } });
207
+ assert.ok(cfg.log.file.startsWith(homedir()));
208
+ assert.ok(cfg.log.file.endsWith('my.log'));
209
+ });
210
+ it('overrides discord token from DISCORD_BOT_TOKEN env var', () => {
211
+ const prev = process.env['DISCORD_BOT_TOKEN'];
212
+ try {
213
+ process.env['DISCORD_BOT_TOKEN'] = 'env-override-token';
214
+ const cfg = validateConfig({
215
+ discord: { token: 'fixture', guild_id: 'g1', owner_id: 'o1' },
216
+ });
217
+ assert.equal(cfg.discord?.token, 'env-override-token');
218
+ assert.equal(cfg.discord?.guild_id, 'g1');
219
+ }
220
+ finally {
221
+ if (prev === undefined)
222
+ delete process.env['DISCORD_BOT_TOKEN'];
223
+ else
224
+ process.env['DISCORD_BOT_TOKEN'] = prev;
225
+ }
226
+ });
227
+ it('creates discord block from env var even when absent in config', () => {
228
+ const prev = process.env['DISCORD_BOT_TOKEN'];
229
+ try {
230
+ process.env['DISCORD_BOT_TOKEN'] = 'env-only-token';
231
+ const cfg = validateConfig({});
232
+ assert.equal(cfg.discord?.token, 'env-only-token');
233
+ }
234
+ finally {
235
+ if (prev === undefined)
236
+ delete process.env['DISCORD_BOT_TOKEN'];
237
+ else
238
+ process.env['DISCORD_BOT_TOKEN'] = prev;
239
+ }
240
+ });
241
+ });
242
+ // ---------- logger ----------
243
+ describe('Logger', () => {
244
+ it('writes JSON-lines entries to file', async () => {
245
+ const dir = tmpDir();
246
+ cleanupDirs.push(dir);
247
+ const logPath = join(dir, 'test.log');
248
+ const logger = new Logger({ filePath: logPath, level: 'debug' });
249
+ logger.info('hello world');
250
+ logger.debug('detail', { key: 'val' });
251
+ await logger.close();
252
+ const lines = readFileSync(logPath, 'utf-8').trim().split('\n');
253
+ assert.equal(lines.length, 2);
254
+ const entry0 = JSON.parse(lines[0]);
255
+ assert.equal(entry0.level, 'info');
256
+ assert.equal(entry0.msg, 'hello world');
257
+ assert.ok(entry0.ts); // ISO-8601
258
+ const entry1 = JSON.parse(lines[1]);
259
+ assert.equal(entry1.level, 'debug');
260
+ assert.equal(entry1.msg, 'detail');
261
+ assert.deepEqual(entry1.data, { key: 'val' });
262
+ });
263
+ it('filters entries below configured level', async () => {
264
+ const dir = tmpDir();
265
+ cleanupDirs.push(dir);
266
+ const logPath = join(dir, 'filter.log');
267
+ const logger = new Logger({ filePath: logPath, level: 'warn' });
268
+ logger.debug('should not appear');
269
+ logger.info('should not appear either');
270
+ logger.warn('visible warning');
271
+ logger.error('visible error');
272
+ await logger.close();
273
+ const lines = readFileSync(logPath, 'utf-8').trim().split('\n');
274
+ assert.equal(lines.length, 2);
275
+ assert.equal(JSON.parse(lines[0]).level, 'warn');
276
+ assert.equal(JSON.parse(lines[1]).level, 'error');
277
+ });
278
+ it('close() resolves after stream ends', async () => {
279
+ const dir = tmpDir();
280
+ cleanupDirs.push(dir);
281
+ const logPath = join(dir, 'close.log');
282
+ const logger = new Logger({ filePath: logPath, level: 'info' });
283
+ logger.info('before close');
284
+ await logger.close();
285
+ // File should be readable and contain the entry
286
+ const content = readFileSync(logPath, 'utf-8');
287
+ assert.ok(content.includes('before close'));
288
+ });
289
+ it('creates parent directories if they do not exist', async () => {
290
+ const dir = tmpDir();
291
+ cleanupDirs.push(dir);
292
+ const logPath = join(dir, 'nested', 'deep', 'test.log');
293
+ const logger = new Logger({ filePath: logPath, level: 'info' });
294
+ logger.info('nested dir test');
295
+ await logger.close();
296
+ assert.ok(existsSync(logPath));
297
+ const content = readFileSync(logPath, 'utf-8');
298
+ assert.ok(content.includes('nested dir test'));
299
+ });
300
+ it('does not include data field when not provided', async () => {
301
+ const dir = tmpDir();
302
+ cleanupDirs.push(dir);
303
+ const logPath = join(dir, 'nodata.log');
304
+ const logger = new Logger({ filePath: logPath, level: 'info' });
305
+ logger.info('no extra data');
306
+ await logger.close();
307
+ const entry = JSON.parse(readFileSync(logPath, 'utf-8').trim());
308
+ assert.equal(entry.data, undefined);
309
+ // Also verify the raw JSON doesn't contain "data" key
310
+ assert.ok(!readFileSync(logPath, 'utf-8').includes('"data"'));
311
+ });
312
+ });
313
+ // ---------- token safety ----------
314
+ describe('token safety', () => {
315
+ it('discord token never appears in log output', async () => {
316
+ const dir = tmpDir();
317
+ cleanupDirs.push(dir);
318
+ const logPath = join(dir, 'token-safety.log');
319
+ // Config with a token
320
+ const prev = process.env['DISCORD_BOT_TOKEN'];
321
+ try {
322
+ process.env['DISCORD_BOT_TOKEN'] = 'super-secret-token-value';
323
+ const cfg = validateConfig({});
324
+ const logger = new Logger({ filePath: logPath, level: 'debug' });
325
+ // Log the config object — token must not leak
326
+ logger.info('config loaded', { discord_configured: !!cfg.discord });
327
+ logger.debug('startup complete');
328
+ await logger.close();
329
+ const content = readFileSync(logPath, 'utf-8');
330
+ assert.ok(!content.includes('super-secret-token-value'));
331
+ }
332
+ finally {
333
+ if (prev === undefined)
334
+ delete process.env['DISCORD_BOT_TOKEN'];
335
+ else
336
+ process.env['DISCORD_BOT_TOKEN'] = prev;
337
+ }
338
+ });
339
+ });
340
+ // ---------- daemon lifecycle ----------
341
+ // Resolve the dist/ directory for spawning CLI
342
+ const __filename = fileURLToPath(import.meta.url);
343
+ const __dirname = dirname(__filename);
344
+ describe('Daemon', () => {
345
+ it('logs lifecycle events on start and shutdown', async () => {
346
+ const dir = tmpDir();
347
+ cleanupDirs.push(dir);
348
+ const logPath = join(dir, 'daemon-lifecycle.log');
349
+ const config = {
350
+ discord: undefined,
351
+ projects: { scan_roots: ['/a', '/b'] },
352
+ log: { file: logPath, level: 'info', max_size_mb: 50 },
353
+ };
354
+ const logger = new Logger({ filePath: logPath, level: 'info' });
355
+ const daemon = new Daemon(config, logger);
356
+ await daemon.start();
357
+ // start() should have logged 'daemon started'
358
+ // shutdown() directly — we override process.exit to prevent test runner from dying
359
+ const origExit = process.exit;
360
+ let exitCode;
361
+ // @ts-expect-error — overriding process.exit for test
362
+ process.exit = (code) => { exitCode = code ?? 0; };
363
+ try {
364
+ await daemon.shutdown();
365
+ }
366
+ finally {
367
+ process.exit = origExit;
368
+ }
369
+ assert.equal(exitCode, 0);
370
+ const content = readFileSync(logPath, 'utf-8');
371
+ const lines = content.trim().split('\n');
372
+ // First line: daemon started
373
+ const startEntry = JSON.parse(lines[0]);
374
+ assert.equal(startEntry.msg, 'daemon started');
375
+ assert.equal(startEntry.data?.scan_roots, 2);
376
+ assert.equal(startEntry.data?.discord_configured, false);
377
+ // Second line: daemon shutting down
378
+ const stopEntry = JSON.parse(lines[1]);
379
+ assert.equal(stopEntry.msg, 'daemon shutting down');
380
+ });
381
+ it('shutdown is idempotent — second call is a no-op', async () => {
382
+ const dir = tmpDir();
383
+ cleanupDirs.push(dir);
384
+ const logPath = join(dir, 'idempotent.log');
385
+ const config = {
386
+ discord: undefined,
387
+ projects: { scan_roots: [] },
388
+ log: { file: logPath, level: 'info', max_size_mb: 50 },
389
+ };
390
+ const logger = new Logger({ filePath: logPath, level: 'info' });
391
+ const daemon = new Daemon(config, logger);
392
+ await daemon.start();
393
+ const origExit = process.exit;
394
+ let exitCount = 0;
395
+ // @ts-expect-error — overriding process.exit for test
396
+ process.exit = () => { exitCount++; };
397
+ try {
398
+ await daemon.shutdown();
399
+ await daemon.shutdown(); // second call — should be no-op
400
+ }
401
+ finally {
402
+ process.exit = origExit;
403
+ }
404
+ assert.equal(exitCount, 1, 'process.exit should be called exactly once');
405
+ const lines = readFileSync(logPath, 'utf-8').trim().split('\n');
406
+ const shutdownLines = lines.filter(l => {
407
+ const e = JSON.parse(l);
408
+ return e.msg === 'daemon shutting down';
409
+ });
410
+ assert.equal(shutdownLines.length, 1, 'shutdown log should appear exactly once');
411
+ });
412
+ });
413
+ // ---------- Health heartbeat ----------
414
+ describe('Health heartbeat', () => {
415
+ it('logs health entry with expected fields after interval tick', async () => {
416
+ const dir = tmpDir();
417
+ cleanupDirs.push(dir);
418
+ const logPath = join(dir, 'health.log');
419
+ const config = {
420
+ discord: undefined,
421
+ projects: { scan_roots: [] },
422
+ log: { file: logPath, level: 'info', max_size_mb: 50 },
423
+ };
424
+ const logger = new Logger({ filePath: logPath, level: 'info' });
425
+ // Use 50ms interval for fast test
426
+ const daemon = new Daemon(config, logger, 50);
427
+ await daemon.start();
428
+ // Wait for at least one health tick
429
+ await new Promise((r) => setTimeout(r, 120));
430
+ const origExit = process.exit;
431
+ // @ts-expect-error — overriding process.exit for test
432
+ process.exit = () => { };
433
+ try {
434
+ await daemon.shutdown();
435
+ }
436
+ finally {
437
+ process.exit = origExit;
438
+ }
439
+ const content = readFileSync(logPath, 'utf-8');
440
+ const lines = content.trim().split('\n');
441
+ const healthLines = lines.filter((l) => {
442
+ const e = JSON.parse(l);
443
+ return e.msg === 'health';
444
+ });
445
+ assert.ok(healthLines.length >= 1, 'should have at least one health log entry');
446
+ const entry = JSON.parse(healthLines[0]);
447
+ assert.equal(entry.msg, 'health');
448
+ assert.equal(typeof entry.data?.uptime_s, 'number');
449
+ assert.equal(typeof entry.data?.active_sessions, 'number');
450
+ assert.equal(typeof entry.data?.discord_connected, 'boolean');
451
+ assert.equal(typeof entry.data?.memory_rss_mb, 'number');
452
+ assert.equal(entry.data?.discord_connected, false); // no discord configured
453
+ assert.equal(entry.data?.active_sessions, 0); // no sessions
454
+ });
455
+ it('health timer is cleared on shutdown — no lingering intervals', async () => {
456
+ const dir = tmpDir();
457
+ cleanupDirs.push(dir);
458
+ const logPath = join(dir, 'health-cleanup.log');
459
+ const config = {
460
+ discord: undefined,
461
+ projects: { scan_roots: [] },
462
+ log: { file: logPath, level: 'info', max_size_mb: 50 },
463
+ };
464
+ const logger = new Logger({ filePath: logPath, level: 'info' });
465
+ // Use 50ms interval
466
+ const daemon = new Daemon(config, logger, 50);
467
+ await daemon.start();
468
+ // Wait for one tick
469
+ await new Promise((r) => setTimeout(r, 80));
470
+ const origExit = process.exit;
471
+ // @ts-expect-error — overriding process.exit for test
472
+ process.exit = () => { };
473
+ try {
474
+ await daemon.shutdown();
475
+ }
476
+ finally {
477
+ process.exit = origExit;
478
+ }
479
+ // Count health entries at shutdown
480
+ const contentAtShutdown = readFileSync(logPath, 'utf-8');
481
+ const healthCountAtShutdown = contentAtShutdown
482
+ .trim()
483
+ .split('\n')
484
+ .filter((l) => JSON.parse(l).msg === 'health').length;
485
+ // Wait another interval — no new health entries should appear
486
+ await new Promise((r) => setTimeout(r, 120));
487
+ // Re-read (logger is closed, so file shouldn't change)
488
+ const contentAfterWait = readFileSync(logPath, 'utf-8');
489
+ const healthCountAfterWait = contentAfterWait
490
+ .trim()
491
+ .split('\n')
492
+ .filter((l) => JSON.parse(l).msg === 'health').length;
493
+ assert.equal(healthCountAfterWait, healthCountAtShutdown, 'no new health entries should appear after shutdown');
494
+ });
495
+ });
496
+ describe('CLI integration', () => {
497
+ it('--help prints usage and exits 0', () => {
498
+ const result = execFileSync(process.execPath, [join(__dirname, 'cli.js'), '--help'], { encoding: 'utf-8', timeout: 5000 });
499
+ assert.ok(result.includes('Usage: gsd-daemon'));
500
+ assert.ok(result.includes('--config'));
501
+ assert.ok(result.includes('--verbose'));
502
+ });
503
+ it('starts, logs to file, and exits cleanly on SIGTERM', { timeout: 15000 }, async () => {
504
+ const dir = tmpDir();
505
+ cleanupDirs.push(dir);
506
+ const logPath = join(dir, 'integration.log');
507
+ const configPath = join(dir, 'daemon.yaml');
508
+ writeFileSync(configPath, `
509
+ projects:
510
+ scan_roots:
511
+ - /tmp/test-project
512
+ log:
513
+ file: "${logPath}"
514
+ level: info
515
+ max_size_mb: 10
516
+ `);
517
+ // Use execFile with a wrapper script approach: spawn, wait for start, SIGTERM, verify
518
+ const exitCode = await new Promise((resolve, reject) => {
519
+ const child = spawn(process.execPath, [join(__dirname, 'cli.js'), '--config', configPath], { stdio: 'ignore' });
520
+ let resolved = false;
521
+ child.on('error', (err) => { if (!resolved) {
522
+ resolved = true;
523
+ reject(err);
524
+ } });
525
+ child.on('exit', (code) => { if (!resolved) {
526
+ resolved = true;
527
+ resolve(code ?? 1);
528
+ } });
529
+ // Poll for startup, then send SIGTERM
530
+ const poll = setInterval(() => {
531
+ if (existsSync(logPath)) {
532
+ const content = readFileSync(logPath, 'utf-8');
533
+ if (content.includes('daemon started')) {
534
+ clearInterval(poll);
535
+ child.kill('SIGTERM');
536
+ }
537
+ }
538
+ }, 100);
539
+ // Safety: kill child if it takes too long
540
+ setTimeout(() => {
541
+ clearInterval(poll);
542
+ if (!resolved) {
543
+ child.kill('SIGKILL');
544
+ resolved = true;
545
+ reject(new Error('timed out waiting for daemon'));
546
+ }
547
+ }, 10000);
548
+ });
549
+ assert.equal(exitCode, 0, 'daemon should exit with code 0 on SIGTERM');
550
+ // Small delay for filesystem flush
551
+ await new Promise(r => setTimeout(r, 100));
552
+ // Verify log file contents
553
+ const finalContent = readFileSync(logPath, 'utf-8');
554
+ assert.ok(finalContent.includes('daemon started'), 'log should contain startup entry');
555
+ assert.ok(finalContent.includes('daemon shutting down'), 'log should contain shutdown entry');
556
+ // Verify log entries are valid JSON-lines
557
+ const lines = finalContent.trim().split('\n');
558
+ for (const line of lines) {
559
+ const entry = JSON.parse(line);
560
+ assert.ok(entry.ts, 'each entry should have a timestamp');
561
+ assert.ok(entry.level, 'each entry should have a level');
562
+ assert.ok(entry.msg, 'each entry should have a message');
563
+ }
564
+ });
565
+ it('exits with code 1 on invalid config', () => {
566
+ const dir = tmpDir();
567
+ cleanupDirs.push(dir);
568
+ const configPath = join(dir, 'bad.yaml');
569
+ writeFileSync(configPath, ':\n :\n bad: [unclosed');
570
+ try {
571
+ execFileSync(process.execPath, [join(__dirname, 'cli.js'), '--config', configPath], { encoding: 'utf-8', timeout: 5000 });
572
+ assert.fail('should have thrown');
573
+ }
574
+ catch (err) {
575
+ // execFileSync throws on non-zero exit
576
+ const execErr = err;
577
+ assert.equal(execErr.status, 1);
578
+ assert.ok(execErr.stderr.includes('fatal'));
579
+ }
580
+ });
581
+ });
582
+ // ---------- Daemon + SessionManager integration ----------
583
+ describe('Daemon integration', () => {
584
+ it('getSessionManager() returns SessionManager after start()', async () => {
585
+ const dir = tmpDir();
586
+ cleanupDirs.push(dir);
587
+ const logPath = join(dir, 'daemon-sm.log');
588
+ const config = {
589
+ discord: undefined,
590
+ projects: { scan_roots: [] },
591
+ log: { file: logPath, level: 'info', max_size_mb: 50 },
592
+ };
593
+ const logger = new Logger({ filePath: logPath, level: 'info' });
594
+ const daemon = new Daemon(config, logger);
595
+ await daemon.start();
596
+ const sm = daemon.getSessionManager();
597
+ assert.ok(sm instanceof SessionManager);
598
+ // Clean shutdown
599
+ const origExit = process.exit;
600
+ // @ts-expect-error — overriding process.exit for test
601
+ process.exit = () => { };
602
+ try {
603
+ await daemon.shutdown();
604
+ }
605
+ finally {
606
+ process.exit = origExit;
607
+ }
608
+ });
609
+ it('getSessionManager() throws before start()', async () => {
610
+ const dir = tmpDir();
611
+ cleanupDirs.push(dir);
612
+ const logPath = join(dir, 'daemon-nostart.log');
613
+ const config = {
614
+ discord: undefined,
615
+ projects: { scan_roots: [] },
616
+ log: { file: logPath, level: 'info', max_size_mb: 50 },
617
+ };
618
+ const logger = new Logger({ filePath: logPath, level: 'info' });
619
+ const daemon = new Daemon(config, logger);
620
+ assert.throws(() => daemon.getSessionManager(), (err) => {
621
+ assert.ok(err.message.includes('Daemon not started'));
622
+ return true;
623
+ });
624
+ // Close logger to prevent async write stream from hitting cleaned-up tmpdir
625
+ await logger.close();
626
+ });
627
+ it('scanProjects() delegates to scanForProjects with configured roots', async () => {
628
+ const dir = tmpDir();
629
+ cleanupDirs.push(dir);
630
+ const logPath = join(dir, 'daemon-scan.log');
631
+ // Create a fake project root with a project that has a .git marker
632
+ const scanRoot = join(dir, 'projects');
633
+ mkdirSync(scanRoot);
634
+ const projectDir = join(scanRoot, 'my-project');
635
+ mkdirSync(projectDir);
636
+ mkdirSync(join(projectDir, '.git'));
637
+ const config = {
638
+ discord: undefined,
639
+ projects: { scan_roots: [scanRoot] },
640
+ log: { file: logPath, level: 'info', max_size_mb: 50 },
641
+ };
642
+ const logger = new Logger({ filePath: logPath, level: 'info' });
643
+ const daemon = new Daemon(config, logger);
644
+ await daemon.start();
645
+ const projects = await daemon.scanProjects();
646
+ assert.ok(projects.length >= 1);
647
+ const found = projects.find(p => p.name === 'my-project');
648
+ assert.ok(found);
649
+ assert.ok(found.markers.includes('git'));
650
+ // Clean shutdown
651
+ const origExit = process.exit;
652
+ // @ts-expect-error — overriding process.exit for test
653
+ process.exit = () => { };
654
+ try {
655
+ await daemon.shutdown();
656
+ }
657
+ finally {
658
+ process.exit = origExit;
659
+ }
660
+ });
661
+ it('shutdown cleans up sessionManager before closing logger', async () => {
662
+ const dir = tmpDir();
663
+ cleanupDirs.push(dir);
664
+ const logPath = join(dir, 'daemon-cleanup.log');
665
+ const config = {
666
+ discord: undefined,
667
+ projects: { scan_roots: [] },
668
+ log: { file: logPath, level: 'info', max_size_mb: 50 },
669
+ };
670
+ const logger = new Logger({ filePath: logPath, level: 'info' });
671
+ const daemon = new Daemon(config, logger);
672
+ await daemon.start();
673
+ // Access sessionManager to verify it exists
674
+ const sm = daemon.getSessionManager();
675
+ assert.ok(sm);
676
+ // Shutdown — should not throw even though sessionManager has no active sessions
677
+ const origExit = process.exit;
678
+ // @ts-expect-error — overriding process.exit for test
679
+ process.exit = () => { };
680
+ try {
681
+ await daemon.shutdown();
682
+ }
683
+ finally {
684
+ process.exit = origExit;
685
+ }
686
+ // Verify log contains both started and shutting down
687
+ const content = readFileSync(logPath, 'utf-8');
688
+ assert.ok(content.includes('daemon started'));
689
+ assert.ok(content.includes('daemon shutting down'));
690
+ });
691
+ });
692
+ //# sourceMappingURL=daemon.test.js.map