gsd-pi 2.57.0 → 2.58.0-dev.d63175c

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 (194) hide show
  1. package/dist/resources/extensions/gsd/auto/infra-errors.js +4 -0
  2. package/dist/resources/extensions/gsd/auto-dispatch.js +3 -3
  3. package/dist/resources/extensions/gsd/auto-worktree.js +7 -2
  4. package/dist/resources/extensions/gsd/auto.js +4 -0
  5. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +2 -1
  6. package/dist/resources/extensions/gsd/dispatch-guard.js +11 -1
  7. package/dist/resources/extensions/gsd/gsd-db.js +8 -1
  8. package/dist/resources/extensions/gsd/parallel-orchestrator.js +23 -6
  9. package/dist/resources/extensions/gsd/preferences.js +29 -15
  10. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +2 -2
  11. package/dist/resources/extensions/gsd/tools/validate-milestone.js +4 -0
  12. package/dist/web/standalone/.next/BUILD_ID +1 -1
  13. package/dist/web/standalone/.next/app-path-routes-manifest.json +13 -13
  14. package/dist/web/standalone/.next/build-manifest.json +4 -4
  15. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  16. package/dist/web/standalone/.next/react-loadable-manifest.json +1 -1
  17. package/dist/web/standalone/.next/required-server-files.json +4 -4
  18. package/dist/web/standalone/.next/server/app/_global-error/page.js +3 -3
  19. package/dist/web/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  20. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  21. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found/page.js +2 -2
  29. package/dist/web/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.rsc +3 -3
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +3 -3
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/api/boot/route.js +1 -1
  39. package/dist/web/standalone/.next/server/app/api/boot/route_client-reference-manifest.js +1 -1
  40. package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route.js +1 -1
  41. package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route_client-reference-manifest.js +1 -1
  42. package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route.js +1 -1
  43. package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route_client-reference-manifest.js +1 -1
  44. package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route.js +2 -2
  45. package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route_client-reference-manifest.js +1 -1
  46. package/dist/web/standalone/.next/server/app/api/browse-directories/route.js +1 -1
  47. package/dist/web/standalone/.next/server/app/api/browse-directories/route_client-reference-manifest.js +1 -1
  48. package/dist/web/standalone/.next/server/app/api/captures/route.js +1 -1
  49. package/dist/web/standalone/.next/server/app/api/captures/route_client-reference-manifest.js +1 -1
  50. package/dist/web/standalone/.next/server/app/api/cleanup/route.js +1 -1
  51. package/dist/web/standalone/.next/server/app/api/cleanup/route_client-reference-manifest.js +1 -1
  52. package/dist/web/standalone/.next/server/app/api/dev-mode/route.js +1 -1
  53. package/dist/web/standalone/.next/server/app/api/dev-mode/route_client-reference-manifest.js +1 -1
  54. package/dist/web/standalone/.next/server/app/api/doctor/route.js +1 -1
  55. package/dist/web/standalone/.next/server/app/api/doctor/route_client-reference-manifest.js +1 -1
  56. package/dist/web/standalone/.next/server/app/api/experimental/route.js +2 -2
  57. package/dist/web/standalone/.next/server/app/api/experimental/route_client-reference-manifest.js +1 -1
  58. package/dist/web/standalone/.next/server/app/api/export-data/route.js +1 -1
  59. package/dist/web/standalone/.next/server/app/api/export-data/route_client-reference-manifest.js +1 -1
  60. package/dist/web/standalone/.next/server/app/api/files/route.js +1 -1
  61. package/dist/web/standalone/.next/server/app/api/files/route_client-reference-manifest.js +1 -1
  62. package/dist/web/standalone/.next/server/app/api/forensics/route.js +1 -1
  63. package/dist/web/standalone/.next/server/app/api/forensics/route_client-reference-manifest.js +1 -1
  64. package/dist/web/standalone/.next/server/app/api/git/route.js +1 -1
  65. package/dist/web/standalone/.next/server/app/api/git/route_client-reference-manifest.js +1 -1
  66. package/dist/web/standalone/.next/server/app/api/history/route.js +1 -1
  67. package/dist/web/standalone/.next/server/app/api/history/route_client-reference-manifest.js +1 -1
  68. package/dist/web/standalone/.next/server/app/api/hooks/route.js +1 -1
  69. package/dist/web/standalone/.next/server/app/api/hooks/route_client-reference-manifest.js +1 -1
  70. package/dist/web/standalone/.next/server/app/api/inspect/route.js +1 -1
  71. package/dist/web/standalone/.next/server/app/api/inspect/route_client-reference-manifest.js +1 -1
  72. package/dist/web/standalone/.next/server/app/api/knowledge/route.js +1 -1
  73. package/dist/web/standalone/.next/server/app/api/knowledge/route_client-reference-manifest.js +1 -1
  74. package/dist/web/standalone/.next/server/app/api/live-state/route.js +1 -1
  75. package/dist/web/standalone/.next/server/app/api/live-state/route_client-reference-manifest.js +1 -1
  76. package/dist/web/standalone/.next/server/app/api/onboarding/route.js +1 -1
  77. package/dist/web/standalone/.next/server/app/api/onboarding/route_client-reference-manifest.js +1 -1
  78. package/dist/web/standalone/.next/server/app/api/preferences/route.js +1 -1
  79. package/dist/web/standalone/.next/server/app/api/preferences/route_client-reference-manifest.js +1 -1
  80. package/dist/web/standalone/.next/server/app/api/projects/route.js +1 -1
  81. package/dist/web/standalone/.next/server/app/api/projects/route_client-reference-manifest.js +1 -1
  82. package/dist/web/standalone/.next/server/app/api/recovery/route.js +1 -1
  83. package/dist/web/standalone/.next/server/app/api/recovery/route_client-reference-manifest.js +1 -1
  84. package/dist/web/standalone/.next/server/app/api/remote-questions/route.js +2 -2
  85. package/dist/web/standalone/.next/server/app/api/remote-questions/route_client-reference-manifest.js +1 -1
  86. package/dist/web/standalone/.next/server/app/api/session/browser/route.js +1 -1
  87. package/dist/web/standalone/.next/server/app/api/session/browser/route_client-reference-manifest.js +1 -1
  88. package/dist/web/standalone/.next/server/app/api/session/command/route.js +1 -1
  89. package/dist/web/standalone/.next/server/app/api/session/command/route_client-reference-manifest.js +1 -1
  90. package/dist/web/standalone/.next/server/app/api/session/events/route.js +2 -2
  91. package/dist/web/standalone/.next/server/app/api/session/events/route_client-reference-manifest.js +1 -1
  92. package/dist/web/standalone/.next/server/app/api/session/manage/route.js +1 -1
  93. package/dist/web/standalone/.next/server/app/api/session/manage/route_client-reference-manifest.js +1 -1
  94. package/dist/web/standalone/.next/server/app/api/settings-data/route.js +1 -1
  95. package/dist/web/standalone/.next/server/app/api/settings-data/route_client-reference-manifest.js +1 -1
  96. package/dist/web/standalone/.next/server/app/api/shutdown/route.js +1 -1
  97. package/dist/web/standalone/.next/server/app/api/shutdown/route_client-reference-manifest.js +1 -1
  98. package/dist/web/standalone/.next/server/app/api/skill-health/route.js +1 -1
  99. package/dist/web/standalone/.next/server/app/api/skill-health/route_client-reference-manifest.js +1 -1
  100. package/dist/web/standalone/.next/server/app/api/steer/route.js +1 -1
  101. package/dist/web/standalone/.next/server/app/api/steer/route_client-reference-manifest.js +1 -1
  102. package/dist/web/standalone/.next/server/app/api/switch-root/route.js +1 -1
  103. package/dist/web/standalone/.next/server/app/api/switch-root/route_client-reference-manifest.js +1 -1
  104. package/dist/web/standalone/.next/server/app/api/terminal/input/route.js +2 -2
  105. package/dist/web/standalone/.next/server/app/api/terminal/input/route_client-reference-manifest.js +1 -1
  106. package/dist/web/standalone/.next/server/app/api/terminal/resize/route.js +2 -2
  107. package/dist/web/standalone/.next/server/app/api/terminal/resize/route_client-reference-manifest.js +1 -1
  108. package/dist/web/standalone/.next/server/app/api/terminal/sessions/route.js +2 -2
  109. package/dist/web/standalone/.next/server/app/api/terminal/sessions/route_client-reference-manifest.js +1 -1
  110. package/dist/web/standalone/.next/server/app/api/terminal/stream/route.js +4 -4
  111. package/dist/web/standalone/.next/server/app/api/terminal/stream/route_client-reference-manifest.js +1 -1
  112. package/dist/web/standalone/.next/server/app/api/terminal/upload/route.js +1 -1
  113. package/dist/web/standalone/.next/server/app/api/terminal/upload/route_client-reference-manifest.js +1 -1
  114. package/dist/web/standalone/.next/server/app/api/undo/route.js +1 -1
  115. package/dist/web/standalone/.next/server/app/api/undo/route_client-reference-manifest.js +1 -1
  116. package/dist/web/standalone/.next/server/app/api/update/route.js +1 -1
  117. package/dist/web/standalone/.next/server/app/api/update/route_client-reference-manifest.js +1 -1
  118. package/dist/web/standalone/.next/server/app/api/visualizer/route.js +1 -1
  119. package/dist/web/standalone/.next/server/app/api/visualizer/route_client-reference-manifest.js +1 -1
  120. package/dist/web/standalone/.next/server/app/index.html +1 -1
  121. package/dist/web/standalone/.next/server/app/index.rsc +4 -4
  122. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  123. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +4 -4
  124. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  125. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +3 -3
  126. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  127. package/dist/web/standalone/.next/server/app/page.js +2 -2
  128. package/dist/web/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  129. package/dist/web/standalone/.next/server/app-paths-manifest.json +13 -13
  130. package/dist/web/standalone/.next/server/chunks/2229.js +1 -1
  131. package/dist/web/standalone/.next/server/chunks/7471.js +3 -3
  132. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  133. package/dist/web/standalone/.next/server/middleware-react-loadable-manifest.js +1 -1
  134. package/dist/web/standalone/.next/server/middleware.js +2 -2
  135. package/dist/web/standalone/.next/server/next-font-manifest.js +1 -1
  136. package/dist/web/standalone/.next/server/next-font-manifest.json +1 -1
  137. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  138. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  139. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  140. package/dist/web/standalone/.next/static/chunks/6502.8b732f67a11b11b4.js +9 -0
  141. package/dist/web/standalone/.next/static/chunks/app/_not-found/{page-2f24283c162b6ab3.js → page-f2a7482d42a5614b.js} +1 -1
  142. package/dist/web/standalone/.next/static/chunks/app/{layout-9ecfd95f343793f0.js → layout-a16c7a7ecdf0c2cf.js} +1 -1
  143. package/dist/web/standalone/.next/static/chunks/app/page-0c485498795110d6.js +1 -0
  144. package/dist/web/standalone/.next/static/chunks/main-app-fdab67f7802d7832.js +1 -0
  145. package/dist/web/standalone/.next/static/chunks/next/dist/client/components/builtin/global-error-459824ffb8c323dd.js +1 -0
  146. package/dist/web/standalone/.next/static/chunks/{webpack-4332cbd5dd1be584.js → webpack-61d3afac6d0f0ce7.js} +1 -1
  147. package/dist/web/standalone/node_modules/node-pty/build/Makefile +2 -2
  148. package/dist/web/standalone/node_modules/node-pty/build/Release/pty.node +0 -0
  149. package/dist/web/standalone/node_modules/node-pty/build/pty.target.mk +14 -14
  150. package/dist/web/standalone/node_modules/node-pty/node-addon-api/node_addon_api.target.mk +14 -14
  151. package/dist/web/standalone/node_modules/node-pty/node-addon-api/node_addon_api_except.target.mk +14 -14
  152. package/dist/web/standalone/node_modules/node-pty/node-addon-api/node_addon_api_maybe.target.mk +14 -14
  153. package/dist/web/standalone/server.js +1 -1
  154. package/package.json +1 -1
  155. package/packages/daemon/src/cli.ts +49 -0
  156. package/packages/daemon/src/daemon.test.ts +104 -1
  157. package/packages/daemon/src/daemon.ts +24 -1
  158. package/packages/daemon/src/discord-bot.ts +73 -3
  159. package/packages/daemon/src/event-bridge.ts +15 -9
  160. package/packages/daemon/src/event-formatter.ts +30 -2
  161. package/packages/daemon/src/index.ts +9 -0
  162. package/packages/daemon/src/launchd.test.ts +356 -0
  163. package/packages/daemon/src/launchd.ts +242 -0
  164. package/packages/daemon/src/message-batcher.test.ts +2 -2
  165. package/packages/daemon/src/message-batcher.ts +9 -3
  166. package/packages/daemon/src/orchestrator.test.ts +1 -0
  167. package/packages/daemon/src/orchestrator.ts +106 -2
  168. package/packages/pi-coding-agent/package.json +1 -1
  169. package/pkg/package.json +1 -1
  170. package/src/resources/extensions/gsd/auto/infra-errors.ts +3 -0
  171. package/src/resources/extensions/gsd/auto-dispatch.ts +3 -3
  172. package/src/resources/extensions/gsd/auto-worktree.ts +7 -2
  173. package/src/resources/extensions/gsd/auto.ts +5 -0
  174. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +2 -1
  175. package/src/resources/extensions/gsd/dispatch-guard.ts +12 -1
  176. package/src/resources/extensions/gsd/gsd-db.ts +6 -1
  177. package/src/resources/extensions/gsd/parallel-orchestrator.ts +23 -6
  178. package/src/resources/extensions/gsd/preferences.ts +32 -14
  179. package/src/resources/extensions/gsd/prompts/validate-milestone.md +2 -2
  180. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +18 -0
  181. package/src/resources/extensions/gsd/tests/dispatch-guard.test.ts +47 -0
  182. package/src/resources/extensions/gsd/tests/parallel-worker-monitoring.test.ts +9 -8
  183. package/src/resources/extensions/gsd/tests/preferences.test.ts +34 -0
  184. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +7 -0
  185. package/src/resources/extensions/gsd/tests/validate-milestone-write-order.test.ts +23 -1
  186. package/src/resources/extensions/gsd/tests/validation-gate-patterns.test.ts +44 -2
  187. package/src/resources/extensions/gsd/tests/worktree-db-same-file.test.ts +175 -0
  188. package/src/resources/extensions/gsd/tools/validate-milestone.ts +5 -0
  189. package/dist/web/standalone/.next/static/chunks/6502.2305d0afd2385711.js +0 -9
  190. package/dist/web/standalone/.next/static/chunks/app/page-62be3b5fa91e4c8f.js +0 -1
  191. package/dist/web/standalone/.next/static/chunks/main-app-d3d4c336195465f9.js +0 -1
  192. package/dist/web/standalone/.next/static/chunks/next/dist/client/components/builtin/global-error-ab5a8926e07ec673.js +0 -1
  193. /package/dist/web/standalone/.next/static/{yowc5qPtuKxjOr22KmOAy → 5DLsjFHdSB6_a1EDQVjr7}/_buildManifest.js +0 -0
  194. /package/dist/web/standalone/.next/static/{yowc5qPtuKxjOr22KmOAy → 5DLsjFHdSB6_a1EDQVjr7}/_ssgManifest.js +0 -0
@@ -129,7 +129,73 @@ export class DiscordBot {
129
129
  this.handleInteraction(interaction);
130
130
  });
131
131
 
132
- await client.login(this.config.token);
132
+ // Debug: log all incoming messages at debug level
133
+ client.on('messageCreate', (msg) => {
134
+ this.logger.debug('raw messageCreate', {
135
+ authorId: msg.author.id,
136
+ authorBot: msg.author.bot,
137
+ channelId: msg.channelId,
138
+ contentLength: msg.content.length,
139
+ hasContent: msg.content.length > 0,
140
+ });
141
+ });
142
+
143
+ // Reconnection observability — structured logging for all shard lifecycle events (R027)
144
+ client.on('shardError', (error) => {
145
+ this.logger.error('discord shard error', { error: error.message });
146
+ });
147
+ client.on('shardDisconnect', (event, shardId) => {
148
+ this.logger.warn('discord shard disconnected', { shardId, code: event.code });
149
+ });
150
+ client.on('shardReconnecting', (shardId) => {
151
+ this.logger.info('discord shard reconnecting', { shardId });
152
+ });
153
+ client.on('shardResume', (shardId, replayedEvents) => {
154
+ this.logger.info('discord shard resumed', { shardId, replayedEvents });
155
+ });
156
+ client.on('warn', (message) => {
157
+ this.logger.warn('discord warning', { message });
158
+ });
159
+ client.on('error', (error) => {
160
+ this.logger.error('discord error', { error: error.message });
161
+ });
162
+
163
+ // Wait for both login AND the 'ready' event.
164
+ // client.login() resolves on WebSocket auth, but the 'ready' event fires
165
+ // asynchronously later. We need 'ready' before getChannelManager() works.
166
+ let readyTimeout: ReturnType<typeof setTimeout> | undefined;
167
+ let readySettled = false;
168
+ const readyPromise = new Promise<void>((resolve, reject) => {
169
+ readyTimeout = setTimeout(() => {
170
+ if (!readySettled) { readySettled = true; reject(new Error('Discord ready timeout (30s)')); }
171
+ }, 30_000);
172
+ const cleanup = () => {
173
+ if (readyTimeout) { clearTimeout(readyTimeout); readyTimeout = undefined; }
174
+ };
175
+ client.once('ready', () => {
176
+ cleanup();
177
+ if (!readySettled) { readySettled = true; resolve(); }
178
+ });
179
+ client.once('error', (err) => {
180
+ cleanup();
181
+ if (!readySettled) { readySettled = true; reject(err); }
182
+ });
183
+ // shardDisconnect fires on fatal gateway errors (e.g. 4014 disallowed intents)
184
+ client.once('shardDisconnect', (event) => {
185
+ cleanup();
186
+ if (!readySettled) { readySettled = true; reject(new Error(`Shard disconnected: ${event.code}`)); }
187
+ });
188
+ });
189
+
190
+ try {
191
+ await client.login(this.config.token);
192
+ } catch (err) {
193
+ // Login itself failed — clean up the ready timer so it doesn't fire as unhandled rejection
194
+ if (readyTimeout) { clearTimeout(readyTimeout); readyTimeout = undefined; }
195
+ readySettled = true;
196
+ throw err;
197
+ }
198
+ await readyPromise;
133
199
  this.client = client;
134
200
  this.destroyed = false;
135
201
  }
@@ -331,16 +397,20 @@ export class DiscordBot {
331
397
  const projectPath = collected.values[0];
332
398
  this.logger.info('gsd-start: project selected', { projectPath });
333
399
 
400
+ // Defer the update immediately — startSession can take 10-30s to spawn the GSD process,
401
+ // and Discord's component interaction token expires in 3 seconds without deferral.
402
+ await collected.deferUpdate();
403
+
334
404
  try {
335
405
  const sessionId = await this.sessionManager.startSession({ projectDir: projectPath });
336
- await collected.update({
406
+ await interaction.editReply({
337
407
  content: `✅ Session started for **${projectPath}** (ID: \`${sessionId}\`)`,
338
408
  components: [],
339
409
  });
340
410
  } catch (err) {
341
411
  const errMsg = err instanceof Error ? err.message : String(err);
342
412
  this.logger.error('gsd-start: startSession failed', { error: errMsg, projectPath });
343
- await collected.update({
413
+ await interaction.editReply({
344
414
  content: `❌ Failed to start session: ${errMsg}`,
345
415
  components: [],
346
416
  });
@@ -417,17 +417,23 @@ export class EventBridge {
417
417
  return;
418
418
  }
419
419
 
420
- // Otherwise, steer the session with the message content
421
- if (session.status === 'running') {
422
- try {
420
+ // Otherwise, relay the message to the GSD session
421
+ // Use steer() when running (injects mid-turn), prompt() otherwise (starts new turn)
422
+ try {
423
+ if (session.status === 'running') {
423
424
  await session.client.steer(message.content);
424
- await message.react('📨').catch(() => {});
425
- this.logger.info('bridge: message relayed to session', { sessionId });
426
- } catch (err) {
427
- const errMsg = err instanceof Error ? err.message : String(err);
428
- this.logger.error('bridge: steer failed', { sessionId, error: errMsg });
429
- await message.reply(`❌ Failed to relay message: ${errMsg}`).catch(() => {});
425
+ } else {
426
+ await session.client.prompt(message.content);
430
427
  }
428
+ await message.react('📨').catch(() => {});
429
+ this.logger.info('bridge: message relayed to session', {
430
+ sessionId,
431
+ method: session.status === 'running' ? 'steer' : 'prompt',
432
+ });
433
+ } catch (err) {
434
+ const errMsg = err instanceof Error ? err.message : String(err);
435
+ this.logger.error('bridge: relay failed', { sessionId, error: errMsg });
436
+ await message.reply(`❌ Failed to relay message: ${errMsg}`).catch(() => {});
431
437
  }
432
438
  }
433
439
 
@@ -102,14 +102,42 @@ export function formatToolEnd(event: SdkAgentEvent): FormattedEvent {
102
102
  export function formatMessage(event: SdkAgentEvent): FormattedEvent {
103
103
  // Extract text from content blocks or message field
104
104
  let text = '';
105
+
106
+ // Try content array first (most common for agent messages)
105
107
  if (Array.isArray(event.content)) {
106
108
  const blocks = event.content as Array<{ type?: string; text?: string }>;
107
109
  text = blocks
108
110
  .filter((b) => b.type === 'text' && typeof b.text === 'string')
109
111
  .map((b) => b.text!)
110
112
  .join('\n');
111
- } else {
112
- text = str(event.message || event.text || event.content);
113
+ }
114
+
115
+ // Try message field — could be string, object with content array, or object with text
116
+ if (!text && event.message != null) {
117
+ if (typeof event.message === 'string') {
118
+ text = event.message;
119
+ } else if (typeof event.message === 'object') {
120
+ const msg = event.message as Record<string, unknown>;
121
+ if (Array.isArray(msg.content)) {
122
+ const blocks = msg.content as Array<{ type?: string; text?: string }>;
123
+ text = blocks
124
+ .filter((b) => b.type === 'text' && typeof b.text === 'string')
125
+ .map((b) => b.text!)
126
+ .join('\n');
127
+ } else if (typeof msg.text === 'string') {
128
+ text = msg.text;
129
+ } else if (typeof msg.content === 'string') {
130
+ text = msg.content;
131
+ }
132
+ }
133
+ }
134
+
135
+ // Fallback to text or content as plain strings
136
+ if (!text) {
137
+ text = typeof event.text === 'string' ? event.text : '';
138
+ }
139
+ if (!text && typeof event.content === 'string') {
140
+ text = event.content;
113
141
  }
114
142
 
115
143
  if (!text) {
@@ -44,3 +44,12 @@ export {
44
44
  formatGenericEvent,
45
45
  formatEvent,
46
46
  } from './event-formatter.js';
47
+ export {
48
+ escapeXml,
49
+ generatePlist,
50
+ getPlistPath,
51
+ install as installLaunchAgent,
52
+ uninstall as uninstallLaunchAgent,
53
+ status as launchAgentStatus,
54
+ } from './launchd.js';
55
+ export type { PlistOptions, LaunchdStatus, RunCommandFn } from './launchd.js';
@@ -0,0 +1,356 @@
1
+ import { describe, it, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, existsSync, readFileSync, writeFileSync, rmSync, mkdirSync, statSync } from 'node:fs';
4
+ import { join, dirname } from 'node:path';
5
+ import { tmpdir, homedir } from 'node:os';
6
+ import { randomUUID } from 'node:crypto';
7
+ import {
8
+ escapeXml,
9
+ generatePlist,
10
+ getPlistPath,
11
+ install,
12
+ uninstall,
13
+ status,
14
+ } from './launchd.js';
15
+ import type { PlistOptions, RunCommandFn, LaunchdStatus } from './launchd.js';
16
+
17
+ // ---------- helpers ----------
18
+
19
+ function tmpDir(): string {
20
+ return mkdtempSync(join(tmpdir(), `launchd-test-${randomUUID().slice(0, 8)}-`));
21
+ }
22
+
23
+ const cleanupDirs: string[] = [];
24
+ afterEach(() => {
25
+ while (cleanupDirs.length) {
26
+ const d = cleanupDirs.pop()!;
27
+ if (existsSync(d)) rmSync(d, { recursive: true, force: true });
28
+ }
29
+ });
30
+
31
+ function basePlistOpts(overrides?: Partial<PlistOptions>): PlistOptions {
32
+ return {
33
+ nodePath: '/usr/local/bin/node',
34
+ scriptPath: '/usr/local/lib/gsd-daemon/dist/cli.js',
35
+ configPath: join(homedir(), '.gsd', 'daemon.yaml'),
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ // ---------- escapeXml ----------
41
+
42
+ describe('escapeXml', () => {
43
+ it('escapes & < > " \'', () => {
44
+ assert.equal(escapeXml('a&b<c>d"e\'f'), 'a&amp;b&lt;c&gt;d&quot;e&apos;f');
45
+ });
46
+
47
+ it('leaves plain strings untouched', () => {
48
+ assert.equal(escapeXml('/usr/local/bin/node'), '/usr/local/bin/node');
49
+ });
50
+
51
+ it('escapes paths with spaces and special chars', () => {
52
+ const input = '/Users/John & Jane/my "project"/file.js';
53
+ const output = escapeXml(input);
54
+ assert.ok(output.includes('&amp;'));
55
+ assert.ok(output.includes('&quot;'));
56
+ // Verify no raw unescaped & remain (all & are part of &amp; &lt; etc.)
57
+ assert.equal(output, '/Users/John &amp; Jane/my &quot;project&quot;/file.js');
58
+ });
59
+ });
60
+
61
+ // ---------- generatePlist ----------
62
+
63
+ describe('generatePlist', () => {
64
+ it('produces valid XML with plist header', () => {
65
+ const xml = generatePlist(basePlistOpts());
66
+ assert.ok(xml.startsWith('<?xml version="1.0"'));
67
+ assert.ok(xml.includes('<!DOCTYPE plist'));
68
+ assert.ok(xml.includes('<plist version="1.0">'));
69
+ assert.ok(xml.includes('</plist>'));
70
+ });
71
+
72
+ it('includes label com.gsd.daemon', () => {
73
+ const xml = generatePlist(basePlistOpts());
74
+ assert.ok(xml.includes('<string>com.gsd.daemon</string>'));
75
+ });
76
+
77
+ it('uses the absolute node path from opts', () => {
78
+ const opts = basePlistOpts({ nodePath: '/home/user/.nvm/versions/node/v22.0.0/bin/node' });
79
+ const xml = generatePlist(opts);
80
+ assert.ok(xml.includes('<string>/home/user/.nvm/versions/node/v22.0.0/bin/node</string>'));
81
+ });
82
+
83
+ it('includes NVM bin directory in PATH', () => {
84
+ const opts = basePlistOpts({ nodePath: '/home/user/.nvm/versions/node/v22.0.0/bin/node' });
85
+ const xml = generatePlist(opts);
86
+ assert.ok(xml.includes('/home/user/.nvm/versions/node/v22.0.0/bin'));
87
+ });
88
+
89
+ it('sets KeepAlive with SuccessfulExit false', () => {
90
+ const xml = generatePlist(basePlistOpts());
91
+ assert.ok(xml.includes('<key>KeepAlive</key>'));
92
+ assert.ok(xml.includes('<key>SuccessfulExit</key>'));
93
+ assert.ok(xml.includes('<false/>'));
94
+ });
95
+
96
+ it('sets RunAtLoad true', () => {
97
+ const xml = generatePlist(basePlistOpts());
98
+ assert.ok(xml.includes('<key>RunAtLoad</key>'));
99
+ assert.ok(xml.includes('<true/>'));
100
+ });
101
+
102
+ it('includes --config with the config path', () => {
103
+ const configPath = '/custom/path/daemon.yaml';
104
+ const xml = generatePlist(basePlistOpts({ configPath }));
105
+ assert.ok(xml.includes('<string>--config</string>'));
106
+ assert.ok(xml.includes(`<string>${configPath}</string>`));
107
+ });
108
+
109
+ it('includes HOME environment variable', () => {
110
+ const xml = generatePlist(basePlistOpts());
111
+ assert.ok(xml.includes('<key>HOME</key>'));
112
+ assert.ok(xml.includes(`<string>${homedir()}</string>`));
113
+ });
114
+
115
+ it('includes StandardOutPath and StandardErrorPath', () => {
116
+ const xml = generatePlist(basePlistOpts());
117
+ assert.ok(xml.includes('<key>StandardOutPath</key>'));
118
+ assert.ok(xml.includes('<key>StandardErrorPath</key>'));
119
+ });
120
+
121
+ it('escapes special characters in paths', () => {
122
+ const opts = basePlistOpts({
123
+ configPath: '/Users/John & Jane/config.yaml',
124
+ });
125
+ const xml = generatePlist(opts);
126
+ assert.ok(xml.includes('John &amp; Jane'));
127
+ assert.ok(!xml.includes('John & Jane'));
128
+ });
129
+
130
+ it('uses custom stdout/stderr paths when provided', () => {
131
+ const opts = basePlistOpts({
132
+ stdoutPath: '/tmp/my-stdout.log',
133
+ stderrPath: '/tmp/my-stderr.log',
134
+ });
135
+ const xml = generatePlist(opts);
136
+ assert.ok(xml.includes('<string>/tmp/my-stdout.log</string>'));
137
+ assert.ok(xml.includes('<string>/tmp/my-stderr.log</string>'));
138
+ });
139
+
140
+ it('uses custom working directory when provided', () => {
141
+ const opts = basePlistOpts({
142
+ workingDirectory: '/custom/work/dir',
143
+ });
144
+ const xml = generatePlist(opts);
145
+ assert.ok(xml.includes('<string>/custom/work/dir</string>'));
146
+ });
147
+ });
148
+
149
+ // ---------- getPlistPath ----------
150
+
151
+ describe('getPlistPath', () => {
152
+ it('returns ~/Library/LaunchAgents/com.gsd.daemon.plist', () => {
153
+ const expected = join(homedir(), 'Library', 'LaunchAgents', 'com.gsd.daemon.plist');
154
+ assert.equal(getPlistPath(), expected);
155
+ });
156
+ });
157
+
158
+ // ---------- install ----------
159
+
160
+ describe('install', () => {
161
+ let tmp: string;
162
+ let fakePlistPath: string;
163
+
164
+ // We can't mock getPlistPath directly, but we can verify the commands
165
+ // issued and the plist content by intercepting runCommand and filesystem ops.
166
+ // For filesystem testing, we test the functions that call writeFileSync indirectly
167
+ // by verifying the runCommand calls and returned values.
168
+
169
+ it('calls launchctl load with the plist path', () => {
170
+ const calls: string[] = [];
171
+ const mockRun: RunCommandFn = (cmd: string) => {
172
+ calls.push(cmd);
173
+ return '';
174
+ };
175
+
176
+ // install will try to write to the real plist path, so we need to be careful.
177
+ // We test the command flow by catching the writeFileSync error (dir may not exist in CI)
178
+ // or by letting it proceed in local dev.
179
+ try {
180
+ install(basePlistOpts(), mockRun);
181
+ } catch {
182
+ // writeFileSync may fail if ~/Library/LaunchAgents doesn't exist in test env
183
+ }
184
+
185
+ const loadCalls = calls.filter(c => c.startsWith('launchctl load'));
186
+ const listCalls = calls.filter(c => c.startsWith('launchctl list'));
187
+ // Should have at least attempted launchctl load
188
+ assert.ok(loadCalls.length > 0 || calls.length > 0, 'Expected launchctl commands to be called');
189
+ });
190
+
191
+ it('generates valid plist content when called', () => {
192
+ // Test that the plist content would be correct by testing generatePlist
193
+ // (install is a thin wrapper around generatePlist + writeFile + launchctl)
194
+ const xml = generatePlist(basePlistOpts());
195
+ assert.ok(xml.includes('<key>Label</key>'));
196
+ assert.ok(xml.includes('<string>com.gsd.daemon</string>'));
197
+ });
198
+
199
+ it('handles idempotent install (unloads first if plist exists)', () => {
200
+ const calls: string[] = [];
201
+ const mockRun: RunCommandFn = (cmd: string) => {
202
+ calls.push(cmd);
203
+ return '';
204
+ };
205
+
206
+ // To simulate idempotent install, we need an existing plist file.
207
+ // Since install writes to getPlistPath(), we test the command sequence.
208
+ try {
209
+ install(basePlistOpts(), mockRun);
210
+ // Second install
211
+ install(basePlistOpts(), mockRun);
212
+ } catch {
213
+ // filesystem may not be writable
214
+ }
215
+
216
+ // The second install should have tried to unload first
217
+ const unloadCalls = calls.filter(c => c.startsWith('launchctl unload'));
218
+ // If the plist path exists, we expect at least one unload attempt on second call
219
+ // This is a command-level check; filesystem existence depends on environment
220
+ });
221
+ });
222
+
223
+ // ---------- uninstall ----------
224
+
225
+ describe('uninstall', () => {
226
+ it('calls launchctl unload when plist would exist', () => {
227
+ const calls: string[] = [];
228
+ const mockRun: RunCommandFn = (cmd: string) => {
229
+ calls.push(cmd);
230
+ return '';
231
+ };
232
+
233
+ // uninstall checks existsSync(plistPath) — if plist doesn't exist, it's a no-op
234
+ uninstall(mockRun);
235
+
236
+ // If plist doesn't exist in test environment, calls should be empty (graceful)
237
+ // That's the "handles missing plist gracefully" case
238
+ });
239
+
240
+ it('handles missing plist gracefully (no-op)', () => {
241
+ const calls: string[] = [];
242
+ const mockRun: RunCommandFn = (cmd: string) => {
243
+ calls.push(cmd);
244
+ return '';
245
+ };
246
+
247
+ // Shouldn't throw even if plist doesn't exist
248
+ assert.doesNotThrow(() => uninstall(mockRun));
249
+ });
250
+
251
+ it('handles already-unloaded agent gracefully', () => {
252
+ const mockRun: RunCommandFn = (cmd: string) => {
253
+ if (cmd.includes('launchctl unload')) {
254
+ throw new Error('Could not find specified service');
255
+ }
256
+ return '';
257
+ };
258
+
259
+ // Should not throw even if launchctl unload fails
260
+ assert.doesNotThrow(() => uninstall(mockRun));
261
+ });
262
+ });
263
+
264
+ // ---------- status ----------
265
+
266
+ describe('status', () => {
267
+ it('parses running daemon output (PID present)', () => {
268
+ const mockRun: RunCommandFn = (_cmd: string) => {
269
+ return '{\n\t"PID" = 1234;\n\t"Label" = "com.gsd.daemon";\n}\nPID\tStatus\tLabel\n1234\t0\tcom.gsd.daemon\n';
270
+ };
271
+
272
+ const result = status(mockRun);
273
+ assert.equal(result.registered, true);
274
+ assert.equal(result.pid, 1234);
275
+ assert.equal(result.lastExitStatus, 0);
276
+ });
277
+
278
+ it('parses stopped daemon output (no PID)', () => {
279
+ const mockRun: RunCommandFn = (_cmd: string) => {
280
+ return 'PID\tStatus\tLabel\n-\t78\tcom.gsd.daemon\n';
281
+ };
282
+
283
+ const result = status(mockRun);
284
+ assert.equal(result.registered, true);
285
+ assert.equal(result.pid, null);
286
+ assert.equal(result.lastExitStatus, 78);
287
+ });
288
+
289
+ it('returns not-registered when launchctl list fails', () => {
290
+ const mockRun: RunCommandFn = (_cmd: string) => {
291
+ throw new Error('Could not find service "com.gsd.daemon" in domain for port');
292
+ };
293
+
294
+ const result = status(mockRun);
295
+ assert.equal(result.registered, false);
296
+ assert.equal(result.pid, null);
297
+ assert.equal(result.lastExitStatus, null);
298
+ });
299
+
300
+ it('returns structured result with all fields', () => {
301
+ const mockRun: RunCommandFn = (_cmd: string) => {
302
+ return 'PID\tStatus\tLabel\n5678\t0\tcom.gsd.daemon\n';
303
+ };
304
+
305
+ const result = status(mockRun);
306
+ assert.ok('registered' in result);
307
+ assert.ok('pid' in result);
308
+ assert.ok('lastExitStatus' in result);
309
+ });
310
+
311
+ it('parses JSON-style dict output (newer macOS)', () => {
312
+ const mockRun: RunCommandFn = (_cmd: string) => {
313
+ return `{
314
+ \t"StandardOutPath" = "/Users/me/.gsd/daemon-stdout.log";
315
+ \t"LimitLoadToSessionType" = "Aqua";
316
+ \t"StandardErrorPath" = "/Users/me/.gsd/daemon-stderr.log";
317
+ \t"Label" = "com.gsd.daemon";
318
+ \t"OnDemand" = true;
319
+ \t"LastExitStatus" = 0;
320
+ \t"PID" = 23802;
321
+ \t"Program" = "/usr/local/bin/node";
322
+ };`;
323
+ };
324
+
325
+ const result = status(mockRun);
326
+ assert.equal(result.registered, true);
327
+ assert.equal(result.pid, 23802);
328
+ assert.equal(result.lastExitStatus, 0);
329
+ });
330
+
331
+ it('parses JSON-style dict output when daemon stopped (no PID key)', () => {
332
+ const mockRun: RunCommandFn = (_cmd: string) => {
333
+ return `{
334
+ \t"Label" = "com.gsd.daemon";
335
+ \t"LastExitStatus" = 1;
336
+ \t"OnDemand" = true;
337
+ };`;
338
+ };
339
+
340
+ const result = status(mockRun);
341
+ assert.equal(result.registered, true);
342
+ assert.equal(result.pid, null);
343
+ assert.equal(result.lastExitStatus, 1);
344
+ });
345
+
346
+ it('handles unexpected output format gracefully', () => {
347
+ const mockRun: RunCommandFn = (_cmd: string) => {
348
+ return 'some unexpected output without the label';
349
+ };
350
+
351
+ // Should not throw — should return registered:true but with null fields
352
+ // since the command succeeded (label was found) but output didn't match
353
+ const result = status(mockRun);
354
+ assert.equal(result.registered, true);
355
+ });
356
+ });